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

50
server/lib/btcpay.ts Normal file
View File

@@ -0,0 +1,50 @@
import crypto from 'node:crypto'
const TIMEOUT = 10_000
function getConfig() {
const url = process.env.BTCPAY_URL
const apiKey = process.env.BTCPAY_API_KEY
const storeId = process.env.BTCPAY_STORE_ID
if (!url || !apiKey || !storeId) throw new Error('BTCPay configuration missing: BTCPAY_URL, BTCPAY_API_KEY, BTCPAY_STORE_ID required')
return { url: url.replace(/\/$/, ''), apiKey, storeId }
}
async function btcpayFetch(path: string, options: RequestInit = {}): Promise<Response> {
const { url, apiKey } = getConfig()
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), TIMEOUT)
try {
return await fetch(`${url}${path}`, { ...options, signal: controller.signal, headers: { Authorization: `token ${apiKey}`, 'Content-Type': 'application/json', ...options.headers } })
} finally { clearTimeout(timer) }
}
export interface CreateInvoiceParams { amountSats: number; orderId: string; redirectUrl: string }
export interface BtcPayInvoice { id: string; checkoutLink: string; status: string; amount: string; currency: string }
export async function createInvoice(params: CreateInvoiceParams): Promise<BtcPayInvoice> {
const { storeId } = getConfig()
const res = await btcpayFetch(`/api/v1/stores/${storeId}/invoices`, {
method: 'POST',
body: JSON.stringify({ amount: String(params.amountSats), currency: 'SATS', metadata: { orderId: params.orderId }, checkout: { redirectURL: params.redirectUrl, redirectAutomatically: true } }),
})
if (!res.ok) { const text = await res.text(); throw new Error(`BTCPay invoice creation failed: ${res.status} ${text}`) }
return res.json() as Promise<BtcPayInvoice>
}
export async function getInvoice(invoiceId: string): Promise<BtcPayInvoice> {
const { storeId } = getConfig()
const res = await btcpayFetch(`/api/v1/stores/${storeId}/invoices/${invoiceId}`)
if (!res.ok) throw new Error(`BTCPay invoice fetch failed: ${res.status}`)
return res.json() as Promise<BtcPayInvoice>
}
export function validateWebhookSignature(body: string, signature: string): boolean {
const secret = process.env.BTCPAY_WEBHOOK_SECRET
if (!secret) return false
const expected = crypto.createHmac('sha256', secret).update(body).digest('hex')
const sigBuf = Buffer.from(signature.replace('sha256=', ''), 'hex')
const expectedBuf = Buffer.from(expected, 'hex')
if (sigBuf.length !== expectedBuf.length) return false
return crypto.timingSafeEqual(sigBuf, expectedBuf)
}

33
server/lib/crypto.ts Normal file
View File

@@ -0,0 +1,33 @@
import crypto from 'node:crypto'
const ALGORITHM = 'chacha20-poly1305'
const NONCE_LENGTH = 12
const TAG_LENGTH = 16
function getKey(): Buffer {
const hex = process.env.ENCRYPTION_KEY
if (!hex || hex.length !== 64) {
throw new Error('ENCRYPTION_KEY must be a 64-character hex string (32 bytes)')
}
return Buffer.from(hex, 'hex')
}
export function encrypt(plaintext: string): string {
const key = getKey()
const nonce = crypto.randomBytes(NONCE_LENGTH)
const cipher = crypto.createCipheriv(ALGORITHM, key, nonce, { authTagLength: TAG_LENGTH })
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()])
const tag = cipher.getAuthTag()
return Buffer.concat([nonce, tag, encrypted]).toString('hex')
}
export function decrypt(ciphertext: string): string {
const key = getKey()
const buf = Buffer.from(ciphertext, 'hex')
const nonce = buf.subarray(0, NONCE_LENGTH)
const tag = buf.subarray(NONCE_LENGTH, NONCE_LENGTH + TAG_LENGTH)
const encrypted = buf.subarray(NONCE_LENGTH + TAG_LENGTH)
const decipher = crypto.createDecipheriv(ALGORITHM, key, nonce, { authTagLength: TAG_LENGTH })
decipher.setAuthTag(tag)
return decipher.update(encrypted) + decipher.final('utf8')
}

24
server/lib/mailer.ts Normal file
View File

@@ -0,0 +1,24 @@
import nodemailer from 'nodemailer'
let transporter: nodemailer.Transporter | null = null
function getTransporter(): nodemailer.Transporter | null {
if (transporter) return transporter
const host = process.env.SMTP_HOST
if (!host) return null
transporter = nodemailer.createTransport({ host, port: Number(process.env.SMTP_PORT) || 587, secure: Number(process.env.SMTP_PORT) === 465, auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS } })
return transporter
}
export async function sendOrderConfirmation(email: string, orderId: string, totalSats: number): Promise<void> {
const t = getTransporter()
if (!t) return
await t.sendMail({ from: process.env.SMTP_FROM || 'orders@antonym.fashion', to: email, subject: `Order Confirmed - ${orderId}`, text: `Your order ${orderId} for ${totalSats.toLocaleString()} sats has been confirmed.\n\nTrack your order: ${process.env.SITE_URL || 'http://localhost:3333'}/order/${orderId}` })
}
export async function sendStatusUpdate(email: string, orderId: string, status: string, note?: string): Promise<void> {
const t = getTransporter()
if (!t) return
const noteText = note ? `\n\nNote: ${note}` : ''
await t.sendMail({ from: process.env.SMTP_FROM || 'orders@antonym.fashion', to: email, subject: `Order ${orderId} - ${status.charAt(0).toUpperCase() + status.slice(1)}`, text: `Your order ${orderId} status has been updated to: ${status}${noteText}\n\nTrack your order: ${process.env.SITE_URL || 'http://localhost:3333'}/order/${orderId}` })
}

28
server/lib/nostr.ts Normal file
View File

@@ -0,0 +1,28 @@
export async function sendDm(recipientPubkey: string, message: string): Promise<void> {
const privkeyHex = process.env.NOSTR_PRIVATE_KEY
if (!privkeyHex || !recipientPubkey) return
try {
const { finalizeEvent } = await import('nostr-tools/pure')
const { encrypt } = await import('nostr-tools/nip04')
const privkey = Uint8Array.from(Buffer.from(privkeyHex, 'hex'))
const ciphertext = await encrypt(privkey, recipientPubkey, message)
const event = finalizeEvent({ kind: 4, created_at: Math.floor(Date.now() / 1000), tags: [['p', recipientPubkey]], content: ciphertext }, privkey)
privkey.fill(0)
const { Relay } = await import('nostr-tools/relay')
const relays = ['wss://relay.damus.io', 'wss://nos.lol', 'wss://relay.nostr.band']
for (const url of relays) {
try { const relay = await Relay.connect(url); await relay.publish(event); relay.close() } catch {}
}
} catch { console.error('Failed to send Nostr DM') }
}
export async function notifyOrderConfirmed(pubkey: string, orderId: string, totalSats: number): Promise<void> {
await sendDm(pubkey, `Your Antonym order ${orderId} for ${totalSats.toLocaleString()} sats has been confirmed. Track it at: ${process.env.SITE_URL || 'http://localhost:3333'}/order/${orderId}`)
}
export async function notifyStatusUpdate(pubkey: string, orderId: string, status: string, note?: string): Promise<void> {
const noteText = note ? `${note}` : ''
await sendDm(pubkey, `Your Antonym order ${orderId} has been updated to: ${status}${noteText}`)
}