Skip to content

Commit 1f49b31

Browse files
vayungodaraclaude
andauthored
fix: resolve 13 findings from March 23 triage (security, perf, design, CI)
* fix: resolve 13 findings from March 23 triage (security, perf, design) Security: remove open redirect via x-forwarded-host in auth callback, add crypto.timingSafeEqual for all 4 cron routes via shared cronAuth.js. Performance: memoize FocusContext value (useMemo), batch N+1 notification inserts in streak-breaks cron, cap orphaned session duration correctly. Design: remove noise texture overlay, desaturate bg gradients + accent, vary gradient angles, tighten border-radius, soften dark borders, reduce button glow, neutralize background colors. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: add error handling for activity log insert + update DB notification type whitelist Activity log batch insert in streak-breaks cron now logs errors instead of silently swallowing them. DB create_notification function whitelist updated to include all 19 notification types from NOTIFICATION_TYPES. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: e2e CI — use next start instead of next dev, add build step Root cause: e2e job ran next dev on a cold CI runner, which routinely exceeded the 120s webServer timeout. The build artifact from build-and-lint was never shared to the e2e job. Fix: add npx next build step in e2e job, switch Playwright webServer to next start in CI (2s startup vs 2min). Locally still uses next dev. Also adds a unit test job running npx vitest run. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent a0dd406 commit 1f49b31

11 files changed

Lines changed: 170 additions & 102 deletions

File tree

.github/workflows/ci.yml

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,26 @@ jobs:
2626

2727
- run: npm run lint
2828

29+
unit:
30+
runs-on: ubuntu-latest
31+
steps:
32+
- uses: actions/checkout@v4
33+
34+
- uses: actions/setup-node@v4
35+
with:
36+
node-version: 20
37+
cache: npm
38+
39+
- run: npm ci
40+
41+
- run: npx vitest run
42+
2943
e2e:
3044
runs-on: ubuntu-latest
3145
needs: build-and-lint
3246
env:
47+
# Supabase public vars are needed at build time and for the running server.
48+
# These are safe to expose as NEXT_PUBLIC_* — they are the anon (read-only) key.
3349
NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}
3450
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}
3551
steps:
@@ -42,6 +58,18 @@ jobs:
4258

4359
- run: npm ci
4460

45-
- run: npx playwright install --with-deps chromium
61+
# Build the production bundle in this job so we can serve it with
62+
# `next start` instead of `next dev`. The dev compiler is unreliable
63+
# in CI (cold start can exceed the webServer timeout) and makes tests
64+
# non-deterministic. The production server starts in ~2 s.
65+
- name: Build
66+
run: npx next build
67+
env:
68+
NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}
69+
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}
70+
71+
- name: Install Playwright browsers
72+
run: npx playwright install --with-deps chromium
4673

47-
- run: npm run test:e2e
74+
- name: Run e2e tests
75+
run: npm run test:e2e

app/api/cron/check-streak-breaks/route.js

Lines changed: 23 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { createClient } from '@supabase/supabase-js';
2-
import { createNotification, NOTIFICATION_TYPES } from '@/lib/notifications';
2+
import { NOTIFICATION_TYPES } from '@/lib/notifications';
3+
import { verifyCronSecret } from '@/lib/cronAuth';
34

