Add guest auth for Cabo voters

This commit is contained in:
TopherMayor
2026-04-30 19:58:22 -07:00
parent e5079cbce4
commit bdd2e5968f
4 changed files with 410 additions and 85 deletions

196
server.js
View File

@@ -1,6 +1,7 @@
const express = require('express');
const { WebSocketServer } = require('ws');
const cors = require('cors');
const crypto = require('crypto');
const http = require('http');
const path = require('path');
const fs = require('fs');
@@ -65,6 +66,14 @@ function normalizeKey(value) {
.replace(/^-+|-+$/g, '');
}
function normalizeGuestPin(value) {
return String(value || '').replace(/\D/g, '').slice(-4);
}
function normalizeGuestName(value) {
return normalizeKey(value).replace(/-/g, ' ');
}
function normalizeSourceLabel(value) {
return String(value || 'Unknown source').trim() || 'Unknown source';
}
@@ -112,6 +121,91 @@ function inferBookingType(point, defaults = {}) {
return 'standalone';
}
const GUEST_AUTH_SECRET = process.env.GUEST_AUTH_SECRET || 'cabo-bachelor-party-guest-auth';
function getGuestRoster() {
return Array.isArray(data.guestRoster) ? data.guestRoster : [];
}
function findGuestByNameAndPin(name, pin) {
const normalizedName = normalizeGuestName(name);
const normalizedPin = normalizeGuestPin(pin);
return getGuestRoster().find((guest) => (
normalizeGuestName(guest.name) === normalizedName
&& normalizeGuestPin(guest.last4) === normalizedPin
)) || null;
}
function signGuestToken(guest) {
const payload = {
name: guest.name,
last4: normalizeGuestPin(guest.last4),
role: guest.role || 'guest',
issuedAt: Date.now(),
};
const encodedPayload = Buffer.from(JSON.stringify(payload)).toString('base64url');
const signature = crypto
.createHmac('sha256', GUEST_AUTH_SECRET)
.update(encodedPayload)
.digest('base64url');
return `${encodedPayload}.${signature}`;
}
function verifyGuestToken(token) {
if (typeof token !== 'string' || !token.includes('.')) return null;
const [encodedPayload, signature] = token.split('.');
if (!encodedPayload || !signature) return null;
const expectedSignature = crypto
.createHmac('sha256', GUEST_AUTH_SECRET)
.update(encodedPayload)
.digest('base64url');
const sigA = Buffer.from(signature);
const sigB = Buffer.from(expectedSignature);
if (sigA.length !== sigB.length || !crypto.timingSafeEqual(sigA, sigB)) return null;
try {
const payload = JSON.parse(Buffer.from(encodedPayload, 'base64url').toString('utf8'));
const guest = findGuestByNameAndPin(payload.name, payload.last4);
if (!guest) return null;
return {
name: guest.name,
last4: normalizeGuestPin(guest.last4),
role: guest.role || 'guest',
};
} catch {
return null;
}
}
function readGuestAuthToken(req, body = {}) {
const bodyToken = body.authToken || body.guestToken || body.token;
if (typeof bodyToken === 'string' && bodyToken.trim()) return bodyToken.trim();
const headerToken = req.headers['x-guest-auth'];
if (typeof headerToken === 'string' && headerToken.trim()) return headerToken.trim();
const authorization = req.headers.authorization || '';
if (authorization.toLowerCase().startsWith('bearer ')) {
return authorization.slice(7).trim();
}
return null;
}
function requireGuestAuth(req, res, body = {}) {
const guest = verifyGuestToken(readGuestAuthToken(req, body));
if (!guest) {
res.status(401).json({ error: 'Guest authentication required' });
return null;
}
return guest;
}
const HISTORY_KEY_ALIASES = {
'costco-breathless': 'hotel-breathless',
'costco-grand-fiesta': 'hotel-grand-fiesta',
@@ -597,6 +691,10 @@ function buildRealtimeSnapshot() {
type: 'init',
pollsOpen: data.pollsOpen,
categories: data.categories,
guestRoster: getGuestRoster().map((guest) => ({
name: guest.name,
role: guest.role || 'guest',
})),
options: decorateOptionsWithPriceHistory(approvedOptions, priceHistoryState),
results: approvedOptionsWithVoteSummary(),
totalVoters: data.voters.length,
@@ -627,6 +725,45 @@ function createUserOption({ categoryId, name, desc, url, voterName, lat, lng, ap
let data = loadData();
app.get('/api/auth/guests', (req, res) => {
res.json({
guests: getGuestRoster().map((guest) => ({
name: guest.name,
role: guest.role || 'guest',
})),
});
});
app.get('/api/auth/me', (req, res) => {
const guest = verifyGuestToken(readGuestAuthToken(req));
if (!guest) {
return res.status(401).json({ authenticated: false });
}
res.json({
authenticated: true,
guest,
});
});
app.post('/api/auth/login', (req, res) => {
const { name, pin } = req.body || {};
const guest = findGuestByNameAndPin(name, pin);
if (!guest) {
return res.status(401).json({ error: 'Invalid guest name or code' });
}
res.json({
success: true,
token: signGuestToken(guest),
guest: {
name: guest.name,
role: guest.role || 'guest',
},
});
});
app.get('/api/categories', (req, res) => {
res.json(data.categories);
});
@@ -690,11 +827,13 @@ app.get('/api/price-history', (req, res) => {
});
app.post('/api/vote', (req, res) => {
const { optionId, voterName } = req.body;
const { optionId } = req.body;
const guest = requireGuestAuth(req, res, req.body);
if (!voterName || !optionId) {
if (!optionId) {
return res.status(400).json({ error: 'Missing fields' });
}
if (!guest) return;
if (!data.pollsOpen) {
return res.status(403).json({ error: 'Polls are closed' });
}
@@ -706,17 +845,17 @@ app.post('/api/vote', (req, res) => {
const previousVote = data.options.find((candidate) => (
candidate.categoryId === option.categoryId
&& candidate.votes.some((vote) => vote.name === voterName)
&& candidate.votes.some((vote) => vote.name === guest.name)
));
if (previousVote) {
previousVote.votes = previousVote.votes.filter((vote) => vote.name !== voterName);
previousVote.votes = previousVote.votes.filter((vote) => vote.name !== guest.name);
}
option.votes.push({ name: voterName, timestamp: Date.now() });
option.votes.push({ name: guest.name, timestamp: Date.now() });
if (!data.voters.find((voter) => voter.name === voterName)) {
data.voters.push({ name: voterName, joinedAt: Date.now() });
if (!data.voters.find((voter) => voter.name === guest.name)) {
data.voters.push({ name: guest.name, joinedAt: Date.now() });
}
saveData(data);
@@ -725,23 +864,26 @@ app.post('/api/vote', (req, res) => {
});
app.delete('/api/vote/:optionId', (req, res) => {
const { voterName } = req.body;
const guest = requireGuestAuth(req, res, req.body);
if (!guest) return;
const option = data.options.find((candidate) => candidate.id === req.params.optionId);
if (!option) return res.status(404).json({ error: 'Not found' });
option.votes = option.votes.filter((vote) => vote.name !== voterName);
option.votes = option.votes.filter((vote) => vote.name !== guest.name);
saveData(data);
broadcast({ type: 'vote_update', results: approvedOptionsWithVoteSummary() });
res.json({ success: true });
});
app.post('/api/options', (req, res) => {
const { categoryId, name, desc, url, voterName, lat, lng } = req.body;
const { categoryId, name, desc, url, lat, lng } = req.body;
const guest = requireGuestAuth(req, res, req.body);
if (!categoryId || !name || !voterName) {
if (!categoryId || !name) {
return res.status(400).json({ error: 'Missing required fields' });
}
if (!guest) return;
const category = data.categories.find((candidate) => candidate.id === categoryId);
if (!category) return res.status(404).json({ error: 'Category not found' });
@@ -751,7 +893,7 @@ app.post('/api/options', (req, res) => {
name,
desc,
url,
voterName,
voterName: guest.name,
lat,
lng,
approved: false,
@@ -852,8 +994,12 @@ wss.on('connection', (ws) => {
const msg = JSON.parse(raw);
if (msg.type === 'vote') {
const { optionId, voterName, remove } = msg;
if (!voterName || !optionId) return;
const { optionId, remove } = msg;
const guest = verifyGuestToken(msg.authToken || msg.guestToken || null);
if (!guest || !optionId) {
ws.send(JSON.stringify({ type: 'error', message: 'Guest authentication required' }));
return;
}
if (!data.pollsOpen) {
ws.send(JSON.stringify({ type: 'error', message: 'Polls closed' }));
return;
@@ -863,35 +1009,39 @@ wss.on('connection', (ws) => {
if (!option || !option.approved) return;
if (remove) {
option.votes = option.votes.filter((vote) => vote.name !== voterName);
option.votes = option.votes.filter((vote) => vote.name !== guest.name);
} else {
const previousVote = data.options.find((candidate) => (
candidate.categoryId === option.categoryId
&& candidate.votes.some((vote) => vote.name === voterName)
&& candidate.votes.some((vote) => vote.name === guest.name)
));
if (previousVote) {
previousVote.votes = previousVote.votes.filter((vote) => vote.name !== voterName);
previousVote.votes = previousVote.votes.filter((vote) => vote.name !== guest.name);
}
option.votes.push({ name: voterName, timestamp: Date.now() });
option.votes.push({ name: guest.name, timestamp: Date.now() });
}
if (!data.voters.find((voter) => voter.name === voterName)) {
data.voters.push({ name: voterName, joinedAt: Date.now() });
if (!data.voters.find((voter) => voter.name === guest.name)) {
data.voters.push({ name: guest.name, joinedAt: Date.now() });
}
saveData(data);
broadcast({ type: 'vote_update', results: approvedOptionsWithVoteSummary() });
} else if (msg.type === 'add_option') {
const { categoryId, name, desc, url, voterName, lat, lng } = msg;
if (!categoryId || !name || !voterName) return;
const { categoryId, name, desc, url, lat, lng } = msg;
const guest = verifyGuestToken(msg.authToken || msg.guestToken || null);
if (!guest || !categoryId || !name) {
ws.send(JSON.stringify({ type: 'error', message: 'Guest authentication required' }));
return;
}
const newOption = createUserOption({
categoryId,
name,
desc,
url,
voterName,
voterName: guest.name,
lat,
lng,
approved: true,