Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions apps/web/src/app/api/reminders/run/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';

/**
* The only thing standing between this endpoint and anybody who finds the URL
* is a shared secret, and the endpoint mails a meeting's entire invitee list.
* So these cover the refusals rather than the happy path.
*/

const runMeetingReminders = vi.fn();
vi.mock('@/lib/meeting-reminders-runner', () => ({
runMeetingReminders: (...args: unknown[]) => runMeetingReminders(...args),
}));

const SECRET = 'a-long-enough-secret-value';

async function post(headers: Record<string, string> = {}): Promise<Response> {
const { POST } = await import('./route');
return POST(new Request('https://pairux.com/api/reminders/run', { method: 'POST', headers }));
}

describe('POST /api/reminders/run', () => {
beforeEach(() => {
vi.resetModules();
runMeetingReminders.mockReset();
runMeetingReminders.mockResolvedValue({
meetings: 2,
emails: 3,
pushes: 1,
skipped: 0,
errors: [],
});
process.env.REMINDERS_CRON_SECRET = SECRET;
});

afterEach(() => {
delete process.env.REMINDERS_CRON_SECRET;
});

it('runs and reports what it sent when the secret matches', async () => {
const res = await post({ authorization: `Bearer ${SECRET}` });
expect(res.status).toBe(200);
expect(await res.json()).toMatchObject({ ok: true, meetings: 2, emails: 3, pushes: 1 });
expect(runMeetingReminders).toHaveBeenCalledOnce();
});

it('refuses a wrong secret, and sends nothing', async () => {
const res = await post({ authorization: 'Bearer not-the-secret-value' });
expect(res.status).toBe(401);
expect(runMeetingReminders).not.toHaveBeenCalled();
});

it('refuses a missing header', async () => {
const res = await post();
expect(res.status).toBe(401);
expect(runMeetingReminders).not.toHaveBeenCalled();
});

it('refuses a bare token without the Bearer scheme', async () => {
const res = await post({ authorization: SECRET });
expect(res.status).toBe(401);
expect(runMeetingReminders).not.toHaveBeenCalled();
});

it('refuses everything when the secret is not configured at all', async () => {
// The case worth being deliberate about: a new environment where the
// variable was forgotten must be closed, not open. An endpoint that mails
// an invitee list is not one to leave ungated by omission.
delete process.env.REMINDERS_CRON_SECRET;
const res = await post({ authorization: 'Bearer anything' });
expect(res.status).toBe(401);
expect(runMeetingReminders).not.toHaveBeenCalled();
});

it('a secret of a different length is refused rather than throwing', async () => {
// timingSafeEqual throws on unequal lengths, which would turn a wrong guess
// into a 500 and leak the secret's length through the error.
const res = await post({ authorization: 'Bearer short' });
expect(res.status).toBe(401);
});

it('reports a failed run as 500 rather than pretending it worked', async () => {
runMeetingReminders.mockRejectedValue(new Error('database is on fire'));
const res = await post({ authorization: `Bearer ${SECRET}` });
expect(res.status).toBe(500);
expect(await res.json()).toMatchObject({ ok: false });
});
});
69 changes: 69 additions & 0 deletions apps/web/src/app/api/reminders/run/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { timingSafeEqual } from 'node:crypto';
import { NextResponse } from 'next/server';

import { runMeetingReminders } from '@/lib/meeting-reminders-runner';

/**
* The tick that sends meeting reminders.
*
* Called once a minute by pg_cron via pg_net. It lives here rather than in the
* database because everything it needs -- the Resend client, the web-push
* library, the VAPID keys -- is already wired up in this app, and a second copy
* of that inside Postgres would be a second thing to keep in step.
*
* There is no user session behind this request, so it is authorised by a shared
* secret instead. Without `REMINDERS_CRON_SECRET` set the route refuses
* everything: an endpoint that mails a meeting's whole invitee list is not one
* to leave open because a variable was forgotten on a new environment.
*/

export const dynamic = 'force-dynamic';
export const maxDuration = 60;

/**
* Compare in constant time, and only after the lengths match.
*
* `timingSafeEqual` throws on differing lengths, which would both crash the
* route and leak the secret's length through the error; the explicit check
* turns that into an ordinary refusal.
*/
function secretMatches(provided: string, expected: string): boolean {
const a = Buffer.from(provided);
const b = Buffer.from(expected);
if (a.length !== b.length) return false;
return timingSafeEqual(a, b);
}

