Compare commits

..
Author SHA1 Message Date
joshua 0cd78ab1b7 Merge branch 'fix/forgot-password-enumeration' 2026-10-30 08:51:41 +02:00
joshuaandClaude Opus 5.5 d49aebb1b0 Security: forgot password and sign-in don't reveal accounts (1.51.17)
- Forgot password answers the same (message, status, timing) for every email; the reset link
  is made and sent after the answer. Rate-limited like sign-in.
- Sign-in with an unknown email is refused exactly like a wrong password, after a password
  check against a stand-in hash.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls1vMdg2GYqbnKZmPaX1ot
2026-10-30 08:51:41 +02:00
joshuaandClaude Opus 5.5 98e5d05548 WIP 1.51.17: forgot password and sign-in don't reveal accounts
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls1vMdg2GYqbnKZmPaX1ot
2026-10-30 08:47:04 +02:00
joshua 14ece27690 Merge branch 'feat/api-v2-webhooks-links' 2026-10-30 08:34:41 +02:00
joshuaandClaude Opus 5.5 5b50b0ce4f Incoming webhooks and message links on API v2 (1.51.16)
- /api/v2/webhooks/{yoco,payfast,whatsapp}; new provider set-ups use them, the v1 addresses
  keep working with the same handlers.
- Calendar and one-click unsubscribe links point at v2; old links answer 308 (a one-click POST
  to the old address is still handled there).
