feat: scaffold Antonym fashion store

Anonymous Bitcoin-only fashion e-commerce with:
- Vue 3 + Tailwind 4 frontend with glassmorphism dark/light design system
- Express 5 + SQLite backend with BTCPay Server integration
- Nostr identity (NIP-07/keypair) for anonymous purchase tracking
- ChaCha20-Poly1305 encrypted shipping addresses
- Admin panel with order/product/stock management
- SVG logo splash animation with clip-path reveal
- 5 seeded products across 4 categories

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-17 00:23:21 +00:00
commit 54500a68e6
64 changed files with 6983 additions and 0 deletions

22
server/routes/admin.ts Normal file
View File

@@ -0,0 +1,22 @@
import { Router } from 'express'
import { rateLimit, createSession, deleteSession, verifyPassword, adminAuth } from '../middleware/adminAuth.js'
import { requireBody } from '../middleware/validate.js'
export const adminRouter = Router()
adminRouter.post('/login', rateLimit, requireBody('password'), (req, res) => {
const { password } = req.body as { password: string }
if (!verifyPassword(password)) { res.status(401).json({ error: { code: 'INVALID_PASSWORD', message: 'Invalid password' } }); return }
const token = createSession()
res.cookie('admin_session', token, { httpOnly: true, sameSite: 'strict', secure: process.env.NODE_ENV === 'production', maxAge: 24 * 60 * 60 * 1000 })
res.json({ ok: true })
})
adminRouter.post('/logout', adminAuth, (req, res) => {
const token = req.cookies?.admin_session
if (token) deleteSession(token)
res.clearCookie('admin_session')
res.json({ ok: true })
})
adminRouter.get('/verify', adminAuth, (_req, res) => { res.json({ ok: true }) })

View File

@@ -0,0 +1,49 @@
import { Router } from 'express'
import { getDb } from '../db/connection.js'
import { adminAuth } from '../middleware/adminAuth.js'
import { requireBody, sanitizeString } from '../middleware/validate.js'
import { decrypt } from '../lib/crypto.js'
import { sendStatusUpdate } from '../lib/mailer.js'
import { notifyStatusUpdate } from '../lib/nostr.js'
import type { Order, OrderItem, OrderEvent, OrderStatus, ShippingAddress } from '../../shared/types.js'
export const adminOrdersRouter = Router()
adminOrdersRouter.use(adminAuth)
interface OrderRow { id: string; nostr_pubkey: string | null; email: string | null; btcpay_invoice_id: string | null; status: string; shipping_address_encrypted: string | null; items: string; total_sats: number; note: string | null; created_at: string; updated_at: string }
const VALID_STATUSES: OrderStatus[] = ['pending', 'paid', 'confirmed', 'shipped', 'delivered', 'cancelled']
adminOrdersRouter.get('/', (req, res) => {
const db = getDb()
const status = req.query.status as string | undefined
let rows: OrderRow[]
if (status) { rows = db.prepare('SELECT * FROM orders WHERE status = ? ORDER BY created_at DESC').all(status) as OrderRow[] }
else { rows = db.prepare('SELECT * FROM orders ORDER BY created_at DESC').all() as OrderRow[] }
res.json(rows.map((row) => ({ id: row.id, nostrPubkey: row.nostr_pubkey, email: row.email, btcpayInvoiceId: row.btcpay_invoice_id, status: row.status as OrderStatus, items: JSON.parse(row.items) as OrderItem[], totalSats: row.total_sats, note: row.note, createdAt: row.created_at, updatedAt: row.updated_at })))
})
adminOrdersRouter.get('/:id', (req, res) => {
const db = getDb()
const row = db.prepare('SELECT * FROM orders WHERE id = ?').get(req.params.id) as OrderRow | undefined
if (!row) { res.status(404).json({ error: { code: 'NOT_FOUND', message: 'Order not found' } }); return }
let shippingAddress: ShippingAddress | null = null
if (row.shipping_address_encrypted) { try { shippingAddress = JSON.parse(decrypt(row.shipping_address_encrypted)) as ShippingAddress } catch { shippingAddress = null } }
const events = db.prepare('SELECT * FROM order_events WHERE order_id = ? ORDER BY created_at ASC').all(req.params.id) as OrderEvent[]
const order: Order & { shippingAddress: ShippingAddress | null; events: OrderEvent[] } = { id: row.id, nostrPubkey: row.nostr_pubkey, email: row.email, btcpayInvoiceId: row.btcpay_invoice_id, status: row.status as OrderStatus, items: JSON.parse(row.items) as OrderItem[], totalSats: row.total_sats, note: row.note, createdAt: row.created_at, updatedAt: row.updated_at, shippingAddress, events }
res.json(order)
})
adminOrdersRouter.patch('/:id/status', requireBody('status'), (req, res) => {
const db = getDb()
const { status, note } = req.body as { status: string; note?: string }
if (!VALID_STATUSES.includes(status as OrderStatus)) { res.status(400).json({ error: { code: 'INVALID_STATUS', message: `Status must be one of: ${VALID_STATUSES.join(', ')}` } }); return }
const order = db.prepare('SELECT * FROM orders WHERE id = ?').get(req.params.id) as OrderRow | undefined
if (!order) { res.status(404).json({ error: { code: 'NOT_FOUND', message: 'Order not found' } }); return }
const noteText = note ? sanitizeString(note) : null
db.prepare("UPDATE orders SET status = ?, updated_at = datetime('now') WHERE id = ?").run(status, req.params.id)
db.prepare('INSERT INTO order_events (order_id, status, note) VALUES (?, ?, ?)').run(req.params.id, status, noteText)
if (order.email) sendStatusUpdate(order.email, order.id, status, noteText ?? undefined).catch(() => {})
if (order.nostr_pubkey) notifyStatusUpdate(order.nostr_pubkey, order.id, status, noteText ?? undefined).catch(() => {})
res.json({ ok: true, status })
})

