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' }}
+
+
@@ -86,6 +220,55 @@ h1 { font-size: 1.5rem; font-weight: 700; margin-bottom: 1.5rem; }
.field { margin-bottom: 1rem; }
.field label { display: block; font-size: 0.8125rem; font-weight: 500; color: var(--text-secondary); margin-bottom: 0.375rem; }
.field textarea { resize: vertical; font-family: inherit; }
+
+.image-preview-grid {
+ display: flex;
+ gap: 0.75rem;
+ flex-wrap: wrap;
+ margin-bottom: 0.75rem;
+}
+
+.image-preview {
+ position: relative;
+ width: 80px;
+ height: 100px;
+ border-radius: var(--radius-sm);
+ overflow: hidden;
+ border: 1px solid var(--glass-border);
+}
+
+.image-preview img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.image-remove {
+ position: absolute;
+ top: 2px;
+ right: 2px;
+ width: 20px;
+ height: 20px;
+ border-radius: 50%;
+ background: var(--error);
+ color: #fff;
+ border: none;
+ font-size: 0.75rem;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.upload-btn {
+ cursor: pointer;
+ display: inline-flex;
+}
+
+.upload-input {
+ display: none;
+}
+
.sizes-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.375rem; }
.add-size-btn { background: none; border: none; color: var(--accent); font-size: 0.8125rem; cursor: pointer; }
.size-row { display: flex; gap: 0.5rem; margin-bottom: 0.5rem; align-items: center; }
diff --git a/tests/server/btcpay.test.ts b/tests/server/btcpay.test.ts
new file mode 100644
index 0000000..4ba690d
--- /dev/null
+++ b/tests/server/btcpay.test.ts
@@ -0,0 +1,38 @@
+import { describe, it, expect } from 'vitest'
+import crypto from 'node:crypto'
+import { validateWebhookSignature } from '../../server/lib/btcpay.js'
+
+describe('BTCPay webhook validation', () => {
+ const secret = 'test-webhook-secret-key'
+
+ it('validates correct HMAC signature', () => {
+ process.env.BTCPAY_WEBHOOK_SECRET = secret
+ const body = '{"type":"InvoiceSettled","invoiceId":"test123"}'
+ const hmac = crypto.createHmac('sha256', secret).update(body).digest('hex')
+
+ expect(validateWebhookSignature(body, `sha256=${hmac}`)).toBe(true)
+ })
+
+ it('rejects incorrect signature', () => {
+ process.env.BTCPAY_WEBHOOK_SECRET = secret
+ const body = '{"type":"InvoiceSettled","invoiceId":"test123"}'
+
+ expect(validateWebhookSignature(body, 'sha256=deadbeef00112233445566778899aabbccddeeff00112233445566778899aabb')).toBe(false)
+ })
+
+ it('rejects when no secret configured', () => {
+ delete process.env.BTCPAY_WEBHOOK_SECRET
+ const body = '{"type":"InvoiceSettled"}'
+
+ expect(validateWebhookSignature(body, 'sha256=anything')).toBe(false)
+ })
+
+ it('rejects tampered body', () => {
+ process.env.BTCPAY_WEBHOOK_SECRET = secret
+ const body = '{"type":"InvoiceSettled","invoiceId":"test123"}'
+ const hmac = crypto.createHmac('sha256', secret).update(body).digest('hex')
+
+ const tamperedBody = '{"type":"InvoiceSettled","invoiceId":"hacked"}'
+ expect(validateWebhookSignature(tamperedBody, `sha256=${hmac}`)).toBe(false)
+ })
+})
diff --git a/tests/server/crypto.test.ts b/tests/server/crypto.test.ts
new file mode 100644
index 0000000..9f57d9b
--- /dev/null
+++ b/tests/server/crypto.test.ts
@@ -0,0 +1,55 @@
+import { describe, it, expect, beforeAll, afterAll } from 'vitest'
+import crypto from 'node:crypto'
+
+// Generate a test key and set it before importing the module
+const testKey = crypto.randomBytes(32).toString('hex')
+
+describe('server/lib/crypto', () => {
+ beforeAll(() => {
+ process.env.ENCRYPTION_KEY = testKey
+ })
+
+ afterAll(() => {
+ delete process.env.ENCRYPTION_KEY
+ })
+
+ it('encrypts and decrypts shipping address', async () => {
+ const { encrypt, decrypt } = await import('../../server/lib/crypto.js')
+
+ const address = JSON.stringify({
+ name: 'Satoshi Nakamoto',
+ line1: '1 Bitcoin Ave',
+ city: 'Cryptoville',
+ postalCode: '21000',
+ country: 'Decentraland',
+ })
+
+ const encrypted = encrypt(address)
+ expect(encrypted).not.toBe(address)
+ expect(encrypted).toMatch(/^[0-9a-f]+$/)
+
+ const decrypted = decrypt(encrypted)
+ expect(decrypted).toBe(address)
+ expect(JSON.parse(decrypted).name).toBe('Satoshi Nakamoto')
+ })
+
+ it('produces different ciphertext for same plaintext', async () => {
+ const { encrypt } = await import('../../server/lib/crypto.js')
+
+ const plaintext = 'same input'
+ const a = encrypt(plaintext)
+ const b = encrypt(plaintext)
+
+ expect(a).not.toBe(b) // Random nonce ensures different output
+ })
+
+ it('fails on tampered ciphertext', async () => {
+ const { encrypt, decrypt } = await import('../../server/lib/crypto.js')
+
+ const encrypted = encrypt('secret data')
+ // Flip a byte in the middle
+ const tampered = encrypted.slice(0, 40) + 'ff' + encrypted.slice(42)
+
+ expect(() => decrypt(tampered)).toThrow()
+ })
+})
diff --git a/tests/server/validate.test.ts b/tests/server/validate.test.ts
new file mode 100644
index 0000000..bb663e0
--- /dev/null
+++ b/tests/server/validate.test.ts
@@ -0,0 +1,47 @@
+import { describe, it, expect } from 'vitest'
+import { sanitizeString, sanitizeInt } from '../../server/middleware/validate.js'
+
+describe('sanitizeString', () => {
+ it('trims whitespace', () => {
+ expect(sanitizeString(' hello ')).toBe('hello')
+ })
+
+ it('truncates to 10000 chars', () => {
+ const long = 'a'.repeat(20_000)
+ expect(sanitizeString(long)).toHaveLength(10_000)
+ })
+
+ it('returns empty string for non-string input', () => {
+ expect(sanitizeString(123)).toBe('')
+ expect(sanitizeString(null)).toBe('')
+ expect(sanitizeString(undefined)).toBe('')
+ })
+})
+
+describe('sanitizeInt', () => {
+ it('accepts valid positive integers', () => {
+ expect(sanitizeInt(42)).toBe(42)
+ expect(sanitizeInt(0)).toBe(0)
+ expect(sanitizeInt(100_000)).toBe(100_000)
+ })
+
+ it('rejects negative numbers', () => {
+ expect(sanitizeInt(-1)).toBeNull()
+ expect(sanitizeInt(-100)).toBeNull()
+ })
+
+ it('rejects floats', () => {
+ expect(sanitizeInt(1.5)).toBeNull()
+ expect(sanitizeInt(0.001)).toBeNull()
+ })
+
+ it('rejects non-numeric input', () => {
+ expect(sanitizeInt('abc')).toBeNull()
+ expect(sanitizeInt(NaN)).toBeNull()
+ })
+
+ it('converts string numbers', () => {
+ expect(sanitizeInt('42')).toBe(42)
+ expect(sanitizeInt('0')).toBe(0)
+ })
+})
diff --git a/tests/shared/types.test.ts b/tests/shared/types.test.ts
new file mode 100644
index 0000000..208d2a8
--- /dev/null
+++ b/tests/shared/types.test.ts
@@ -0,0 +1,52 @@
+import { describe, it, expect } from 'vitest'
+import type { Product, Order, CartItem, OrderStatus, SizeStock } from '@shared/types'
+
+describe('shared types', () => {
+ it('Product type enforces integer sats', () => {
+ const product: Product = {
+ id: 'test-1',
+ name: 'Test Product',
+ slug: 'test-product',
+ description: 'A test product',
+ priceSats: 100_000,
+ images: ['/images/test.jpg'],
+ sizes: [{ size: 'M', stock: 10 }],
+ category: 'tops',
+ isActive: true,
+ createdAt: '2026-01-01T00:00:00Z',
+ updatedAt: '2026-01-01T00:00:00Z',
+ }
+ expect(product.priceSats).toBe(100_000)
+ expect(Number.isInteger(product.priceSats)).toBe(true)
+ })
+
+ it('CartItem calculates correctly with integer sats', () => {
+ const item: CartItem = {
+ productId: 'test-1',
+ slug: 'test-product',
+ name: 'Test Product',
+ size: 'M',
+ quantity: 3,
+ priceSats: 85_000,
+ image: '/images/test.jpg',
+ }
+ const total = item.priceSats * item.quantity
+ expect(total).toBe(255_000)
+ expect(Number.isInteger(total)).toBe(true)
+ })
+
+ it('OrderStatus has valid values', () => {
+ const statuses: OrderStatus[] = ['pending', 'paid', 'confirmed', 'shipped', 'delivered', 'cancelled']
+ expect(statuses).toHaveLength(6)
+ })
+
+ it('SizeStock tracks integer stock levels', () => {
+ const sizes: SizeStock[] = [
+ { size: 'S', stock: 10 },
+ { size: 'M', stock: 0 },
+ ]
+ const totalStock = sizes.reduce((sum, s) => sum + s.stock, 0)
+ expect(totalStock).toBe(10)
+ expect(sizes[1].stock).toBe(0)
+ })
+})
diff --git a/vitest.config.ts b/vitest.config.ts
new file mode 100644
index 0000000..d127eef
--- /dev/null
+++ b/vitest.config.ts
@@ -0,0 +1,15 @@
+import { defineConfig } from 'vitest/config'
+import { fileURLToPath, URL } from 'node:url'
+
+export default defineConfig({
+ resolve: {
+ alias: {
+ '@': fileURLToPath(new URL('./src', import.meta.url)),
+ '@shared': fileURLToPath(new URL('./shared', import.meta.url)),
+ },
+ },
+ test: {
+ environment: 'node',
+ include: ['tests/**/*.test.ts'],
+ },
+})