function authorised(request: Request): boolean {
const expected = process.env.REMINDERS_CRON_SECRET;
if (!expected) return false;

const header = request.headers.get('authorization') ?? '';
const token = header.startsWith('Bearer ') ? header.slice(7) : '';
if (!token) return false;

return secretMatches(token, expected);
}

export async function POST(request: Request): Promise<NextResponse> {
if (!authorised(request)) {
// Deliberately identical whether the secret is wrong or unset: the caller
// is a cron job, not a person who needs help debugging.
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}

try {
const summary = await runMeetingReminders();

// Errors inside the run are reported rather than thrown: one meeting whose
// host has been deleted should not make the whole tick look like a failure,
// and pg_cron's log is the only place anybody will see this.
return NextResponse.json({ ok: true, ...summary });
} catch (err) {
console.error('[reminders] run failed:', err);
return NextResponse.json(
{ ok: false, error: err instanceof Error ? err.message : String(err) },
{ status: 500 }
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ interface NotificationPrefs {
hostDisconnected: boolean;
creatorLive: boolean;
directMessage: boolean;
meetingReminder1Day: boolean;
meetingReminder1Hour: boolean;
meetingReminder15Min: boolean;
meetingReminder1Min: boolean;
}

const DEFAULT_PREFS: NotificationPrefs = {
Expand All @@ -24,9 +28,16 @@ const DEFAULT_PREFS: NotificationPrefs = {
hostDisconnected: true,
creatorLive: true,
directMessage: true,
meetingReminder1Day: true,
meetingReminder1Hour: true,
meetingReminder15Min: true,
meetingReminder1Min: true,
};

const PREF_LABELS: Record<keyof Omit<NotificationPrefs, 'pushEnabled'>, string> = {
const PREF_LABELS: Record<
Exclude<keyof NotificationPrefs, 'pushEnabled' | ReminderKey>,
string
> = {
controlRequest: 'Control requests',
chatMessage: 'Chat messages',
participantJoined: 'Participant joined',
Expand All @@ -36,6 +47,29 @@ const PREF_LABELS: Record<keyof Omit<NotificationPrefs, 'pushEnabled'>, string>
directMessage: 'Someone sends you a direct message',
};

type ReminderKey =
| 'meetingReminder1Day'
| 'meetingReminder1Hour'
| 'meetingReminder15Min'
| 'meetingReminder1Min';

/**
* Meeting reminders, kept out of the list above on purpose.
*
* The toggles above only render once the browser is subscribed to push, which
* is right for them — they describe push notifications and nothing else. These
* four also govern the *emailed* reminder, so hiding them behind a push
* subscription would leave anyone who has not enabled push, or cannot (an
* iPhone that has not installed the app, a locked-down browser), receiving
* emails they have no way to turn off.
*/
const REMINDER_LABELS: Record<ReminderKey, string> = {
meetingReminder1Day: '1 day before',
meetingReminder1Hour: '1 hour before',
meetingReminder15Min: '15 minutes before',
meetingReminder1Min: '1 minute before',
};

function Toggle({
enabled,
onChange,
Expand Down Expand Up @@ -197,6 +231,34 @@ export function NotificationPreferences() {
)}
</div>
)}

{/*
* Always rendered, unlike the block above. These govern the emailed
* reminder as well as the pushed one, so somebody who has never enabled
* push still needs a way to turn them off.
*/}
<div className="space-y-3 border-t border-gray-100 pt-4">
<div>
<p className="text-xs font-medium tracking-wide text-gray-500 uppercase">
Meeting reminders
</p>
<p className="mt-1 text-xs text-gray-500">
Sent by email, and as a notification when push is on.
</p>
</div>
{(Object.entries(REMINDER_LABELS) as [ReminderKey, string][]).map(([key, label]) => (
<div key={key} className="flex items-center justify-between">
<span className="text-sm text-gray-700">{label}</span>
<Toggle
enabled={preferences[key]}
onChange={() => {
void savePreference(key, !preferences[key]);
}}
disabled={saving}
/>
</div>
))}
</div>
</div>
);
}
Loading
Loading