View File

@@ -0,0 +1,67 @@
import { Router } from 'express'
import { nanoid } from 'nanoid'
import { getDb } from '../db/connection.js'
import { adminAuth } from '../middleware/adminAuth.js'
import { requireBody, sanitizeString, sanitizeInt } from '../middleware/validate.js'
import { rowToProduct } from './products.js'
export const adminProductsRouter = Router()
adminProductsRouter.use(adminAuth)
interface ProductRow { id: string; name: string; slug: string; description: string; price_sats: number; images: string; sizes: string; category: string; is_active: number; created_at: string; updated_at: string }
adminProductsRouter.get('/', (_req, res) => {
const db = getDb()
const rows = db.prepare('SELECT * FROM products ORDER BY created_at DESC').all() as ProductRow[]
res.json(rows.map(rowToProduct))
})
adminProductsRouter.post('/', requireBody('name', 'slug', 'priceSats'), (req, res) => {
const db = getDb()
const { name, slug, description, priceSats, images, sizes, category } = req.body
const price = sanitizeInt(priceSats)
if (price === null || price <= 0) { res.status(400).json({ error: { code: 'INVALID_PRICE', message: 'Price must be a positive integer (sats)' } }); return }
const id = nanoid()
try {
db.prepare('INSERT INTO products (id, name, slug, description, price_sats, images, sizes, category) VALUES (?, ?, ?, ?, ?, ?, ?, ?)').run(id, sanitizeString(name), sanitizeString(slug), sanitizeString(description || ''), price, JSON.stringify(images || []), JSON.stringify(sizes || []), sanitizeString(category || 'general'))
const row = db.prepare('SELECT * FROM products WHERE id = ?').get(id) as ProductRow
res.status(201).json(rowToProduct(row))
} catch (err: unknown) {
if (err instanceof Error && err.message.includes('UNIQUE')) { res.status(409).json({ error: { code: 'SLUG_EXISTS', message: 'A product with this slug already exists' } }); return }
throw err
}
})
adminProductsRouter.put('/:id', (req, res) => {
const db = getDb()
const { name, slug, description, priceSats, images, sizes, category, isActive } = req.body
const existing = db.prepare('SELECT id FROM products WHERE id = ?').get(req.params.id)
if (!existing) { res.status(404).json({ error: { code: 'NOT_FOUND', message: 'Product not found' } }); return }
const price = sanitizeInt(priceSats)
if (price !== null && price <= 0) { res.status(400).json({ error: { code: 'INVALID_PRICE', message: 'Price must be a positive integer (sats)' } }); return }
try {
db.prepare("UPDATE products SET name = COALESCE(?, name), slug = COALESCE(?, slug), description = COALESCE(?, description), price_sats = COALESCE(?, price_sats), images = COALESCE(?, images), sizes = COALESCE(?, sizes), category = COALESCE(?, category), is_active = COALESCE(?, is_active), updated_at = datetime('now') WHERE id = ?").run(name ? sanitizeString(name) : null, slug ? sanitizeString(slug) : null, description !== undefined ? sanitizeString(description) : null, price, images ? JSON.stringify(images) : null, sizes ? JSON.stringify(sizes) : null, category ? sanitizeString(category) : null, isActive !== undefined ? (isActive ? 1 : 0) : null, req.params.id)
const row = db.prepare('SELECT * FROM products WHERE id = ?').get(req.params.id) as ProductRow
res.json(rowToProduct(row))
} catch (err: unknown) {
if (err instanceof Error && err.message.includes('UNIQUE')) { res.status(409).json({ error: { code: 'SLUG_EXISTS', message: 'A product with this slug already exists' } }); return }
throw err
}
})
adminProductsRouter.delete('/:id', (req, res) => {
const db = getDb()
const result = db.prepare("UPDATE products SET is_active = 0, updated_at = datetime('now') WHERE id = ?").run(req.params.id)
if (result.changes === 0) { res.status(404).json({ error: { code: 'NOT_FOUND', message: 'Product not found' } }); return }
res.json({ ok: true })
})
adminProductsRouter.patch('/:id/stock', requireBody('sizes'), (req, res) => {
const db = getDb()
const { sizes } = req.body
if (!Array.isArray(sizes)) { res.status(400).json({ error: { code: 'INVALID_SIZES', message: 'Sizes must be an array' } }); return }
const result = db.prepare("UPDATE products SET sizes = ?, updated_at = datetime('now') WHERE id = ?").run(JSON.stringify(sizes), req.params.id)
if (result.changes === 0) { res.status(404).json({ error: { code: 'NOT_FOUND', message: 'Product not found' } }); return }
const row = db.prepare('SELECT * FROM products WHERE id = ?').get(req.params.id) as ProductRow
res.json(rowToProduct(row))
})

