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:
50
server/lib/btcpay.ts
Normal file
50
server/lib/btcpay.ts
Normal 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
33
server/lib/crypto.ts
Normal 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
24
server/lib/mailer.ts
Normal 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
28
server/lib/nostr.ts
Normal 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}`)
|
||||
}
|
||||
Reference in New Issue
Block a user