45
function getSupabaseClient() {
56
return createClient(
@@ -9,12 +10,9 @@ function getSupabaseClient() {
910
}
1011

1112
export async function GET(request) {
12-
const authHeader = request.headers.get('authorization');
13-
const cronSecret = process.env.CRON_SECRET;
14-
15-
if (!cronSecret || authHeader !== `Bearer ${cronSecret}`) {
16-
return Response.json({ error: 'Unauthorized' }, { status: 401 });
17-
}
13+
// Verify the request is from Vercel Cron using timing-safe comparison
14+
const { authorized, response } = verifyCronSecret(request);
15+
if (!authorized) return response;
1816

1917
const supabase = getSupabaseClient();
2018

@@ -55,17 +53,14 @@ export async function GET(request) {
5553
.select('user_id, group_id')
5654
.in('user_id', profileIds);
5755

58-
// Send notifications and activity logs (still per-user for custom messages)
59-
const notificationPromises = profiles.map(profile =>
60-
createNotification(
61-
supabase,
62-
profile.id,
63-
NOTIFICATION_TYPES.STREAK_BROKEN,
64-
'Streak Broken',
65-
`Your ${profile.current_streak}-day streak has ended. Start a new one today!`,
66-
{ brokenStreak: profile.current_streak }
67-
).catch(err => console.error(`Notification error for ${profile.id}:`, err))
68-
);
56+
// Batch insert all notifications in a single DB round-trip
57+
const notifications = profiles.map(profile => ({
58+
user_id: profile.id,
59+
type: NOTIFICATION_TYPES.STREAK_BROKEN,
60+
title: 'Streak Broken',
61+
message: `Your ${profile.current_streak}-day streak has ended. Start a new one today!`,
62+
metadata: { brokenStreak: profile.current_streak },
63+
}));
6964

7065
// Build activity log entries in bulk
7166
const activityEntries = [];
@@ -81,11 +76,19 @@ export async function GET(request) {
8176
}
8277
}
8378

79+
const notificationPromise = notifications.length > 0
80+
? supabase.from('notifications').insert(notifications).then(({ error: notifError }) => {
81+
if (notifError) console.error('Batch notification error:', notifError);
82+
})
83+
: Promise.resolve();
84+
8485
const activityPromise = activityEntries.length > 0
85-
? supabase.from('activity_log').insert(activityEntries)
86+
? supabase.from('activity_log').insert(activityEntries).then(({ error: actError }) => {
87+
if (actError) console.error('Batch activity log error:', actError);
88+
})
8689
: Promise.resolve();
8790

88-
await Promise.all([...notificationPromises, activityPromise]);
91+
await Promise.all([notificationPromise, activityPromise]);
8992

9093
const broken = profiles.length;
9194

app/api/cron/check-streak-risk/route.js

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { createClient } from '@supabase/supabase-js';
22
import { NOTIFICATION_TYPES } from '@/lib/notifications';
3+
import { verifyCronSecret } from '@/lib/cronAuth';
34

45
function getSupabaseClient() {
56
return createClient(
@@ -9,12 +10,9 @@ function getSupabaseClient() {
910
}
1011

1112
export async function GET(request) {
12-
const authHeader = request.headers.get('authorization');
13-
const cronSecret = process.env.CRON_SECRET;
14-
15-
if (!cronSecret || authHeader !== `Bearer ${cronSecret}`) {
16-
return Response.json({ error: 'Unauthorized' }, { status: 401 });
17-
}
13+
// Verify the request is from Vercel Cron using timing-safe comparison
14+
const { authorized, response } = verifyCronSecret(request);
15+
if (!authorized) return response;
1816

1917
const supabase = getSupabaseClient();
2018

app/api/cron/cleanup/route.js

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { createClient } from '@supabase/supabase-js';
2+
import { verifyCronSecret } from '@/lib/cronAuth';
23

34
function getSupabaseClient() {
45
return createClient(
@@ -8,12 +9,9 @@ function getSupabaseClient() {
89
}
910

1011
export async function GET(request) {
11-
const authHeader = request.headers.get('authorization');
12-
const cronSecret = process.env.CRON_SECRET;
13-
14-
if (!cronSecret || authHeader !== `Bearer ${cronSecret}`) {
15-
return Response.json({ error: 'Unauthorized' }, { status: 401 });
16-
}
12+
// Verify the request is from Vercel Cron using timing-safe comparison
13+
const { authorized, response } = verifyCronSecret(request);
14+
if (!authorized) return response;
1715

1816
const supabase = getSupabaseClient();
1917
const results = { overduePacts: 0, orphanedSessions: 0, errors: [] };
@@ -46,13 +44,18 @@ export async function GET(request) {
4644
if (fetchError) throw fetchError;
4745

4846
if (orphaned && orphaned.length > 0) {
49-
// Batch upsert: compute ended_at for each session, then write all at once
50-
const updates = orphaned.map(session => ({
51-
id: session.id,
52-
ended_at: new Date(
47+
// Batch upsert: compute ended_at for each session, capped at expected end time.
48+
// Abandoned sessions should not inflate duration beyond what was configured.
49+
const now = new Date();
50+
const updates = orphaned.map(session => {
51+
const expectedEnd = new Date(
5352
new Date(session.started_at).getTime() + session.duration_minutes * 60 * 1000
54-
).toISOString(),
55-
}));
53+
);
54+
return {
55+
id: session.id,
56+
ended_at: new Date(Math.min(now.getTime(), expectedEnd.getTime())).toISOString(),
57+
};
58+
});
5659

5760
const { error: upsertError } = await supabase
5861
.from('focus_sessions')

app/api/cron/send-reminders/route.js

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { createClient } from '@supabase/supabase-js';
22
import { sendReminderEmail } from '@/lib/email';
3+
import { verifyCronSecret } from '@/lib/cronAuth';
34

45
// Create Supabase client with service role (lazy-loaded)
56
function getSupabaseClient() {
@@ -10,13 +11,9 @@ function getSupabaseClient() {
1011
}
1112

1213
export async function GET(request) {
13-
// Verify the request is from Vercel Cron or has correct secret
14-
const authHeader = request.headers.get('authorization');
15-
const cronSecret = process.env.CRON_SECRET;
16-
17-
if (!cronSecret || authHeader !== `Bearer ${cronSecret}`) {
18-
return Response.json({ error: 'Unauthorized' }, { status: 401 });
19-
}
14+
// Verify the request is from Vercel Cron using timing-safe comparison
15+
const { authorized, response } = verifyCronSecret(request);
16+
if (!authorized) return response;
2017

2118
const supabase = getSupabaseClient();
2219

app/auth/callback/route.js

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,13 @@ export async function GET(request) {
66
const { searchParams, origin } = new URL(request.url)
77
const code = searchParams.get('code')
88
// Validate redirect path to prevent open redirect attacks
9+
// Only allow relative paths starting with "/" -- reject absolute URLs,
10+
// protocol-relative URLs (//evil.com), and anything else an attacker
11+
// could use to redirect to an external domain.
912
let next = searchParams.get('next') ?? '/dashboard'
13+
if (typeof next !== 'string' || !next.startsWith('/') || next.startsWith('//')) {
14+
next = '/dashboard'
15+
}
1016
try {
1117
const resolvedUrl = new URL(next, origin)
1218
if (resolvedUrl.origin !== origin) {
@@ -45,19 +51,11 @@ export async function GET(request) {
4551
const { error } = await supabase.auth.exchangeCodeForSession(code)
4652

4753
if (!error) {
48-
const forwardedHost = request.headers.get('x-forwarded-host')
49-
const isLocalEnv = process.env.NODE_ENV === 'development'
50-
51-
let redirectUrl
52-
if (isLocalEnv) {
53-
redirectUrl = `${origin}${next}`
54-
} else if (forwardedHost) {
55-
redirectUrl = `https://${forwardedHost}${next}`
56-
} else {
57-
redirectUrl = `${origin}${next}`
58-
}
59-
60-
return NextResponse.redirect(redirectUrl)
54+
// Use origin from the request URL (safe, server-controlled) or
55+
// NEXT_PUBLIC_APP_URL as a fallback. Never use x-forwarded-host
56+
// because it is a user-controlled header that enables open redirects.
57+
const appOrigin = process.env.NEXT_PUBLIC_APP_URL || origin
58+
return NextResponse.redirect(`${appOrigin}${next}`)
6159
} else {
6260
console.error('Auth callback error:', error.message)
6361
}

app/globals.css

Lines changed: 21 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@
99
/* === LIGHT MODE — "Luminous" === */
1010

1111
/* Background colors — warm off-whites with violet undertone */
12-
--bg-primary: #FAFAFF;
13-
--bg-secondary: #F5F4FF;
12+
--bg-primary: #FAFAFA;
13+
--bg-secondary: #F5F5F5;
1414
--bg-tertiary: #EEEDF6;
1515
--bg-elevated: #FFFFFF;
1616
--bg-hover: #ECEAFF;
@@ -30,22 +30,22 @@
3030
--text-inverse: #FFFFFF;
3131

3232
/* Gradient Accents — clean 2-stop indigo family */
33-
--gradient-primary: linear-gradient(135deg, #4338CA 0%, #5B5EF5 100%);
33+
--gradient-primary: linear-gradient(150deg, #4338CA 0%, #6366F1 100%);
3434
--gradient-secondary: linear-gradient(135deg, #3730A3 0%, #4F46E5 100%);
35-
--gradient-success: linear-gradient(135deg, #0DBF73 0%, #2DDFAC 100%);
35+
--gradient-success: linear-gradient(90deg, #0DBF73 0%, #2DDFAC 100%);
3636
--gradient-warm: linear-gradient(135deg, #F5A623 0%, #FF6B35 100%);
37-
--gradient-celebration: linear-gradient(135deg, #E8A217 0%, #FFD700 100%);
38-
--gradient-subtle: linear-gradient(135deg, rgba(var(--accent-primary-rgb), 0.1) 0%, rgba(var(--accent-primary-rgb), 0.04) 100%);
37+
--gradient-celebration: radial-gradient(circle at 30% 50%, #E8A217 0%, #FFD700 100%);
38+
--gradient-subtle: linear-gradient(180deg, rgba(var(--accent-primary-rgb), 0.1) 0%, rgba(var(--accent-primary-rgb), 0.04) 100%);
3939
--gradient-glow: linear-gradient(135deg, rgba(var(--accent-primary-rgb), 0.22) 0%, rgba(var(--accent-primary-rgb), 0.10) 100%);
4040

4141
/* Solid accent colors */
42-
--accent-primary: #5B5EF5;
42+
--accent-primary: #6366F1;
4343
--accent-primary-hover: #4745E0;
4444
--accent-secondary: #7C4DFF;
4545
--accent-tertiary: #E040CB;
4646
--accent-text: #5B5EF5;
4747
--accent-glow: rgba(var(--accent-primary-rgb), 0.18);
48-
--accent-primary-rgb: 91, 94, 245;
48+
--accent-primary-rgb: 99, 102, 241;
4949
--accent-tertiary-rgb: 224, 64, 203;
5050
--accent-celebration: #F5A623;
5151
--accent-celebration-rgb: 245, 166, 35;
@@ -138,10 +138,10 @@
138138
--space-32: 8rem; /* 128px */
139139

140140
/* Border radius — rounded interactive, structured containers */
141-
--radius-sm: 0.5rem; /* 8px */
142-
--radius-md: 0.625rem; /* 10px */
143-
--radius-lg: 0.875rem; /* 14px */
144-
--radius-xl: 1.125rem; /* 18px */
141+
--radius-sm: 0.375rem; /* 6px */
142+
--radius-md: 0.5rem; /* 8px */
143+
--radius-lg: 0.75rem; /* 12px */
144+
--radius-xl: 1rem; /* 16px */
145145
--radius-2xl: 1.375rem; /* 22px */
146146
--radius-3xl: 1.75rem; /* 28px */
147147
--radius-full: 9999px;
@@ -215,7 +215,7 @@
215215
--info-glow: rgba(110, 168, 254, 0.35);
216216

217217
/* Borders — soft card edges */
218-
--border-subtle: rgba(255, 255, 255, 0.06);
218+
--border-subtle: rgba(255, 255, 255, 0.04);
219219
--border-default: rgba(255, 255, 255, 0.09);
220220
--border-strong: rgba(255, 255, 255, 0.14);
221221
--border-focus: rgba(var(--accent-primary-rgb), 0.6);
@@ -286,35 +286,23 @@ body::before {
286286
width: 100%;
287287
height: 100%;
288288
background:
289-
radial-gradient(ellipse 80% 50% at 50% -20%, rgba(var(--accent-primary-rgb), 0.10), transparent),
290-
radial-gradient(ellipse 50% 40% at 100% 0%, rgba(var(--accent-tertiary-rgb), 0.07), transparent),
291-
radial-gradient(ellipse 40% 30% at 0% 80%, rgba(var(--accent-celebration-rgb), 0.04), transparent);
289+
radial-gradient(ellipse 80% 50% at 50% -20%, rgba(var(--accent-primary-rgb), 0.04), transparent),
290+
radial-gradient(ellipse 50% 40% at 100% 0%, rgba(var(--accent-tertiary-rgb), 0.03), transparent),
291+
radial-gradient(ellipse 40% 30% at 0% 80%, rgba(var(--accent-celebration-rgb), 0.02), transparent);
292292
z-index: -1;
293293
pointer-events: none;
294294
transition: opacity var(--transition-slow);
295295
}
296296

297297
[data-theme="dark"] body::before {
298298
background:
299-
radial-gradient(ellipse 80% 50% at 50% -20%, rgba(var(--accent-primary-rgb), 0.18), transparent),
300-
radial-gradient(ellipse 50% 40% at 100% 0%, rgba(var(--accent-tertiary-rgb), 0.12), transparent),
301-
radial-gradient(ellipse 40% 30% at 0% 80%, rgba(var(--accent-celebration-rgb), 0.06), transparent);
299+
radial-gradient(ellipse 80% 50% at 50% -20%, rgba(var(--accent-primary-rgb), 0.08), transparent),
300+
radial-gradient(ellipse 50% 40% at 100% 0%, rgba(var(--accent-tertiary-rgb), 0.05), transparent),
301+
radial-gradient(ellipse 40% 30% at 0% 80%, rgba(var(--accent-celebration-rgb), 0.03), transparent);
302302
}
303303

304304
/* body::before dark fallback also handled by data-theme attribute */
305305

306-
/* SVG noise texture — organic depth (Linear/Raycast/Craft style) */
307-
body::after {
308-
content: '';
309-
position: fixed;
310-
inset: 0;
311-
z-index: 9999;
312-
pointer-events: none;
313-
opacity: 0.03;
314-
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='300' height='300'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.75' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
315-
background-repeat: repeat;
316-
background-size: 300px 300px;
317-
}
318306

319307
/* === MODAL BLUR EFFECT ===
320308
When a modal is open, blur the dashboard content behind it.
@@ -451,12 +439,12 @@ p {
451439
background: var(--gradient-primary);
452440
color: white;
453441
border: none;
454-
box-shadow: var(--shadow-md), 0 0 20px rgba(var(--accent-primary-rgb), 0.2);
442+
box-shadow: var(--shadow-md), 0 0 12px rgba(var(--accent-primary-rgb), 0.12);
455443
}
456444

457445
.btn-primary:hover {
458446
transform: translateY(-2px);
459-
box-shadow: var(--shadow-lg), 0 0 35px rgba(var(--accent-primary-rgb), 0.35);
447+
box-shadow: var(--shadow-lg), 0 0 20px rgba(var(--accent-primary-rgb), 0.2);
460448
filter: brightness(1.05);
461449
}
462450

0 commit comments

Comments
 (0)