56
server/routes/orders.ts Normal file
View File

@@ -0,0 +1,56 @@
import { Router } from 'express'
import { nanoid } from 'nanoid'
import { getDb } from '../db/connection.js'
import { encrypt } from '../lib/crypto.js'
import { createInvoice } from '../lib/btcpay.js'
import { requireBody, sanitizeString } from '../middleware/validate.js'
import type { CreateOrderRequest, Order, OrderItem, OrderEvent, OrderStatus } from '../../shared/types.js'
export const ordersRouter = Router()
interface OrderRow { id: string; nostr_pubkey: string | null; email: string | null; btcpay_invoice_id: string | null; status: string; shipping_address_encrypted: string | null; items: string; total_sats: number; note: string | null; created_at: string; updated_at: string }
function rowToOrder(row: OrderRow): Order {
return { id: row.id, nostrPubkey: row.nostr_pubkey, email: row.email, btcpayInvoiceId: row.btcpay_invoice_id, status: row.status as OrderStatus, items: JSON.parse(row.items) as OrderItem[], totalSats: row.total_sats, note: row.note, createdAt: row.created_at, updatedAt: row.updated_at }
}
ordersRouter.post('/', requireBody('items', 'shippingAddress'), async (req, res) => {
try {
const body = req.body as CreateOrderRequest
const db = getDb()
if (!Array.isArray(body.items) || body.items.length === 0) { res.status(400).json({ error: { code: 'INVALID_ITEMS', message: 'Order must contain items' } }); return }
const orderItems: OrderItem[] = []
let totalSats = 0
for (const item of body.items) {
const product = db.prepare('SELECT * FROM products WHERE id = ? AND is_active = 1').get(item.productId) as { name: string; price_sats: number; sizes: string } | undefined
if (!product) { res.status(400).json({ error: { code: 'INVALID_PRODUCT', message: `Product ${item.productId} not found` } }); return }
const sizes = JSON.parse(product.sizes) as { size: string; stock: number }[]
const sizeEntry = sizes.find((s) => s.size === item.size)
if (!sizeEntry || sizeEntry.stock < item.quantity) { res.status(400).json({ error: { code: 'INSUFFICIENT_STOCK', message: `Insufficient stock for ${product.name} size ${item.size}` } }); return }
const lineTotal = product.price_sats * item.quantity
totalSats += lineTotal
orderItems.push({ productId: item.productId, productName: product.name, size: item.size, quantity: item.quantity, priceSats: product.price_sats })
}
const orderId = nanoid()
const encryptedAddress = encrypt(JSON.stringify(body.shippingAddress))
const siteUrl = process.env.SITE_URL || 'http://localhost:3333'
const invoice = await createInvoice({ amountSats: totalSats, orderId, redirectUrl: `${siteUrl}/order/${orderId}` })
db.prepare('INSERT INTO orders (id, nostr_pubkey, email, btcpay_invoice_id, status, shipping_address_encrypted, items, total_sats, note) VALUES (?, ?, ?, ?, \'pending\', ?, ?, ?, ?)').run(orderId, body.nostrPubkey ? sanitizeString(body.nostrPubkey) : null, body.email ? sanitizeString(body.email) : null, invoice.id, encryptedAddress, JSON.stringify(orderItems), totalSats, body.note ? sanitizeString(body.note) : null)
db.prepare('INSERT INTO order_events (order_id, status, note) VALUES (?, ?, ?)').run(orderId, 'pending', 'Order created')
res.status(201).json({ orderId, invoiceUrl: invoice.checkoutLink, invoiceId: invoice.id })
} catch (err) { console.error('Order creation failed:', err); res.status(500).json({ error: { code: 'ORDER_FAILED', message: 'Failed to create order' } }) }
})
ordersRouter.get('/:id', (req, res) => {
const db = getDb()
const row = db.prepare('SELECT * FROM orders WHERE id = ?').get(req.params.id) as OrderRow | undefined
if (!row) { res.status(404).json({ error: { code: 'NOT_FOUND', message: 'Order not found' } }); return }
const order = rowToOrder(row)
const events = db.prepare('SELECT * FROM order_events WHERE order_id = ? ORDER BY created_at ASC').all(req.params.id) as OrderEvent[]
res.json({ ...order, events })
})

