Files
housing-helper/server/index.mjs
Dr Jones 697185c0a1 first commit
Made-with: Cursor
2026-04-25 06:56:23 +00:00

441 lines
14 KiB
JavaScript

import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import crypto from 'node:crypto'
import express from 'express'
import cors from 'cors'
import Stripe from 'stripe'
import dotenv from 'dotenv'
import * as applications from './applicationsStore.mjs'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
dotenv.config({ path: path.join(__dirname, '..', '.env') })
const distDir = path.join(__dirname, '..', 'dist')
const isProd = process.env.NODE_ENV === 'production'
const PORT = Number(process.env.PORT) || 4242
const defaultClientUrl = isProd
? `http://127.0.0.1:${PORT}`
: 'http://localhost:5173'
const clientUrl = (process.env.CLIENT_URL || defaultClientUrl).replace(/\/$/, '')
const allowedOrigins = new Set([
clientUrl,
'http://localhost:5173',
'http://127.0.0.1:5173',
`http://localhost:${PORT}`,
`http://127.0.0.1:${PORT}`,
'http://home.thetempleofdoom.com',
'https://home.thetempleofdoom.com',
])
process.umask(0o077)
const app = express()
app.disable('x-powered-by')
app.set('trust proxy', true)
app.use(
cors({
origin(origin, cb) {
if (!origin || allowedOrigins.has(origin)) {
cb(null, true)
return
}
cb(null, false)
},
}),
)
app.use((_, res, next) => {
res.setHeader('X-Content-Type-Options', 'nosniff')
res.setHeader('X-Frame-Options', 'SAMEORIGIN')
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin')
res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()')
next()
})
function rateLimit({ windowMs, max }) {
const buckets = new Map()
return (req, res, next) => {
const key = req.ip || req.socket.remoteAddress || 'unknown'
const now = Date.now()
const bucket = buckets.get(key)
if (!bucket || bucket.resetAt <= now) {
buckets.set(key, { count: 1, resetAt: now + windowMs })
next()
return
}
bucket.count += 1
if (bucket.count > max) {
res.status(429).json({ error: 'Too many requests. Please try again later.' })
return
}
next()
}
}
function hasValidApplicationSecret(req, record) {
const secret = req.get('x-application-secret') || req.body?.applicationSecret
const storedHash = record?.applicationSecretHash
if (!secret || !storedHash) return false
const providedHash = applications.hashApplicationSecret(secret)
return crypto.timingSafeEqual(Buffer.from(providedHash), Buffer.from(storedHash))
}
const applicationInitLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 20 })
const checkoutLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 40 })
const stripeSecret = process.env.STRIPE_SECRET_KEY
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET
/** Webhooks need the raw body for signature verification — must run before express.json(). */
app.post(
'/api/webhooks/stripe',
express.raw({ type: 'application/json' }),
(req, res) => {
if (!stripeSecret) {
return res.status(503).send('Stripe secret not configured.')
}
if (!webhookSecret) {
return res.status(503).send('STRIPE_WEBHOOK_SECRET not set.')
}
const sig = req.headers['stripe-signature']
if (!sig || typeof sig !== 'string') {
return res.status(400).send('Missing stripe-signature header.')
}
let event
try {
const stripe = new Stripe(stripeSecret)
event = stripe.webhooks.constructEvent(req.body, sig, webhookSecret)
} catch (err) {
const msg = err instanceof Error ? err.message : 'Invalid payload'
console.error('Webhook signature verification failed:', msg)
return res.status(400).send(`Webhook Error: ${msg}`)
}
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object
console.info(
'[Stripe] checkout.session.completed',
session.id,
session.metadata?.house_name || '',
)
{
const ref = session.metadata?.application_ref
if (ref && applications.isValidApplicationRef(ref)) {
void applications.markPaidFromStripe(ref, session.id).catch((err) => {
console.error('[applications] mark paid failed', err)
})
}
}
break
}
default:
break
}
res.json({ received: true })
},
)
app.use(express.json({ limit: '1mb' }))
app.get('/api/health', (_, res) => {
const payload = {
ok: true,
site: 'Sacred Villas',
uptime_s: Math.round(process.uptime()),
}
if (!isProd) {
payload.stripe = {
secret_key_configured: Boolean(stripeSecret),
publishable_key_configured: Boolean(process.env.VITE_STRIPE_PUBLISHABLE_KEY),
webhook_secret_configured: Boolean(webhookSecret),
}
payload.client_url_configured = Boolean(process.env.CLIENT_URL)
payload.applications_store = 'file'
}
res.json(payload)
})
app.post('/api/applications/init', applicationInitLimiter, async (_, res) => {
try {
const record = await applications.createApplicationRecord({
payload: { version: 1 },
})
res.json({
applicationRef: record.applicationRef,
applicationSecret: record.applicationSecret,
createdAt: record.createdAt,
})
} catch (err) {
console.error(err)
res.status(500).json({ error: 'Could not create application file.' })
}
})
app.put('/api/applications/:ref', async (req, res) => {
try {
const { ref } = req.params
if (!applications.isValidApplicationRef(ref)) {
return res.status(400).json({ error: 'Invalid application reference.' })
}
const existing = await applications.readApplication(ref)
if (!existing) {
return res.status(404).json({ error: 'Application not found.' })
}
if (!hasValidApplicationSecret(req, existing)) {
return res.status(401).json({ error: 'Invalid application credentials.' })
}
if (existing.status === 'paid') {
return res.status(409).json({ error: 'Paid applications cannot be changed.' })
}
const payload = req.body?.payload
if (!payload || typeof payload !== 'object') {
return res.status(400).json({ error: 'payload object is required.' })
}
const identity = payload.identity ?? {}
const nextStatus = req.body?.status
let status = existing.status
if (nextStatus === 'submitted') status = 'submitted'
else if (nextStatus === 'draft') status = 'draft'
const updated = await applications.updateApplication(ref, {
payload,
applicantEmail: String(identity.email ?? '').slice(0, 256),
applicantPhone: String(identity.primaryPhone ?? '').slice(0, 80),
paymentType: payload.prefs?.paymentPath
? String(payload.prefs.paymentPath).slice(0, 32)
: existing.paymentType,
status,
})
res.json({
ok: true,
applicationRef: updated.applicationRef,
updatedAt: updated.updatedAt,
status: updated.status,
})
} catch (err) {
console.error(err)
res.status(500).json({ error: 'Could not save application.' })
}
})
app.post('/api/applications/sync-payment', checkoutLimiter, async (req, res) => {
try {
if (!stripeSecret) {
return res.status(503).json({ error: 'Stripe is not configured on this host.' })
}
const sessionId = req.body?.sessionId
if (!sessionId || typeof sessionId !== 'string') {
return res.status(400).json({ error: 'sessionId is required.' })
}
const stripe = new Stripe(stripeSecret)
const session = await stripe.checkout.sessions.retrieve(String(sessionId).slice(0, 128))
const ref = session.metadata?.application_ref
if (!ref || !applications.isValidApplicationRef(ref)) {
return res.json({ ok: false, reason: 'no_application_ref' })
}
if (session.status === 'complete' && session.payment_status === 'paid') {
await applications.markPaidFromStripe(ref, session.id)
return res.json({ ok: true, applicationRef: ref })
}
res.json({
ok: false,
reason: 'not_paid',
status: session.status,
payment_status: session.payment_status,
})
} catch (err) {
console.error(err)
res.status(500).json({ error: 'Unable to sync payment status.' })
}
})
function assertApplicationsAdmin(req, res) {
const token = process.env.APPLICATIONS_ADMIN_TOKEN
if (!token) {
res.status(503).json({
error:
'Admin listing is disabled. Set APPLICATIONS_ADMIN_TOKEN in the server environment to enable it.',
})
return false
}
const auth = req.headers.authorization || ''
const bearer = auth.startsWith('Bearer ') ? auth.slice(7).trim() : ''
if (bearer !== token) {
res.status(401).json({ error: 'Unauthorized.' })
return false
}
return true
}
app.get('/api/admin/applications', async (req, res) => {
if (!assertApplicationsAdmin(req, res)) return
const limit = Math.min(200, Math.max(1, Number(req.query.limit) || 80))
const rows = await applications.listApplicationSummaries(limit)
res.json({ applications: rows })
})
app.get('/api/admin/applications/:ref', async (req, res) => {
if (!assertApplicationsAdmin(req, res)) return
const { ref } = req.params
if (!applications.isValidApplicationRef(ref)) {
return res.status(400).json({ error: 'Invalid application reference.' })
}
const doc = await applications.readApplication(ref)
if (!doc) {
return res.status(404).json({ error: 'Not found.' })
}
res.json(doc)
})
app.post('/api/create-checkout-session', checkoutLimiter, async (req, res) => {
try {
if (!stripeSecret) {
return res.status(503).json({
error:
'Payments are not configured yet. Add STRIPE_SECRET_KEY on the server.',
})
}
const stripe = new Stripe(stripeSecret)
const {
applicationRef,
paymentType,
houseId,
houseName,
houseArea,
customerEmail,
fullName,
phone,
notes,
} = req.body || {}
if (!applicationRef || typeof applicationRef !== 'string') {
return res.status(400).json({
error: 'applicationRef is required. Return to /apply and use the housing application wizard.',
})
}
if (!applications.isValidApplicationRef(applicationRef)) {
return res.status(400).json({ error: 'Invalid application reference format.' })
}
const appRow = await applications.readApplication(applicationRef)
if (!appRow) {
return res.status(400).json({
error: 'Unknown application reference. Please restart from the application page.',
})
}
if (!hasValidApplicationSecret(req, appRow)) {
return res.status(401).json({ error: 'Invalid application credentials.' })
}
if (paymentType !== 'deposit' && paymentType !== 'credit') {
return res.status(400).json({ error: 'Invalid payment type.' })
}
if (!customerEmail || typeof customerEmail !== 'string') {
return res.status(400).json({ error: 'Email is required.' })
}
if (!fullName || typeof fullName !== 'string') {
return res.status(400).json({ error: 'Full name is required.' })
}
const isDeposit = paymentType === 'deposit'
const unitAmount = isDeposit ? 50000 : 7500
const productName = isDeposit
? 'Holding deposit — Sacred Villas'
: 'Applicant credit screening — Sacred Villas'
const propertyLine = houseName
? `Property: ${String(houseName).slice(0, 180)}`
: 'Property: to be assigned with your concierge'
const lineDescription =
`${propertyLine}. Applicant: ${String(fullName).slice(0, 100)}. File: ${String(applicationRef).slice(0, 32)}`.slice(
0,
500,
)
const session = await stripe.checkout.sessions.create({
ui_mode: 'embedded_page',
mode: 'payment',
customer_email: customerEmail.trim().slice(0, 256),
line_items: [
{
price_data: {
currency: 'usd',
unit_amount: unitAmount,
product_data: {
name: productName.slice(0, 120),
description: lineDescription,
},
},
quantity: 1,
},
],
metadata: {
application_ref: String(applicationRef).slice(0, 40),
payment_type: paymentType,
house_id: houseId ? String(houseId).slice(0, 120) : '',
house_name: houseName ? String(houseName).slice(0, 120) : '',
seattle_area: houseArea ? String(houseArea).slice(0, 120) : '',
full_name: String(fullName).slice(0, 120),
phone: phone ? String(phone).slice(0, 40) : '',
notes: notes ? String(notes).slice(0, 500) : '',
},
return_url: `${clientUrl}/apply/return?session_id={CHECKOUT_SESSION_ID}`,
})
if (!session.client_secret) {
return res.status(500).json({
error: 'Checkout session did not return a client secret.',
})
}
res.json({ clientSecret: session.client_secret })
} catch (err) {
console.error(err)
res.status(500).json({ error: 'Unable to start checkout.' })
}
})
app.get('/api/checkout-session-status', checkoutLimiter, async (req, res) => {
try {
if (!stripeSecret) {
return res.status(503).json({
error:
'Payments are not configured yet. Add STRIPE_SECRET_KEY on the server.',
})
}
const sessionId = req.query.session_id
if (!sessionId || typeof sessionId !== 'string') {
return res.status(400).json({ error: 'session_id query parameter is required.' })
}
const stripe = new Stripe(stripeSecret)
const session = await stripe.checkout.sessions.retrieve(String(sessionId).slice(0, 128))
res.json({
status: session.status,
payment_status: session.payment_status,
customer_email: session.customer_details?.email ?? null,
application_ref: session.metadata?.application_ref ?? null,
})
} catch (err) {
console.error(err)
res.status(500).json({ error: 'Unable to load session.' })
}
})
if (isProd && fs.existsSync(distDir)) {
app.use(express.static(distDir))
// Express 5 / path-to-regexp: named splat, not bare '*'
app.get('/{*splat}', (req, res) => {
res.sendFile(path.join(distDir, 'index.html'))
})
}
app.listen(PORT, '0.0.0.0', () => {
console.log(`Sacred Villas API ${isProd ? '+ static' : ''} on http://0.0.0.0:${PORT}`)
})