From c2ef3dd70918887862a60ec8557e105015ea2ff8 Mon Sep 17 00:00:00 2001 From: Jared Zwick <52264361+jaredzwick@users.noreply.github.com> Date: Sun, 3 May 2026 05:16:33 -0400 Subject: [PATCH] hir-94: vercel.json cron config + GET-driven cron handlers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /api/cron/* routes only had POST handlers, so nothing actually fired them on a schedule. Queued emails just sat in the database; expiring OAuth tokens silently rotted; silent-reply follow-ups never flushed. The recommended-frequency comments in each handler were aspirational — there was no scheduler. - vercel.json: cron config invoking /api/cron/process-queue and /api/cron/process-followups every 5 minutes, /refresh-tokens every 30 minutes (matches the per-route docstrings). - All three cron routes now expose a single shared `handle` for both GET and POST. Vercel Cron only sends GET, so GET previously did nothing useful (returned a health-check JSON). POST callers (manual triggers, external runners) keep working with no behavior change. - Auth lifted into src/lib/cronAuth.ts — pure verifyCronAuth() that returns ok / 500 misconfig / 401. 10 vitest specs cover scheme case-sensitivity, prefix-attack rejection, missing/empty header, empty/missing secret, and the happy path. No schema, no migrations. tsc clean. test:int 105 passed (only the pre-existing PAYLOAD_SECRET api.int.spec.ts failure remains, same as main). Co-Authored-By: Paperclip --- src/app/api/cron/process-followups/route.ts | 73 ++++++---------- src/app/api/cron/process-queue/route.ts | 70 +++++----------- src/app/api/cron/refresh-tokens/route.ts | 70 +++++----------- src/lib/cronAuth.ts | 30 +++++++ tests/int/cronAuth.int.spec.ts | 93 +++++++++++++++++++++ vercel.json | 17 ++++ 6 files changed, 207 insertions(+), 146 deletions(-) create mode 100644 src/lib/cronAuth.ts create mode 100644 tests/int/cronAuth.int.spec.ts create mode 100644 vercel.json diff --git a/src/app/api/cron/process-followups/route.ts b/src/app/api/cron/process-followups/route.ts index 41cd5cf..d2ee88e 100644 --- a/src/app/api/cron/process-followups/route.ts +++ b/src/app/api/cron/process-followups/route.ts @@ -1,38 +1,32 @@ -import { NextRequest, NextResponse } from "next/server"; -import { processDueReplyFollowups } from "@/lib/replyFollowup"; +import { NextRequest, NextResponse } from 'next/server'; +import { processDueReplyFollowups } from '@/lib/replyFollowup'; +import { verifyCronAuth } from '@/lib/cronAuth'; /** - * POST /api/cron/process-followups + * /api/cron/process-followups * - * Cron endpoint for processing scheduled silent-reply follow-ups. Mirrors the - * auth shape of /api/cron/process-queue (Bearer CRON_SECRET) so the same cron - * runner can hit both. + * Cron endpoint for processing scheduled silent-reply follow-ups. Accepts + * both GET (used by Vercel Cron) and POST (manual triggers). Auth is + * `Authorization: Bearer $CRON_SECRET` for both. Mirrors process-queue. * * Recommended frequency: every 5 minutes. */ -export async function POST(request: NextRequest) { - try { - const authHeader = request.headers.get("authorization"); - const cronSecret = process.env.CRON_SECRET; - - if (!cronSecret) { - console.error("CRON_SECRET not configured"); - return NextResponse.json( - { success: false, error: "Server misconfiguration" }, - { status: 500 } - ); - } - - if (!authHeader || authHeader !== `Bearer ${cronSecret}`) { - return NextResponse.json( - { success: false, error: "Unauthorized" }, - { status: 401 } - ); - } +async function handle(request: NextRequest) { + const auth = verifyCronAuth({ + authorizationHeader: request.headers.get('authorization'), + cronSecret: process.env.CRON_SECRET, + }); + if (!auth.ok) { + if (auth.status === 500) console.error('CRON_SECRET not configured'); + return NextResponse.json( + { success: false, error: auth.error }, + { status: auth.status } + ); + } + try { const result = await processDueReplyFollowups(50); - return NextResponse.json({ success: true, result: { @@ -44,34 +38,17 @@ export async function POST(request: NextRequest) { }, }); } catch (error) { - console.error("Error in follow-up processing cron:", error); - + console.error('Error in follow-up processing cron:', error); return NextResponse.json( { success: false, - error: "Failed to process follow-ups", - details: error instanceof Error ? error.message : "Unknown error", + error: 'Failed to process follow-ups', + details: error instanceof Error ? error.message : 'Unknown error', }, { status: 500 } ); } } -export async function GET(request: NextRequest) { - const authHeader = request.headers.get("authorization"); - const cronSecret = process.env.CRON_SECRET; - - if (!cronSecret || !authHeader || authHeader !== `Bearer ${cronSecret}`) { - return NextResponse.json( - { success: false, error: "Unauthorized" }, - { status: 401 } - ); - } - - return NextResponse.json({ - success: true, - message: "Follow-up processing cron endpoint is healthy", - endpoint: "/api/cron/process-followups", - method: "POST", - }); -} +export const GET = handle; +export const POST = handle; diff --git a/src/app/api/cron/process-queue/route.ts b/src/app/api/cron/process-queue/route.ts index 3d42d2f..94ced9a 100644 --- a/src/app/api/cron/process-queue/route.ts +++ b/src/app/api/cron/process-queue/route.ts @@ -1,41 +1,32 @@ import { NextRequest, NextResponse } from 'next/server'; import { processEmailQueue } from '@/lib/emailQueueProcessor'; +import { verifyCronAuth } from '@/lib/cronAuth'; /** - * POST /api/cron/process-queue + * /api/cron/process-queue * - * Cron endpoint for processing the email queue. - * Protected by CRON_SECRET environment variable. + * Cron endpoint for processing the email queue. Accepts both GET (used by + * Vercel Cron, which only sends GET) and POST (manual triggers / external + * runners). Auth is `Authorization: Bearer $CRON_SECRET` for both. * - * Configure with Vercel Cron, GitHub Actions, or external cron service: - * - Recommended frequency: Every 5 minutes - * - Header: Authorization: Bearer YOUR_CRON_SECRET + * Recommended frequency: every 5 minutes. */ -export async function POST(request: NextRequest) { - try { - // Verify CRON_SECRET - const authHeader = request.headers.get('authorization'); - const cronSecret = process.env.CRON_SECRET; - - if (!cronSecret) { - console.error('CRON_SECRET not configured'); - return NextResponse.json( - { success: false, error: 'Server misconfiguration' }, - { status: 500 } - ); - } - - if (!authHeader || authHeader !== `Bearer ${cronSecret}`) { - return NextResponse.json( - { success: false, error: 'Unauthorized' }, - { status: 401 } - ); - } +async function handle(request: NextRequest) { + const auth = verifyCronAuth({ + authorizationHeader: request.headers.get('authorization'), + cronSecret: process.env.CRON_SECRET, + }); + if (!auth.ok) { + if (auth.status === 500) console.error('CRON_SECRET not configured'); + return NextResponse.json( + { success: false, error: auth.error }, + { status: auth.status } + ); + } - // Process the queue (default batch size: 50 for cron jobs) + try { const result = await processEmailQueue(50); - return NextResponse.json({ success: true, result: { @@ -48,7 +39,6 @@ export async function POST(request: NextRequest) { }); } catch (error) { console.error('Error in queue processing cron:', error); - return NextResponse.json( { success: false, @@ -60,23 +50,5 @@ export async function POST(request: NextRequest) { } } -// Support GET for health checks -export async function GET(request: NextRequest) { - // Verify CRON_SECRET for GET as well - const authHeader = request.headers.get('authorization'); - const cronSecret = process.env.CRON_SECRET; - - if (!cronSecret || !authHeader || authHeader !== `Bearer ${cronSecret}`) { - return NextResponse.json( - { success: false, error: 'Unauthorized' }, - { status: 401 } - ); - } - - return NextResponse.json({ - success: true, - message: 'Queue processing cron endpoint is healthy', - endpoint: '/api/cron/process-queue', - method: 'POST', - }); -} +export const GET = handle; +export const POST = handle; diff --git a/src/app/api/cron/refresh-tokens/route.ts b/src/app/api/cron/refresh-tokens/route.ts index bca156c..d0e3e4c 100644 --- a/src/app/api/cron/refresh-tokens/route.ts +++ b/src/app/api/cron/refresh-tokens/route.ts @@ -1,41 +1,32 @@ import { NextRequest, NextResponse } from 'next/server'; import { refreshExpiringTokens } from '@/lib/tokenRefreshJob'; +import { verifyCronAuth } from '@/lib/cronAuth'; /** - * POST /api/cron/refresh-tokens + * /api/cron/refresh-tokens * - * Cron endpoint for refreshing expiring OAuth tokens. - * Protected by CRON_SECRET environment variable. + * Cron endpoint for refreshing expiring OAuth tokens. Accepts both GET (used + * by Vercel Cron) and POST (manual triggers). Auth is + * `Authorization: Bearer $CRON_SECRET` for both. * - * Configure with Vercel Cron, GitHub Actions, or external cron service: - * - Recommended frequency: Every 30 minutes - * - Header: Authorization: Bearer YOUR_CRON_SECRET + * Recommended frequency: every 30 minutes. */ -export async function POST(request: NextRequest) { - try { - // Verify CRON_SECRET - const authHeader = request.headers.get('authorization'); - const cronSecret = process.env.CRON_SECRET; - - if (!cronSecret) { - console.error('CRON_SECRET not configured'); - return NextResponse.json( - { success: false, error: 'Server misconfiguration' }, - { status: 500 } - ); - } - - if (!authHeader || authHeader !== `Bearer ${cronSecret}`) { - return NextResponse.json( - { success: false, error: 'Unauthorized' }, - { status: 401 } - ); - } +async function handle(request: NextRequest) { + const auth = verifyCronAuth({ + authorizationHeader: request.headers.get('authorization'), + cronSecret: process.env.CRON_SECRET, + }); + if (!auth.ok) { + if (auth.status === 500) console.error('CRON_SECRET not configured'); + return NextResponse.json( + { success: false, error: auth.error }, + { status: auth.status } + ); + } - // Refresh expiring tokens + try { const result = await refreshExpiringTokens(); - return NextResponse.json({ success: true, result: { @@ -46,7 +37,6 @@ export async function POST(request: NextRequest) { }); } catch (error) { console.error('Error in token refresh cron:', error); - return NextResponse.json( { success: false, @@ -58,23 +48,5 @@ export async function POST(request: NextRequest) { } } -// Support GET for health checks -export async function GET(request: NextRequest) { - // Verify CRON_SECRET for GET as well - const authHeader = request.headers.get('authorization'); - const cronSecret = process.env.CRON_SECRET; - - if (!cronSecret || !authHeader || authHeader !== `Bearer ${cronSecret}`) { - return NextResponse.json( - { success: false, error: 'Unauthorized' }, - { status: 401 } - ); - } - - return NextResponse.json({ - success: true, - message: 'Token refresh cron endpoint is healthy', - endpoint: '/api/cron/refresh-tokens', - method: 'POST', - }); -} +export const GET = handle; +export const POST = handle; diff --git a/src/lib/cronAuth.ts b/src/lib/cronAuth.ts new file mode 100644 index 0000000..c6d583d --- /dev/null +++ b/src/lib/cronAuth.ts @@ -0,0 +1,30 @@ +export type CronAuthFailure = + | { ok: false; status: 500; error: 'Server misconfiguration' } + | { ok: false; status: 401; error: 'Unauthorized' } + +export type CronAuthSuccess = { ok: true } + +export type CronAuthResult = CronAuthFailure | CronAuthSuccess + +/** + * Validate the `Authorization: Bearer ` header used by every + * `/api/cron/*` route. Pure function so route handlers stay thin and the + * auth contract is unit-testable. + * + * Vercel Cron sends GET requests with the project's `CRON_SECRET` injected + * automatically — the same env var that POST callers use, so a single helper + * covers both methods. + */ +export function verifyCronAuth(input: { + authorizationHeader: string | null + cronSecret: string | undefined +}): CronAuthResult { + if (!input.cronSecret) { + return { ok: false, status: 500, error: 'Server misconfiguration' } + } + const expected = `Bearer ${input.cronSecret}` + if (!input.authorizationHeader || input.authorizationHeader !== expected) { + return { ok: false, status: 401, error: 'Unauthorized' } + } + return { ok: true } +} diff --git a/tests/int/cronAuth.int.spec.ts b/tests/int/cronAuth.int.spec.ts new file mode 100644 index 0000000..4b79fd5 --- /dev/null +++ b/tests/int/cronAuth.int.spec.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from 'vitest' +import { verifyCronAuth } from '@/lib/cronAuth' + +describe('verifyCronAuth', () => { + it('returns ok when header matches the secret', () => { + const r = verifyCronAuth({ + authorizationHeader: 'Bearer s3cret', + cronSecret: 's3cret', + }) + expect(r).toEqual({ ok: true }) + }) + + it('returns 500 when CRON_SECRET is not configured', () => { + expect( + verifyCronAuth({ + authorizationHeader: 'Bearer anything', + cronSecret: undefined, + }), + ).toEqual({ ok: false, status: 500, error: 'Server misconfiguration' }) + }) + + it('returns 500 even with empty-string secret (treats falsy as misconfigured)', () => { + expect( + verifyCronAuth({ + authorizationHeader: 'Bearer ', + cronSecret: '', + }), + ).toEqual({ ok: false, status: 500, error: 'Server misconfiguration' }) + }) + + it('returns 401 when header is missing', () => { + expect( + verifyCronAuth({ + authorizationHeader: null, + cronSecret: 's3cret', + }), + ).toEqual({ ok: false, status: 401, error: 'Unauthorized' }) + }) + + it('returns 401 when header is empty string', () => { + expect( + verifyCronAuth({ + authorizationHeader: '', + cronSecret: 's3cret', + }), + ).toEqual({ ok: false, status: 401, error: 'Unauthorized' }) + }) + + it('returns 401 when header has wrong scheme', () => { + expect( + verifyCronAuth({ + authorizationHeader: 'Basic s3cret', + cronSecret: 's3cret', + }), + ).toEqual({ ok: false, status: 401, error: 'Unauthorized' }) + }) + + it('returns 401 when secret value does not match', () => { + expect( + verifyCronAuth({ + authorizationHeader: 'Bearer wrong', + cronSecret: 's3cret', + }), + ).toEqual({ ok: false, status: 401, error: 'Unauthorized' }) + }) + + it('is case-sensitive on the bearer scheme (defensive)', () => { + expect( + verifyCronAuth({ + authorizationHeader: 'bearer s3cret', + cronSecret: 's3cret', + }), + ).toEqual({ ok: false, status: 401, error: 'Unauthorized' }) + }) + + it('does not strip surrounding whitespace from the header', () => { + expect( + verifyCronAuth({ + authorizationHeader: ' Bearer s3cret', + cronSecret: 's3cret', + }), + ).toEqual({ ok: false, status: 401, error: 'Unauthorized' }) + }) + + it('treats different secrets with the same prefix as unauthorized', () => { + expect( + verifyCronAuth({ + authorizationHeader: 'Bearer s3cret-x', + cronSecret: 's3cret', + }), + ).toEqual({ ok: false, status: 401, error: 'Unauthorized' }) + }) +}) diff --git a/vercel.json b/vercel.json new file mode 100644 index 0000000..d2900ab --- /dev/null +++ b/vercel.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "crons": [ + { + "path": "/api/cron/process-queue", + "schedule": "*/5 * * * *" + }, + { + "path": "/api/cron/process-followups", + "schedule": "*/5 * * * *" + }, + { + "path": "/api/cron/refresh-tokens", + "schedule": "*/30 * * * *" + } + ] +}