27
server/routes/products.ts Normal file
View File

@@ -0,0 +1,27 @@
import { Router } from 'express'
import { getDb } from '../db/connection.js'
import type { Product, SizeStock } from '../../shared/types.js'
export const productsRouter = Router()
interface ProductRow { id: string; name: string; slug: string; description: string; price_sats: number; images: string; sizes: string; category: string; is_active: number; created_at: string; updated_at: string }
export function rowToProduct(row: ProductRow): Product {
return { id: row.id, name: row.name, slug: row.slug, description: row.description, priceSats: row.price_sats, images: JSON.parse(row.images) as string[], sizes: JSON.parse(row.sizes) as SizeStock[], category: row.category, isActive: row.is_active === 1, createdAt: row.created_at, updatedAt: row.updated_at }
}
productsRouter.get('/', (req, res) => {
const db = getDb()
const category = req.query.category as string | undefined
let rows: ProductRow[]
if (category) { rows = db.prepare('SELECT * FROM products WHERE is_active = 1 AND category = ? ORDER BY created_at DESC').all(category) as ProductRow[] }
else { rows = db.prepare('SELECT * FROM products WHERE is_active = 1 ORDER BY created_at DESC').all() as ProductRow[] }
res.json(rows.map(rowToProduct))
})
productsRouter.get('/:slug', (req, res) => {
const db = getDb()
const row = db.prepare('SELECT * FROM products WHERE slug = ? AND is_active = 1').get(req.params.slug) as ProductRow | undefined
if (!row) { res.status(404).json({ error: { code: 'NOT_FOUND', message: 'Product not found' } }); return }
res.json(rowToProduct(row))
})