- /api/webhooks/whatsapp runs the real WhatsApp handler (was an empty stub).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls1vMdg2GYqbnKZmPaX1ot
2026-10-30 08:34:41 +02:00
joshuaandClaude Opus 5.5 99f16375b9 WIP 1.51.16: incoming webhooks and message links on v2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls1vMdg2GYqbnKZmPaX1ot
2026-10-30 08:30:05 +02:00
joshua a62cac6335 Merge branch 'feat/api-v2-privacy-admin' 2026-10-30 08:23:49 +02:00
34 changed files with 390 additions and 99 deletions
+44
View File
@@ -7,6 +7,50 @@ and this project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
## [1.51.17] - 2026-10-30 — security release
### Security
- **Forgot password no longer reveals whether an account exists.** It answered "Email not found"
(404) for an unknown address, so anyone could check which emails have accounts. It now gives
the same answer and status for every address, sent before any work that only happens for a
real account (so the response time doesn't tell either); the reset link is made and sent
afterwards. Applies to `POST /api/users/forgot` and `POST /api/v2/auth/password/forgot`.
- **Sign-in no longer reveals it either.** An unknown email answered "User does not exist" (with a
different status on v1); it now gets exactly the answer a wrong password gets, after a password
check against a stand-in hash so it takes as long.
- Forgot password is rate-limited like sign-in, so it can't be used to flood someone's inbox with
reset emails.
### Upgrading
- Update as soon as you can. Nothing else changes; the forgot-password page shows the new message.
## [1.51.16] - 2026-10-30
### Added
- **Incoming webhooks on v2:** `/api/v2/webhooks/yoco`, `/payfast` and `/whatsapp`. New provider
set-ups use them — the PayFast notify URL (every new checkout), the Yoco webhook URL offered in
Site settings → Payments, and the WhatsApp webhook URL. The v1 addresses (`/api/webhooks/…`,
`/api/whatsapp/webhook`) keep working with the same handlers, because a provider may not follow
a redirect on a POST; they now name their v2 address in a `Link` header.
- **Links in sent messages on v2:** "Add to calendar" links point at
`/api/v2/events/{id}/calendar.ics`, and the one-click unsubscribe header at
`/api/v2/unsubscribe/{token}/one-click`. The same links in messages already sent answer `308`
with their v2 address; a mail app's one-click POST to the old address is still handled there.
### Fixed
- `/api/webhooks/whatsapp` was an empty stub that answered 500; it now runs the WhatsApp webhook
handler like `/api/whatsapp/webhook`.
### Upgrading
- Nothing has to change: existing Yoco and WhatsApp webhook registrations keep working. To move a
registration to its v2 address, generate the Yoco webhook again (Site settings → Payments → Yoco)
and paste the webhook URL shown in Site settings → WhatsApp into WAWP. PayFast moves by itself.
## [1.51.15] - 2026-10-30
### Added
+1 -1
View File
@@ -137,7 +137,7 @@ set names that match your PM2 apps (`pm2 ls`) and the port you use.
2. Set all required environment variables (see backend/README.md).
3. Run Prisma migrations (if applicable).
4. Start the server with: npm start (or a process manager like PM2).
5. Expose the webhook endpoint /api/webhooks/yoco publicly and configure the YOCO webhook to point to it.
5. Expose the webhook endpoint /api/v2/webhooks/yoco publicly and register it with Yoco (Site settings → Payments → Yoco → Generate does this).
6. Ensure the uploads directory public/uploads exists and is writable.
### Frontend Deployment
+11
View File
@@ -12,6 +12,17 @@ Write for church admins, not developers: no environment variables, endpoints or
developer log is CHANGELOG.md. A tagged version without a section here shows "No details given".
-->
## 1.51.17 — security update
- **Better protection for people's accounts.** The forgot-password and sign-in pages no longer
let anyone find out whether an email address has an account here.
## 1.51.16
- **The move to the new server connection is complete.** Payment confirmations from Yoco and
PayFast, WhatsApp messages coming in, and the calendar and unsubscribe links in emails now use
the new addresses. Links in emails you've already sent keep working.
## 1.51.15
- **The app now talks to the server only through its new version.** Privacy settings, the admin
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "event-management-backend",
"version": "1.51.15",
"version": "1.51.17",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "event-management-backend",
"version": "1.51.15",
"version": "1.51.17",
"hasInstallScript": true,
"dependencies": {
"@cj-tech-master/excelts": "^10.2.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "event-management-backend",
"version": "1.51.15",
"version": "1.51.17",
"description": "Event Management System Backend",
"main": "src/index.js",
"scripts": {
+8 -2
View File
@@ -99,8 +99,10 @@ const globalLimiter = rateLimit({
standardHeaders: true,
legacyHeaders: false,
message: { message: 'Too many requests, please try again later.' },
skip: (req) => req.path.startsWith('/api/webhooks'),
skip: (req) => RAW_BODY_PREFIXES.some(p => req.path.startsWith(p)),
});
// Incoming webhooks and links in sent messages on v2 (1.51.16): see src/v2/links.ts.
const { RAW_BODY_PREFIXES, webhooksRouter, linksRouter, legacyLinkRedirects } = require('./v2/links');
// /api/v2 answers every error as { error: { code, message } }, including the shared
// middleware below (rate limits, read-only guards, auth) that writes v1's { message }.
app.use('/api/v2', require('./v2/errors').legacyErrorsToV2);
@@ -115,7 +117,7 @@ app.use(require('./utils/impersonation').readOnlyGuard);
// Raw body parsing middleware for webhooks
app.use((req, res, next) => {
if (req.path.startsWith('/api/webhooks')) {
if (RAW_BODY_PREFIXES.some(p => req.path.startsWith(p))) {
getRawBody(req, {
length: req.headers['content-length'],
encoding: 'utf-8'
@@ -161,6 +163,7 @@ const privacyRoutes = require('./routes/privacyRoutes');
// Mount webhook routes BEFORE JSON body parser to avoid double-reading the stream
app.use('/api/webhooks', webhookRoutes);
app.use('/api/v2/webhooks', webhooksRouter);
// Apply JSON body parser for all non-webhook routes
app.use(express.json());
@@ -196,7 +199,10 @@ app.get('/health', async (req, res) => {
// Use routes
// /api/v2 (1.51.0, TypeScript): consistent errors and paging, described by OpenAPI at /docs/v2.
app.use('/api/v2', linksRouter);
app.use('/api/v2', require('./v2').v2Router());
// Links in messages already sent: v1 addresses answer 308 with their v2 one.
app.use(legacyLinkRedirects);
app.use('/docs/v2', require('./v2').v2DocsRouter());
app.use('/api/hub', require('./routes/hubRoutes'));
app.use('/api/campuses', require('./routes/campusRoutes'));
+41 -44
View File
@@ -539,55 +539,52 @@ const requestPasswordReset = async (req, res) => {
const user = await prisma.user.findUnique({ where: { email } });
// Explicitly check existence as requested
if (!user) {
return res.status(404).json({ message: 'Email not found' });
}
// Create token valid for 1 hour
const token = uuidv4();
const expiresAt = new Date(Date.now() + 60 * 60 * 1000);
// If PasswordReset model isn't available (migration not run), return clear error
if (!prisma.passwordReset || typeof prisma.passwordReset.create !== 'function' || typeof prisma.passwordReset.updateMany !== 'function') {
console.warn('[PasswordReset] Prisma model not available. Run Prisma migrations to enable password reset tokens.');
return res.status(500).json({ message: 'Password reset is not available. Please contact support.' });
}
// Invalidate previous tokens (optional)
await prisma.passwordReset.updateMany({
where: { userId: user.id, used: false, expiresAt: { gt: new Date() } },
data: { used: true }
});
await prisma.passwordReset.create({
data: {
id: uuidv4(),
userId: user.id,
token,
expiresAt,
used: false
}
});
const baseUrl = process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001';
const resetUrl = `${baseUrl.replace(/\/$/, '')}/reset-password?token=${encodeURIComponent(token)}`;
const { sendMail, buildPasswordResetEmail } = require('../utils/email');
const emailContent = buildPasswordResetEmail({ name: user.name, resetUrl });
// Security: always email
sendMail({ to: user.email, subject: 'Reset your password', ...emailContent })
.catch(e => console.warn('[password reset email] Failed:', e?.message || e));
// Also WhatsApp if preferred (security message — sent in addition to email)
const { waText } = require('../utils/notify');
waText(user, emailContent.text).catch(() => {});
return res.json({ message: 'If that email exists, a password reset link has been sent.' });
// Security (1.51.17): the same answer, with the same status, whether or not an account has
// this email — and it's sent before any work that only happens for real accounts, so the
// response time doesn't tell either. The link is made and sent afterwards.
res.json({ message: GENERIC_RESET_MESSAGE });
if (user) sendPasswordResetLink(user).catch(e => console.warn('[password reset] Failed:', e?.message || e));
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
const GENERIC_RESET_MESSAGE = 'If an account uses that email, we\'ve sent it a link to reset the password.';
/** A one-hour reset link for this account, by email (and WhatsApp when they prefer it). */
async function sendPasswordResetLink(user) {
const token = uuidv4();
const expiresAt = new Date(Date.now() + 60 * 60 * 1000);
// Invalidate previous tokens (optional)
await prisma.passwordReset.updateMany({
where: { userId: user.id, used: false, expiresAt: { gt: new Date() } },
data: { used: true }
});
await prisma.passwordReset.create({
data: {
id: uuidv4(),
userId: user.id,
token,
expiresAt,
used: false
}
});
const baseUrl = process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001';
const resetUrl = `${baseUrl.replace(/\/$/, '')}/reset-password?token=${encodeURIComponent(token)}`;
const { sendMail, buildPasswordResetEmail } = require('../utils/email');
const emailContent = buildPasswordResetEmail({ name: user.name, resetUrl });
// Security: always email
sendMail({ to: user.email, subject: 'Reset your password', ...emailContent })
.catch(e => console.warn('[password reset email] Failed:', e?.message || e));
// Also WhatsApp if preferred (security message — sent in addition to email)
const { waText } = require('../utils/notify');
waText(user, emailContent.text).catch(() => {});
}
// @desc Reset password using token
// @route POST /api/users/reset
// @access Public
@@ -5,16 +5,6 @@ const { handleProviderWebhook } = require('../payments/webhook');
// @access Public
const handleYocoWebhook = handleProviderWebhook('yoco');
const handleWhatsappWebhook = async (req, res) => {
try {
const { body } = req;
const { message } = body;
} catch (error) {
res.status(500).json({ error: 'Internal Server Error' });
}
}
module.exports = {
handleYocoWebhook,
handleWhatsappWebhook,
};
+1 -1
View File
@@ -33,7 +33,7 @@ router.post('/', registerUser);
router.post('/login', loginLimiter, loginUser);
router.post('/login/2fa', loginLimiter, verifyLoginTwoFactor);
router.post('/login/2fa/resend', loginLimiter, resendLoginTwoFactor);
router.post('/forgot', requestPasswordReset);
router.post('/forgot', loginLimiter, requestPasswordReset);
router.post('/reset', resetPassword);
router.post('/activate', activateAccount);
+3 -2
View File
@@ -1,11 +1,12 @@
const express = require('express');
const router = express.Router();
const { handleYocoWebhook, handleWhatsappWebhook } = require('../controllers/webhookController');
const { handleYocoWebhook } = require('../controllers/webhookController');
const { handleProviderWebhook } = require('../payments/webhook');
// Payment provider webhooks (src/payments)
router.post('/yoco', handleYocoWebhook);
router.post('/payfast', handleProviderWebhook('payfast'));
router.post('/whatsapp', handleWhatsappWebhook);
// The WAWP handler (WAWP is registered at /api/whatsapp/webhook, now /api/v2/webhooks/whatsapp).
router.post('/whatsapp', ...require('../v2/links').whatsappWebhook);
module.exports = router;
+10 -1
View File
@@ -207,6 +207,10 @@ export type LoginResult =
| { status: 'signed_in'; session: Session }
| { status: 'code_required'; challenge: { challengeId: string; method: string; destination: string | null }; enrolling: boolean };
let standIn: Promise<string> | null = null;
/** A hash nobody's password matches, so a sign-in with an unknown email does the same work. */
const standInHash = () => (standIn ??= auth.hashPassword(uuidv4()));
const MAX_ATTEMPTS = 5;
const LOCK_TIME = 5 * 60 * 1000;
@@ -219,7 +223,12 @@ export async function logIn(input: { identifier: string; password: string; devic
// SA local format (0xx) → international (27xx)
const normalizedPhone = whatsapp().normalizeZAPhone(rawInput) || rawInput.replace(/\D/g, '');
const user = await prisma.user.findFirst({ where: { OR: [{ email: rawInput }, ...(normalizedPhone ? [{ phoneNumber: normalizedPhone }] : [])] } });
if (!user) return fail(401, 'User does not exist');
// Security (1.51.17): an unknown email answers like a wrong password — same message and
// status, and a password check against a stand-in hash so it takes as long.
if (!user) {
await auth.comparePassword(input.password, await standInHash());
return fail(401, 'Invalid email or password', undefined, { v1Status: 400 });
}
if (!user.isActive) {
// Resend the activation link on each attempt, in case the earlier one expired.
+1 -1
View File
@@ -34,7 +34,7 @@ function getBackendUrl() {
function calendarLinkRow(eventId) {
const backendUrl = getBackendUrl();
if (!backendUrl || !eventId) return '';
return secondaryLink('Add to calendar', `${backendUrl}/api/events/${eventId}/ics`);
return secondaryLink('Add to calendar', `${backendUrl}/api/v2/events/${eventId}/calendar.ics`);
}
function getOrg() {
+1 -1
View File
@@ -38,7 +38,7 @@ const payfastHost = (mode) => HOSTS[mode === 'live' ? 'live' : 'sandbox'];
/** Where PayFast should post ITNs: BACKEND_URL (or the request's origin) + /api/webhooks/payfast. */
function payfastNotifyUrl(req) {
const base = (process.env.BACKEND_URL || (req ? `${req.protocol}://${req.get('host')}` : '')).replace(/\/$/, '');
return base ? `${base}/api/webhooks/payfast` : '';
return base ? `${base}/api/v2/webhooks/payfast` : '';
}
/** Why PayFast can't reach the notify URL (null when it can). */
+1 -1
View File
@@ -120,7 +120,7 @@ function unsubscribePageUrl(target) {
/** RFC 8058 one-click endpoint, for the List-Unsubscribe header (mail apps POST to it). */
function oneClickUrl(target) {
return `${backendUrl()}/api/privacy/unsubscribe/${encodeURIComponent(unsubscribeToken(target))}/one-click`;
return `${backendUrl()}/api/v2/unsubscribe/${encodeURIComponent(unsubscribeToken(target))}/one-click`;
}
/** What an unsubscribe link refers to, for the landing page. */
+1 -1
View File
@@ -314,7 +314,7 @@ async function getWebhookSecret() {
/** The URL to paste into WAWP's webhook settings (events: message, session.status). */
async function webhookUrl() {
const base = String(process.env.BACKEND_URL || process.env.API_URL || 'http://localhost:5000').replace(/\/$/, '');
return `${base}/api/whatsapp/webhook?secret=${encodeURIComponent(await getWebhookSecret())}`;
return `${base}/api/v2/webhooks/whatsapp?secret=${encodeURIComponent(await getWebhookSecret())}`;
}
/** The sender's phone (27…) from a WAWP chat id, or null for groups, channels and LIDs. */
+1 -1
View File
@@ -114,7 +114,7 @@ const deleteWebhook = (id) => yocoRequest('delete', `/webhooks/${encodeURIComp
*/
function defaultWebhookUrl(req) {
const base = (process.env.BACKEND_URL || (req ? `${req.protocol}://${req.get('host')}` : '')).replace(/\/$/, '');
return base ? `${base}/api/webhooks/yoco` : '';
return base ? `${base}/api/v2/webhooks/yoco` : '';
}
/** Returns an error message if Yoco won't be able to reach the URL, otherwise null. */
+4
View File
@@ -46,6 +46,10 @@ const SUCCESSORS: Successor[] = [
{ method: 'POST', v1: /^\/api\/events\/([^/]+)\/copy-from$/, v2: m => `/api/v2/events/${m[1]}/copy-from` },
{ method: 'GET', v1: /^\/api\/events\/([^/]+)\/notify-recipients$/, v2: m => `/api/v2/events/${m[1]}/notify-recipients` },
{ method: 'PUT', v1: /^\/api\/events\/([^/]+)\/notify-recipients$/, v2: m => `/api/v2/events/${m[1]}/notify-recipients` },
// 1.51.16: incoming webhooks and links in sent messages (v1 still answered by the same code)
{ method: 'POST', v1: /^\/api\/webhooks\/(yoco|payfast|whatsapp)$/, v2: m => `/api/v2/webhooks/${m[1]}` },
{ method: 'POST', v1: /^\/api\/whatsapp\/webhook$/, v2: () => '/api/v2/webhooks/whatsapp' },
{ method: 'POST', v1: /^\/api\/privacy\/unsubscribe\/([^/]+)\/one-click$/, v2: m => `/api/v2/unsubscribe/${m[1]}/one-click` },
// 1.51.15: privacy and admin
{ method: 'GET', v1: /^\/api\/privacy\/me$/, v2: () => '/api/v2/me/privacy' },
{ method: 'PUT', v1: /^\/api\/privacy\/me\/news$/, v2: () => '/api/v2/me/privacy/news' },
+52
View File
@@ -0,0 +1,52 @@
/**
* Incoming webhooks and the links in sent messages on v2 (1.51.16). These aren't JSON API calls,
* so they're plain Express routes, mounted by app.js beside the v2 router:
*
* - `webhooksRouter` at /api/v2/webhooks: Yoco, PayFast and WAWP (WhatsApp) post here. New
* provider registrations use these URLs; the v1 paths (/api/webhooks/…, /api/whatsapp/webhook)
* are still answered by the same handlers, because providers may not follow a redirect on POST.
* - `linksRouter` at /api/v2: the one-click unsubscribe link (RFC 8058) from email headers.
* - `legacyLinkRedirects` at /api: links in messages already sent (calendar files, opening a
* one-click unsubscribe link) answer 308 with their v2 address. A mail app's one-click POST to
* the v1 address is still handled there, like a webhook.
*/
import express, { Router, type RequestHandler } from 'express';
/* eslint-disable @typescript-eslint/no-explicit-any -- the handlers are JavaScript */
const { handleYocoWebhook } = require('../controllers/webhookController') as { handleYocoWebhook: RequestHandler };
const { handleProviderWebhook } = require('../payments/webhook') as { handleProviderWebhook(id: string): RequestHandler };
const { handleWebhook: handleWhatsappWebhook } = require('../controllers/whatsappController') as { handleWebhook: RequestHandler };
const privacy = require('../controllers/privacyController') as { oneClickRedirect: RequestHandler; oneClickUnsubscribe: RequestHandler };
/** Paths whose request body is read raw (signatures are checked over the exact bytes). */
export const RAW_BODY_PREFIXES = ['/api/webhooks', '/api/v2/webhooks'];
/** WAWP sends JSON (and ?secret=…); on the webhook paths its body arrives raw. */
export const whatsappWebhook: RequestHandler[] = [(req: any, _res, next) => {
try { req.body = req.rawBody ? JSON.parse(req.rawBody) : {}; } catch { req.body = {}; }
next();
}, handleWhatsappWebhook];
export const webhooksRouter = Router();
webhooksRouter.post('/yoco', handleYocoWebhook);
webhooksRouter.post('/payfast', handleProviderWebhook('payfast'));
webhooksRouter.post('/whatsapp', ...whatsappWebhook);
export const linksRouter = Router();
linksRouter.get('/unsubscribe/:token/one-click', privacy.oneClickRedirect);
linksRouter.post('/unsubscribe/:token/one-click', express.urlencoded({ extended: false }), privacy.oneClickUnsubscribe);
/** v1 links in messages already sent → their v2 address (308 keeps the method). */
const REDIRECTS: { method: string; v1: RegExp; v2: (m: RegExpMatchArray) => string }[] = [
{ method: 'GET', v1: /^\/api\/events\/([^/]+)\/ics$/, v2: m => `/api/v2/events/${m[1]}/calendar.ics` },
{ method: 'GET', v1: /^\/api\/privacy\/unsubscribe\/([^/]+)\/one-click$/, v2: m => `/api/v2/unsubscribe/${m[1]}/one-click` },
];
export const legacyLinkRedirects: RequestHandler = (req, res, next) => {
const [path, query] = req.originalUrl.split('?');
for (const r of REDIRECTS) {
const m = req.method === r.method ? path.match(r.v1) : null;
if (m) return res.redirect(308, r.v2(m) + (query ? `?${query}` : ''));
}
next();
};
+1 -1
View File
@@ -44,7 +44,7 @@ const qs = (req: Request) => (req.originalUrl.includes('?') ? req.originalUrl.sl
// ─── Accounts ────────────────────────────────────────────────────────────────
v1Route({ method: 'post', path: '/auth/login/code/resend', tag: 'Accounts', summary: 'Send the sign-in code again', access: 'public', middleware: [loginLimiter], body: loose, v1: users.resendLoginTwoFactor });
v1Route({ method: 'post', path: '/auth/password/forgot', tag: 'Accounts', summary: 'Email me a link to reset my password', description: 'NOT_FOUND when no account has that email (as v1 does).', access: 'public', body: z.looseObject({ email: z.string().max(200) }), v1: users.requestPasswordReset });
v1Route({ method: 'post', path: '/auth/password/forgot', tag: 'Accounts', summary: 'Email me a link to reset my password', description: 'Always the same answer, so it doesn\'t say whether an account has that email. Rate-limited like sign-in.', access: 'public', middleware: [loginLimiter], body: z.looseObject({ email: z.string().max(200) }), v1: users.requestPasswordReset });
v1Route({ method: 'post', path: '/auth/password/reset', tag: 'Accounts', summary: 'Set a new password with a reset link', access: 'public', body: loose, v1: users.resetPassword });
v1Route({ method: 'post', path: '/auth/activate', tag: 'Accounts', summary: 'Activate an account with its link', access: 'public', body: loose, v1: users.activateAccount });
v1Route({ method: 'post', path: '/me/sessions/revoke', tag: 'Me', summary: 'Sign out everywhere else', access: 'user', body: loose, v1: users.revokeMySession });
+83
View File
@@ -0,0 +1,83 @@
/**
* 1.51.17 security fix: nothing public says whether an account exists. Forgot-password answers
* the same for every email (before doing any per-account work), and sign-in with an unknown email
* answers exactly like a wrong password.
*/
const mockDb = {
user: { findUnique: jest.fn(), findFirst: jest.fn(), update: jest.fn() },
passwordReset: { updateMany: jest.fn(), create: jest.fn() },
};
jest.mock('../src/config/db', () => mockDb);
const mockCompare = jest.fn(async () => false);
jest.mock('../src/config/auth', () => ({ comparePassword: (...a: unknown[]) => (mockCompare as jest.Mock)(...a), hashPassword: async () => 'stand-in-hash', generateToken: () => 't' }));
const mockSendMail = jest.fn(async () => undefined);
jest.mock('../src/utils/email', () => ({ sendMail: (...a: unknown[]) => (mockSendMail as jest.Mock)(...a), buildPasswordResetEmail: () => ({ html: '', text: 'reset' }) }));
jest.mock('../src/utils/notify', () => ({ waText: async () => undefined }));
jest.mock('../src/utils/securityEvents', () => ({ logSecurityEvent: async () => undefined, getRecentSecurityEvents: async () => [] }));
jest.mock('../src/utils/whatsapp', () => ({ normalizeZAPhone: () => null, isValidZAPhone: () => false }));
import { logIn } from '../src/services/auth';
import { ServiceError } from '../src/services/errors';
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { requestPasswordReset } = require('../src/controllers/userController');
type Answer = { status: number; body: unknown };
function call(handler: (req: unknown, res: unknown) => Promise<void>, body: object): Promise<Answer> {
return new Promise(resolve => {
let status = 200;
const res = {
statusCode: 200,
status(n: number) { status = n; this.statusCode = n; return this; },
json(b: unknown) { resolve({ status, body: b }); return this; },
};
handler({ body, headers: {}, ip: '127.0.0.1' }, res);
});
}
const realUser = { id: 'u1', name: 'Thandi', email: 'thandi@example.org', isActive: true, password: 'real-hash', failedAttempts: 0, lockUntil: null, role: 'user' };
beforeEach(() => jest.clearAllMocks());
describe('forgot password', () => {
test('answers the same, with the same status, for an email with and without an account', async () => {
mockDb.user.findUnique.mockResolvedValueOnce(null);
const unknown = await call(requestPasswordReset, { email: 'nobody@example.org' });
mockDb.user.findUnique.mockResolvedValueOnce(realUser);
const known = await call(requestPasswordReset, { email: realUser.email });
expect(unknown).toEqual(known);
expect(known.status).toBe(200);
});
test('only a real account gets a link, made after the answer is sent', async () => {
mockDb.user.findUnique.mockResolvedValueOnce(null);
await call(requestPasswordReset, { email: 'nobody@example.org' });
await new Promise(r => setImmediate(r));
expect(mockDb.passwordReset.create).not.toHaveBeenCalled();
expect(mockSendMail).not.toHaveBeenCalled();
mockDb.user.findUnique.mockResolvedValueOnce(realUser);
const answered = call(requestPasswordReset, { email: realUser.email });
await answered;
await new Promise(r => setImmediate(r));
expect(mockDb.passwordReset.create).toHaveBeenCalledTimes(1);
expect(mockSendMail).toHaveBeenCalledWith(expect.objectContaining({ to: realUser.email }));
});
});
describe('sign-in', () => {
const failure = async (p: Promise<unknown>) => { try { await p; } catch (e) { return e as ServiceError; } throw new Error('expected a refusal'); };
test('an unknown email is refused exactly like a wrong password', async () => {
mockDb.user.findFirst.mockResolvedValueOnce(null);
const unknown = await failure(logIn({ identifier: 'nobody@example.org', password: 'x' }, { ip: '1', userAgent: 'jest' }));
mockDb.user.findFirst.mockResolvedValueOnce(realUser);
const wrong = await failure(logIn({ identifier: realUser.email, password: 'x' }, { ip: '1', userAgent: 'jest' }));
expect({ message: unknown.message, status: unknown.statusCode, v1: unknown.v1Status }).toEqual({ message: wrong.message, status: wrong.statusCode, v1: wrong.v1Status });
});
test('an unknown email still does a password check, so it takes as long', async () => {
mockDb.user.findFirst.mockResolvedValueOnce(null);
await failure(logIn({ identifier: 'nobody@example.org', password: 'guess' }, { ip: '1', userAgent: 'jest' }));
expect(mockCompare).toHaveBeenCalledWith('guess', 'stand-in-hash');
});
});
+77
View File
@@ -0,0 +1,77 @@
import express from 'express';
import request from 'supertest';
const mockCalls: { name: string; body?: unknown; query?: unknown }[] = [];
jest.mock('../src/controllers/webhookController', () => ({ handleYocoWebhook: (req: { rawBody: string }, res: { json(b: unknown): void }) => { mockCalls.push({ name: 'yoco', body: req.rawBody }); res.json({ ok: true }); } }));
jest.mock('../src/payments/webhook', () => ({ handleProviderWebhook: (id: string) => (_req: unknown, res: { json(b: unknown): void }) => { mockCalls.push({ name: id }); res.json({ ok: true }); } }));
jest.mock('../src/controllers/whatsappController', () => ({ handleWebhook: (req: { body: unknown; query: unknown }, res: { json(b: unknown): void }) => { mockCalls.push({ name: 'whatsapp', body: req.body, query: req.query }); res.json({ ok: true }); } }));
jest.mock('../src/controllers/privacyController', () => ({
oneClickRedirect: (req: { params: { token: string } }, res: { redirect(c: number, u: string): void }) => res.redirect(302, `https://site.example/unsubscribe?t=${req.params.token}`),
oneClickUnsubscribe: (req: { params: { token: string }; body: unknown }, res: { json(b: unknown): void }) => { mockCalls.push({ name: 'one-click', body: req.body }); res.json({ ok: true }); },
}));
import { webhooksRouter, linksRouter, legacyLinkRedirects } from '../src/v2/links';
// The same order and raw-body handling as app.js.
function app() {
const a = express();
a.use((req, _res, next) => {
if (req.path.startsWith('/api/v2/webhooks')) {
let raw = '';
req.setEncoding('utf8');
req.on('data', (c: string) => { raw += c; });
req.on('end', () => { (req as unknown as { rawBody: string }).rawBody = raw; next(); });
} else next();
});
a.use('/api/v2/webhooks', webhooksRouter);
a.use(express.json());
a.use('/api/v2', linksRouter);
a.use(legacyLinkRedirects);
a.post('/api/privacy/unsubscribe/:token/one-click', (_req, res) => { mockCalls.push({ name: 'v1 one-click' }); res.json({ ok: true }); });
return a;
}
beforeEach(() => { mockCalls.length = 0; });
describe('incoming webhooks on v2', () => {
test('Yoco gets the exact bytes it signed', async () => {
const body = '{"type":"payment.succeeded","payload":{"id":"p1"}}';
await request(app()).post('/api/v2/webhooks/yoco').set('Content-Type', 'application/json').send(body);
expect(mockCalls).toEqual([{ name: 'yoco', body }]);
});
test('PayFast reaches the PayFast handler', async () => {
await request(app()).post('/api/v2/webhooks/payfast').type('form').send('m_payment_id=c1');
expect(mockCalls[0].name).toBe('payfast');
});
test('WhatsApp gets its JSON parsed and keeps the secret in the query', async () => {
await request(app()).post('/api/v2/webhooks/whatsapp?secret=s3').send({ event: 'message', payload: { body: 'hi' } });
expect(mockCalls[0]).toEqual({ name: 'whatsapp', body: { event: 'message', payload: { body: 'hi' } }, query: { secret: 's3' } });
});
});
describe('links in sent messages', () => {
test('an old calendar link answers 308 with its v2 address', async () => {
const res = await request(app()).get('/api/events/e1/ics?x=1');
expect(res.status).toBe(308);
expect(res.headers.location).toBe('/api/v2/events/e1/calendar.ics?x=1');
});
test('opening an old one-click link goes to v2, which sends the person to the page', async () => {
const old = await request(app()).get('/api/privacy/unsubscribe/tok/one-click');
expect(old.status).toBe(308);
expect(old.headers.location).toBe('/api/v2/unsubscribe/tok/one-click');
const v2 = await request(app()).get('/api/v2/unsubscribe/tok/one-click');
expect(v2.status).toBe(302);
expect(v2.headers.location).toBe('https://site.example/unsubscribe?t=tok');
});
test("a mail app's one-click POST works on v2, and on the old address without a redirect", async () => {
await request(app()).post('/api/v2/unsubscribe/tok/one-click').type('form').send('List-Unsubscribe=One-Click');
expect(mockCalls[0]).toEqual({ name: 'one-click', body: { 'List-Unsubscribe': 'One-Click' } });
const old = await request(app()).post('/api/privacy/unsubscribe/tok/one-click').type('form').send('List-Unsubscribe=One-Click');
expect(old.status).toBe(200);
expect(mockCalls[1].name).toBe('v1 one-click');
});
});
+1 -1
View File
@@ -193,7 +193,7 @@ describe('PayFast provider', () => {
expect(form.action).toBe('https://sandbox.payfast.co.za/eng/process');
expect(Object.keys(form.fields)).toEqual(['merchant_id', 'merchant_key', 'return_url', 'cancel_url', 'notify_url', 'name_first', 'name_last', 'email_address', 'm_payment_id', 'amount', 'item_name', 'item_description', 'signature']);
expect(form.fields).toMatchObject({
notify_url: 'https://api.example.org/api/webhooks/payfast', name_first: 'Thandi', name_last: "O'Neil Mokoena",
notify_url: 'https://api.example.org/api/v2/webhooks/payfast', name_first: 'Thandi', name_last: "O'Neil Mokoena",
m_payment_id: 'chk_9', amount: '125.50', item_name: "Men's Camp (Youth)", item_description: 'Ticket x2',
});
const { signature, ...rest } = form.fields;
+1 -1
View File
@@ -97,7 +97,7 @@ describe('footers', () => {
test('news emails carry an unsubscribe link and one-click headers', () => {
const f = privacy.bulkEmailFooter({ kind: 'news', recipient: { email: 'a@b.co', userId: 'u1' } });
expect(f.html).toContain('https://events.example.org/unsubscribe?t=');
expect(f.headers['List-Unsubscribe']).toMatch(/^<https:\/\/api\.example\.org\/api\/privacy\/unsubscribe\/.+\/one-click>$/);
expect(f.headers['List-Unsubscribe']).toMatch(/^<https:\/\/api\.example\.org\/api\/v2\/unsubscribe\/.+\/one-click>$/);
expect(f.headers['List-Unsubscribe-Post']).toBe('List-Unsubscribe=One-Click');
});
+2 -2
View File
@@ -82,12 +82,12 @@ describe('webhook URL', () => {
test('built from BACKEND_URL', () => {
process.env.BACKEND_URL = 'https://api.example.com/';
expect(defaultWebhookUrl(req)).toBe('https://api.example.com/api/webhooks/yoco');
expect(defaultWebhookUrl(req)).toBe('https://api.example.com/api/v2/webhooks/yoco');
});
test('falls back to the request origin', () => {
delete process.env.BACKEND_URL;
expect(defaultWebhookUrl(req)).toBe('https://events-demo-api.crosscode.co.za/api/webhooks/yoco');
expect(defaultWebhookUrl(req)).toBe('https://events-demo-api.crosscode.co.za/api/v2/webhooks/yoco');
});
test('must be public https', () => {
+9 -8
View File
@@ -1,6 +1,6 @@
# Handover — where things stand and how to carry on
Last updated: 2026-10-30, after **1.51.15** (and Hub 1.5.0). The plan itself is in
Last updated: 2026-10-30, after **1.51.17** (and Hub 1.5.0). The plan itself is in
[ROADMAP-v2.md](./ROADMAP-v2.md) (decisions table at the end); the previous round of
features is in [FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md).
@@ -17,6 +17,8 @@ encrypted settings unreadable or break existing installs.
| 1.51.3 | **Express 5** (qs query parser and `req.body = {}` kept); scanner and at-the-door on v2 check-in; shared middleware answers v2-shaped on `/api/v2` (`legacyErrorsToV2`, `reason`); v1 `Deprecation` + `successor-version` headers (`src/v2/deprecation.ts`) |
| 1.51.2 | **v2 accounts and check-in**: signup, login (+ code step), `PATCH /me`; check-in look-up and scan with `outcome`s. `src/services/auth.ts` (from userController) and `src/services/checkin.ts` (from ticketController); login/signup/profile pages on v2 |
| 1.51.1 | **v2 registrations and payments**: register, get, cancel, checkout; create/cancel moved to `src/services/registrations.ts`, online checkout to `src/services/payments.ts` (v1 uses them too); typed frontend calls (`apiV2`, `npm run api:types`); register page, Pay now and My Events cancel on v2 |
| 1.51.17 | **Security:** forgot-password and sign-in don't reveal whether an account exists (same answer, status and timing); forgot-password rate-limited |
| 1.51.16 | **Incoming webhooks and message links on v2** — `src/v2/links.ts` (`/api/v2/webhooks/…`, one-click unsubscribe, 308s for old links); provider URLs and email links point at v2 |
| 1.51.15 | **Privacy and admin on v2** — `resources/adminPrivacy.ts`; every frontend call on v2; impersonation end/log allowed on v2 while read-only |
| 1.51.14 | **People and messaging on v2** — `resources/peopleMessaging.ts`; `/event-listing` for the public pages; `defineRoute` refuses duplicates |
| 1.51.13 | **Kids check-in and volunteers on v2** — `resources/kidsVolunteers.ts` |
@@ -75,9 +77,9 @@ encrypted settings unreadable or break existing installs.
Hub 1.1.0 is deployed with update requests rolled out, and `scripts/install.sh` has been tried on
a test server (both done 2026-10-28).
1. **1.51.16: incoming webhooks and links in sent messages** get v2 URLs (every call the app makes
is on v2 since 1.51.15), with the v1 paths still answering (links redirect, webhooks handled by
the same code). Then **2.0.0** — AI helpers and v1 removed.
1. **2.0.0** — AI helpers, and v1 removed. Every endpoint is on v2 since 1.51.16. Keep
answering the v1 webhook paths and the old links in sent messages (`src/v2/links.ts`) after
v1 goes: providers and old emails still use them.
2. **Servers run Node 24** (Joshua, 2026-10-29), so the backend may need Node 22+. Still to do:
move from ExcelTS — deprecated in favour of `documonster` by the same author (pre-1.0 in Oct
2026) — once documonster is stable. After 1.51.7 has run everywhere, the old `pgboss` schema
@@ -174,7 +176,7 @@ a test server (both done 2026-10-28).
up in its transaction history (`findPayments`); Yoco's Checkout API documents no lookup, so
Yoco checkouts are "can't check". Tests: `backend/tests/paymentStatus.test.js`.
- **Adding a provider:** write `providers/<id>.js` with the same interface, add it to
`PROVIDERS` in `index.js`, mount `handleProviderWebhook('<id>')` at `/api/webhooks/<id>`
`PROVIDERS` in `index.js`, mount `handleProviderWebhook('<id>')` at `/api/v2/webhooks/<id>` (in `src/v2/links.ts`; add the v1 path in `routes/webhookRoutes.js` only for providers already registered there)
(`routes/webhookRoutes.js`), and give it a settings card in
`frontend/src/components/admin/PaymentsTab.tsx`. New providers start switched off.
- `Payment.method` stays the wallet type (card, apple_pay…) because cashups bucket on it;
@@ -192,7 +194,7 @@ a test server (both done 2026-10-28).
- Bulk sends: `broadcastController`, `whatsappBroadcastController`, and `emailEventAttendees` /
`whatsappEventAttendees` in `eventController` take `kind`; scheduled payloads carry it.
- WhatsApp STOP: `handleIncomingMessage` in `whatsappController.js` (WAWP `message` event on
`/api/whatsapp/webhook?secret=…`; secret in AppSetting `WAWP_WEBHOOK_SECRET`).
`/api/v2/webhooks/whatsapp?secret=…` — older set-ups use `/api/whatsapp/webhook`, which still works; secret in AppSetting `WAWP_WEBHOOK_SECRET`).
- Frontend: `app/dashboard/user/privacy`, `app/unsubscribe`, `app/dashboard/admin/data-requests`,
`components/shared/NewsPrompt.tsx` (dashboard layout), `MessageKindPicker.tsx`.
- **Anything new that stores personal data** must be added to `anonymiseUser` and
@@ -609,8 +611,7 @@ a test server (both done 2026-10-28).
into WAWP (events `message` + `session.status`), and only for senders WhatsApp identifies by
phone number — a sender known only by a "LID" is logged and ignored (they can still use the
link in the message).
- `/api/webhooks/whatsapp` (`webhookController.handleWhatsappWebhook`) is an old empty stub; the
real WAWP webhook is `/api/whatsapp/webhook`. Remove the stub in a later release.
- `/api/webhooks/whatsapp` runs the real WAWP handler since 1.51.16 (it was an empty stub).
- There's no log of individual messages sent, so "Download my data" can't include "messages
sent to you" yet.
+3 -3
View File
@@ -44,8 +44,8 @@ scanner on v2, v1 deprecation headers** shipped. 1.51.4 **security and dependenc
ExcelTS** , 1.51.7 **setting events up on v2, pg-boss 11** and 1.51.8 **the rest of event set-up on v2** and 1.51.9
**series, passes, deals and campuses on v2** 1.51.10 **managing registrations on v2**, 1.51.11 **money on v2** and 1.51.12 **tickets and
attendance on v2** 1.51.13 **kids and volunteers on v2**, 1.51.14 **people and messaging on v2** and 1.51.15
**privacy and admin on v2** shipped — every call the app makes is on v2. **Next: 1.51.16 (incoming
webhooks and links in sent messages), then 2.0.0.**
**privacy and admin on v2** and 1.51.16 **incoming webhooks and links on v2** shipped — every
endpoint is on v2. **Next: 2.0.0.**
**Repositories:**
@@ -1014,7 +1014,7 @@ income justify the hardware:
| 9 | 1.47.0 ✅ – 1.47.3 ✅, 1.48.0 ✅ | Event series & cloning (1.47.0 clone, 1.47.1 series, 1.47.2 year-on-year reports, 1.47.3 season pass), Outbound webhooks (Wallet passes removed from scope) | Smaller, independent wins |
| 10 | 1.49.0 ✅ – 1.49.2 ✅, 1.50.0 ✅, 1.50.1 ✅, 1.50.2 ✅ | Admin dashboard/Ctrl+K/bulk actions/saved filters, Accessibility pass, pagination and search on long lists, event picker and webhook guide (Afrikaans removed from scope) | Polish before the big release |
| 11 | 1.51.0 ✅, 1.51.1 ✅, 1.51.2 ✅, 1.51.3 ✅ | TypeScript backend + OpenAPI + `/api/v2` alongside v1: 1.51.0 foundations (build, launcher, route declarations, errors, paging, OpenAPI at `/docs/v2`, first read endpoints); 1.51.1 registrations and payments as TS services, typed frontend calls; 1.51.2 check-in and accounts; 1.51.3 Express 5, the scanner on v2, v1 deprecation headers, v2-shaped shared middleware | The breaking-change groundwork |
| 11b | 1.51.4 ✅ – 1.51.15 ✅, 1.51.16 | **Every endpoint on v2 before 2.0** (decided 2026-10-29): 1.51.4 dependency audit and Node 24 LTS; 1.51.5 Next.js 16 and ESLint 10 (ESLint 9 is out of support); 1.51.6 the complete command palette and ExcelTS instead of exceljs; 1.51.7 setting events up (the staff list, create, change, deactivate, copy-from, notification recipients) and pg-boss 11; 1.51.8 the rest of event set-up (the event in full, ticket options and variants, attachments, sections, costs, uploads, calendar file, alias); 1.51.9 series, season passes, bundles, coupons and campuses; 1.51.10 managing registrations (the staff lists, status, at the door, bulk actions, items, coupons, attendees, attendee forms); 1.51.11 money, staff side (payments, cashup, provider transactions, waitlist, sponsorship, consent, form builder, feedback); 1.51.12 tickets and attendance, staff side (tickets, scan locks, attendance, allocations); 1.51.13 kids check-in and volunteer rosters; 1.51.14 people and messaging (users, WhatsApp, broadcasts, scheduled emails, automations, banner, saved filters, dashboard, stats, analytics, reports); 1.51.15 privacy and admin (privacy, admin, settings, setup, Hub, outbound webhooks, backups); 1.51.16 **incoming webhooks and links in sent messages**: Yoco, PayFast and WAWP webhooks and the links in emails and WhatsApp (unsubscribe, consent, ticket transfer, calendar feeds, waitlist offers) get v2 URLs — new messages and provider registrations use them, and the v1 paths keep answering (links by a 308 redirect to v2, provider webhooks handled by the same code, since providers may not follow a redirect on a POST). Each puts its area's endpoints on v2 (1.51.1 – 1.51.10 by moving the logic into TS services; from 1.51.11 by `v1Route`, which runs the v1 handler behind a v2 route — moving those handlers into TS services is a 2.x clean-up that doesn't change the API), its screens onto `apiV2`, and its v1 routes into `SUCCESSORS` | 2.0 can then remove v1 |
| 11b | 1.51.4 ✅ – 1.51.16 ✅ | **Every endpoint on v2 before 2.0** (decided 2026-10-29): 1.51.4 dependency audit and Node 24 LTS; 1.51.5 Next.js 16 and ESLint 10 (ESLint 9 is out of support); 1.51.6 the complete command palette and ExcelTS instead of exceljs; 1.51.7 setting events up (the staff list, create, change, deactivate, copy-from, notification recipients) and pg-boss 11; 1.51.8 the rest of event set-up (the event in full, ticket options and variants, attachments, sections, costs, uploads, calendar file, alias); 1.51.9 series, season passes, bundles, coupons and campuses; 1.51.10 managing registrations (the staff lists, status, at the door, bulk actions, items, coupons, attendees, attendee forms); 1.51.11 money, staff side (payments, cashup, provider transactions, waitlist, sponsorship, consent, form builder, feedback); 1.51.12 tickets and attendance, staff side (tickets, scan locks, attendance, allocations); 1.51.13 kids check-in and volunteer rosters; 1.51.14 people and messaging (users, WhatsApp, broadcasts, scheduled emails, automations, banner, saved filters, dashboard, stats, analytics, reports); 1.51.15 privacy and admin (privacy, admin, settings, setup, Hub, outbound webhooks, backups); 1.51.16 **incoming webhooks and links in sent messages**: Yoco, PayFast and WAWP webhooks and the links in emails and WhatsApp (unsubscribe, consent, ticket transfer, calendar feeds, waitlist offers) get v2 URLs — new messages and provider registrations use them, and the v1 paths keep answering (links by a 308 redirect to v2, provider webhooks handled by the same code, since providers may not follow a redirect on a POST). Each puts its area's endpoints on v2 (1.51.1 – 1.51.10 by moving the logic into TS services; from 1.51.11 by `v1Route`, which runs the v1 handler behind a v2 route — moving those handlers into TS services is a 2.x clean-up that doesn't change the API), its screens onto `apiV2`, and its v1 routes into `SUCCESSORS` | 2.0 can then remove v1 |
| 12 | **2.0.0** | **AI helpers** (§15 — headline feature: draft an event, summarise reports and feedback, draft messages, improve text; through the Hub with a rand cap per licence), v1 removed (all of it is on v2 by then; the old webhook URLs and links already sent in messages keep forwarding to v2), migration guide, marketing site/docs for selling | The release. AI needs the Hub (phase 6) for the key, metering and caps |
Each phase should add matching help content (`frontend/src/content/help/`),
+21 -5
View File
@@ -91,8 +91,17 @@ test.describe("API v2", () => {
const all = await request.get(`${API}/api/events/all`, { headers: { Authorization: `Bearer ${seed().users.staff.token}` } });
expect(all.headers()["link"]).toBe(`</api/v2/events/manage>; rel="successor-version"`);
expect((await request.get(`${API}/api/settings/needs-setup`)).headers()["link"]).toBe(`</api/v2/setup/needed>; rel="successor-version"`);
// Endpoints with no v2 version yet (incoming webhooks, until 1.51.16) say nothing.
expect((await request.post(`${API}/api/webhooks/whatsapp`, { data: {} })).headers()["deprecation"]).toBeUndefined();
// Incoming webhooks: the v1 address still works (same handler) and names its v2 one.
const hook = await request.post(`${API}/api/webhooks/whatsapp`, { data: {} });
expect(hook.status()).toBe(200);
expect(hook.headers()["link"]).toBe(`</api/v2/webhooks/whatsapp>; rel="successor-version"`);
expect((await request.post(`${API}/api/v2/webhooks/whatsapp`, { data: { event: "noop" } })).status()).toBe(200);
// A calendar link in an email already sent goes to its v2 address.
const ics = await request.get(`${API}/api/events/${seed().events.conference}/ics`, { maxRedirects: 0 });
expect(ics.status()).toBe(308);
expect(ics.headers()["location"]).toBe(`/api/v2/events/${seed().events.conference}/calendar.ics`);
const followed = await request.get(`${API}/api/events/${seed().events.conference}/ics`);
expect(followed.headers()["content-type"]).toContain("text/calendar");
});
test("staff list every event; supervisors create, change and admins deactivate", async ({ request }) => {
@@ -298,9 +307,16 @@ test.describe("API v2", () => {
const users = (await (await request.get(`${API}/api/v2/users?page=1&limit=2`, { headers: sup })).json()).data;
expect(users).toMatchObject({ page: 1, limit: 2 });
// As v1: an unknown email is NOT_FOUND.
const forgot = await request.post(`${API}/api/v2/auth/password/forgot`, { data: { email: "nobody-e2e@example.com" } });
expect((await forgot.json()).error.code).toBe("NOT_FOUND");
// Forgot-password doesn't say whether an account exists (1.51.17): the same answer either way.
const unknown = await request.post(`${API}/api/v2/auth/password/forgot`, { data: { email: "nobody-e2e@example.com" } });
const known = await request.post(`${API}/api/v2/auth/password/forgot`, { data: { email: seed().users.user.email } });
expect(unknown.status()).toBe(200);
expect(await unknown.json()).toEqual(await known.json());
// Nor does sign-in: an unknown email is refused like a wrong password.
const noAccount = await request.post(`${API}/api/v2/auth/login`, { data: { identifier: "nobody-e2e@example.com", password: "Wrong-password-1" } });
const wrongPassword = await request.post(`${API}/api/v2/auth/login`, { data: { identifier: seed().users.user.email, password: "Wrong-password-1" } });
expect(noAccount.status()).toBe(wrongPassword.status());
expect(await noAccount.json()).toEqual(await wrongPassword.json());
});
test("privacy and admin: settings, my data, the audit log, webhooks, backups", async ({ request }) => {
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hope-events-frontend",
"version": "1.51.15",
"version": "1.51.17",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events-frontend",
"version": "1.51.15",
"version": "1.51.17",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@hookform/resolvers": "^5.2.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events-frontend",
"version": "1.51.15",
"version": "1.51.17",
"private": true,
"scripts": {
"dev": "next dev",
+1 -1
View File
@@ -284,7 +284,7 @@ export function YocoTab({ active, onChanged }: { active: boolean; onChanged?: ()
<p className="text-xs text-gray-500">Registers a webhook with Yoco and saves the secret it returns. Yoco only shows the secret once, so it is stored straight away.</p>
<div className="grid sm:grid-cols-3 gap-2">
<input className={inputCls} value={hookName} onChange={(e) => setHookName(e.target.value)} placeholder="Name" />
<input className={`${inputCls} sm:col-span-2`} value={hookUrl} onChange={(e) => setHookUrl(e.target.value)} placeholder="https://…/api/webhooks/yoco" />
<input className={`${inputCls} sm:col-span-2`} value={hookUrl} onChange={(e) => setHookUrl(e.target.value)} placeholder="https://…/api/v2/webhooks/yoco" />
</div>
<button type="button" className={primaryBtn} disabled={busy === "generate" || !hookUrl.trim()} onClick={generate}>
{busy === "generate" ? "Registering…" : "Register webhook"}
+1 -1
View File
@@ -29239,7 +29239,7 @@
"Accounts"
],
"summary": "Email me a link to reset my password",
"description": "NOT_FOUND when no account has that email (as v1 does).\n\n**Who:** Anyone.",
"description": "Always the same answer, so it doesn't say whether an account has that email. Rate-limited like sign-in.\n\n**Who:** Anyone.",
"operationId": "postAuthPasswordForgot",
"parameters": [],
"requestBody": {
+1 -1
View File
@@ -4509,7 +4509,7 @@ export interface paths {
put?: never;
/**
* Email me a link to reset my password
* @description NOT_FOUND when no account has that email (as v1 does).
* @description Always the same answer, so it doesn't say whether an account has that email. Rate-limited like sign-in.
*
* **Who:** Anyone.
*/
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hope-events",
"version": "1.51.15",
"version": "1.51.17",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events",
"version": "1.51.15",
"version": "1.51.17",
"license": "ISC",
"devDependencies": {
"@axe-core/playwright": "^4.14.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events",
"version": "1.51.15",
"version": "1.51.17",
"main": "index.js",
"scripts": {
"dev:backend": "cd backend && npm run dev",