47
server/routes/webhooks.ts Normal file
View File

@@ -0,0 +1,47 @@
import { Router } from 'express'
import { getDb } from '../db/connection.js'
import { validateWebhookSignature } from '../lib/btcpay.js'
import { sendOrderConfirmation } from '../lib/mailer.js'
import { notifyOrderConfirmed } from '../lib/nostr.js'
import type { SizeStock } from '../../shared/types.js'
export const webhooksRouter = Router()
interface OrderRow { id: string; nostr_pubkey: string | null; email: string | null; items: string; total_sats: number; status: string }
webhooksRouter.post('/btcpay', async (req, res) => {
try {
const signature = req.headers['btcpay-sig'] as string | undefined
const rawBody = JSON.stringify(req.body)
if (!signature || !validateWebhookSignature(rawBody, signature)) { res.status(401).json({ error: { code: 'INVALID_SIGNATURE', message: 'Invalid webhook signature' } }); return }
const { type, invoiceId } = req.body as { type: string; invoiceId: string }
const db = getDb()
const order = db.prepare('SELECT * FROM orders WHERE btcpay_invoice_id = ?').get(invoiceId) as OrderRow | undefined
if (!order) { res.status(404).json({ error: { code: 'ORDER_NOT_FOUND', message: 'Order not found for invoice' } }); return }
if (type === 'InvoiceSettled' || type === 'InvoicePaymentSettled') {
if (order.status !== 'pending') { res.json({ ok: true, message: 'Already processed' }); return }
const items = JSON.parse(order.items) as { productId: string; size: string; quantity: number }[]
const decrementStock = db.transaction(() => {
for (const item of items) {
const product = db.prepare('SELECT sizes FROM products WHERE id = ?').get(item.productId) as { sizes: string } | undefined
if (!product) continue
const sizes = JSON.parse(product.sizes) as SizeStock[]
const sizeEntry = sizes.find((s) => s.size === item.size)
if (sizeEntry) sizeEntry.stock = Math.max(0, sizeEntry.stock - item.quantity)
db.prepare("UPDATE products SET sizes = ?, updated_at = datetime('now') WHERE id = ?").run(JSON.stringify(sizes), item.productId)
}
db.prepare("UPDATE orders SET status = ?, updated_at = datetime('now') WHERE id = ?").run('paid', order.id)
db.prepare('INSERT INTO order_events (order_id, status, note) VALUES (?, ?, ?)').run(order.id, 'paid', 'Payment confirmed via BTCPay')
})
decrementStock()
if (order.email) sendOrderConfirmation(order.email, order.id, order.total_sats).catch(() => {})
if (order.nostr_pubkey) notifyOrderConfirmed(order.nostr_pubkey, order.id, order.total_sats).catch(() => {})
} else if (type === 'InvoiceExpired') {
db.prepare("UPDATE orders SET status = ?, updated_at = datetime('now') WHERE id = ?").run('cancelled', order.id)
db.prepare('INSERT INTO order_events (order_id, status, note) VALUES (?, ?, ?)').run(order.id, 'cancelled', 'Invoice expired')
}
res.json({ ok: true })
} catch (err) { console.error('Webhook processing failed:', err); res.status(500).json({ error: { code: 'WEBHOOK_FAILED', message: 'Webhook processing failed' } }) }
})