Conversation
- `public.gen_monkeytype_otp`: generates 4 digit numeric code - `monkeytype_duel_otp`: text - `monkeytype_duel_settings`: jsonb
There was a problem hiding this comment.
Pull request overview
This PR implements a Monkeytype Duel integration by adding OTP-based authentication and customizable settings for users. It introduces database changes to store 4-digit OTPs and JSON settings, along with three new API endpoints for managing settings, resetting OTPs, and service-to-service authentication.
Changes:
- Added
monkeytype_duel_otpandmonkeytype_duel_settingscolumns to theuser_profilestable with a database function to generate 4-digit OTPs - Created
/api/monkeytype-duel/settingsendpoint for users to update their Monkeytype Duel settings - Created
/api/monkeytype-duel/reset-otpand/api/monkeytype-duel/authenticateendpoints for OTP management and service authentication
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 10 comments.
Show a summary per file
| File | Description |
|---|---|
supabase/migrations/20260120000001_add_user_profiles_otp_and_monkeytype_settings.sql |
Adds database schema changes including OTP generation function, new columns, and backfill for existing users |
libs/types/src/lib/supabase.gen.ts |
Updates generated TypeScript types to reflect new database columns and function |
apps/dashboard/pages/api/monkeytype-duel/settings.ts |
Implements endpoint for authenticated users to update their Monkeytype Duel settings |
apps/dashboard/pages/api/monkeytype-duel/reset-otp.ts |
Implements endpoint for authenticated users to regenerate their OTP |
apps/dashboard/pages/api/monkeytype-duel/authenticate.ts |
Implements service-to-service authentication endpoint using bearer token and OTP validation |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| .select('monkeytype_duel_settings,created_at') | ||
| .single(); | ||
|
|
||
| if (error) { | ||
| console.error('[dashboard/monkeytype-settings] Update error:', error); | ||
| return res.status(500).json({ message: 'Failed to update settings' }); | ||
| } | ||
|
|
||
| return res.status(200).json({ | ||
| settings: data.monkeytype_duel_settings, | ||
| updatedAt: data.created_at, | ||
| }); |
There was a problem hiding this comment.
The query selects created_at but the response returns it as updatedAt. This is misleading since created_at represents when the user profile was created, not when the settings were updated. Consider either:
- Selecting
updated_atinstead if it exists on the table, or - Using the current timestamp from the server, or
- Removing the
updatedAtfield from the response if an accurate update timestamp isn't available
| if (!token || token !== expectedSecret) { | ||
| return res.status(401).json({ message: 'Unauthorized' }); |
There was a problem hiding this comment.
The token comparison uses strict equality (!==) which is vulnerable to timing attacks. An attacker could potentially determine the correct secret character-by-character by measuring response times. Consider using a constant-time comparison function to prevent timing-based attacks on the authentication secret.
| export default async function handler( | ||
| req: NextApiRequest, | ||
| res: NextApiResponse<ResponseBody> | ||
| ) { | ||
| if (req.method !== 'POST') { | ||
| return res.status(405).json({ message: 'Method not allowed' }); | ||
| } | ||
|
|
||
| const expectedSecret = process.env.MONKEYTYPE_DUEL_SECRET; | ||
| if (!expectedSecret) { | ||
| console.error( | ||
| '[monkeytype-duel/authenticate] MONKEYTYPE_DUEL_SECRET is not set' | ||
| ); | ||
| return res.status(500).json({ message: 'Server misconfigured' }); | ||
| } | ||
|
|
||
| const token = getBearerToken(req); | ||
| if (!token || token !== expectedSecret) { | ||
| return res.status(401).json({ message: 'Unauthorized' }); | ||
| } | ||
|
|
||
| const otp = (req.body as { otp?: unknown } | undefined)?.otp; | ||
| if (typeof otp !== 'string' || otp.trim().length === 0) { | ||
| return res.status(400).json({ message: '`otp` is required' }); | ||
| } | ||
|
|
||
| try { | ||
| const hbc = container.resolve(HibiscusSupabaseClient); | ||
| hbc.setOptions({ useServiceKey: true }); | ||
| const supabase = hbc.getClient(); | ||
|
|
||
| const { data: userProfile, error: userError } = await supabase | ||
| .from('user_profiles') | ||
| .select( | ||
| 'user_id,first_name,last_name,team_id,monkeytype_duel_settings,monkeytype_duel_otp' | ||
| ) | ||
| .eq('monkeytype_duel_otp', otp) | ||
| .maybeSingle(); | ||
|
|
||
| if (userError) { | ||
| console.error( | ||
| '[monkeytype-duel/authenticate] Profile query error:', | ||
| userError | ||
| ); | ||
| return res.status(500).json({ message: 'Failed to authenticate' }); | ||
| } | ||
|
|
||
| if (!userProfile) { | ||
| return res.status(401).json({ message: 'Invalid OTP' }); | ||
| } | ||
|
|
||
| let team: TeamBasicInfo | null = null; | ||
| if (userProfile.team_id) { | ||
| const { data: teamData, error: teamError } = await supabase | ||
| .from('teams') | ||
| .select('team_id,name,created_at,description,organizer_id') | ||
| .eq('team_id', userProfile.team_id) | ||
| .maybeSingle(); | ||
|
|
||
| if (teamError) { | ||
| console.error( | ||
| '[monkeytype-duel/authenticate] Team query error:', | ||
| teamError | ||
| ); | ||
| return res.status(500).json({ message: 'Failed to authenticate' }); | ||
| } | ||
|
|
||
| team = teamData ?? null; | ||
| } | ||
|
|
||
| return res.status(200).json({ | ||
| first_name: userProfile.first_name, | ||
| last_name: userProfile.last_name, | ||
| team, | ||
| monkeytype_duel_settings: userProfile.monkeytype_duel_settings ?? {}, | ||
| }); | ||
| } catch (e) { | ||
| console.error('[monkeytype-duel/authenticate] Error:', e); | ||
| return res.status(500).json({ message: 'Internal server error' }); | ||
| } | ||
| } |
There was a problem hiding this comment.
This endpoint lacks test coverage. Similar endpoints in the codebase (e.g., /api/stamps/, /api/organizer/, /api/invite/) have comprehensive test suites. Consider adding tests to verify:
- Bearer token authentication (401 for missing/incorrect token)
- Environment variable validation (500 when MONKEYTYPE_DUEL_SECRET not set)
- OTP validation (400 for missing OTP, 401 for invalid OTP)
- Successful authentication with and without team data
- Database error handling
- Method validation (405 for non-POST)
| ALTER TABLE user_profiles | ||
| ADD COLUMN IF NOT EXISTS monkeytype_duel_otp text; | ||
|
|
||
| -- Default: random 4-digit code (0000-9999) with leading zeros | ||
| ALTER TABLE user_profiles | ||
| ALTER COLUMN monkeytype_duel_otp | ||
| SET DEFAULT public.gen_monkeytype_otp(); |
There was a problem hiding this comment.
The monkeytype_duel_otp column lacks a UNIQUE constraint, allowing multiple users to have the same OTP. With only 10,000 possible values (0000-9999), collisions become increasingly likely as the user base grows. This creates a security vulnerability where one user's OTP could authenticate as another user. Consider either:
- Adding a UNIQUE constraint on
monkeytype_duel_otpand handling regeneration on collision, or - Increasing the OTP space (e.g., 6 digits = 1M possibilities), or
- Adding a compound uniqueness mechanism with expiration timestamps
|
|
||
| -- Backfill existing rows that don't yet have an OTP | ||
| UPDATE user_profiles | ||
| SET monkeytype_duel_otp = lpad((floor(random() * 10000))::int::text, 4, '0') |
There was a problem hiding this comment.
The backfill query duplicates the OTP generation logic inline instead of using the newly created gen_monkeytype_otp() function. This creates maintenance burden and potential inconsistency. Consider using the function instead: UPDATE user_profiles SET monkeytype_duel_otp = public.gen_monkeytype_otp() WHERE monkeytype_duel_otp IS NULL;
| SET monkeytype_duel_otp = lpad((floor(random() * 10000))::int::text, 4, '0') | |
| SET monkeytype_duel_otp = public.gen_monkeytype_otp() |
| console.error('[dashboard/monkeytype-otp/reset] Update error:', error); | ||
| return res.status(500).json({ message: 'Failed to reset OTP' }); | ||
| } | ||
|
|
There was a problem hiding this comment.
The response returns data.monkeytype_duel_otp which could potentially be null based on the database schema (the column is nullable). While the database default should generate a value, the type safety here is questionable. Consider adding a null check or assertion, or ensure the column is NOT NULL in the migration.
| if (!data || typeof data.monkeytype_duel_otp !== 'string') { | |
| console.error( | |
| '[dashboard/monkeytype-otp/reset] Unexpected null OTP after update:', | |
| data | |
| ); | |
| return res.status(500).json({ message: 'Failed to reset OTP' }); | |
| } |
| export default async function handler( | ||
| req: NextApiRequest, | ||
| res: NextApiResponse<ResponseBody> | ||
| ) { | ||
| if (req.method !== 'POST') { | ||
| return res.status(405).json({ message: 'Method not allowed' }); | ||
| } | ||
|
|
||
| const user = await getAuthenticatedUser(req); | ||
| if (!user) { | ||
| return res.status(401).json({ message: 'Unauthorized' }); | ||
| } | ||
|
|
||
| const settings = (req.body as { settings?: unknown } | undefined)?.settings; | ||
| // TODO: add validation once schema is known | ||
| if ( | ||
| settings == null || | ||
| typeof settings !== 'object' || | ||
| Array.isArray(settings) | ||
| ) { | ||
| return res.status(400).json({ | ||
| message: '`settings` must be a JSON object', | ||
| }); | ||
| } | ||
|
|
||
| try { | ||
| const hbc = container.resolve(HibiscusSupabaseClient); | ||
| hbc.setOptions({ useServiceKey: true }); | ||
|
|
||
| const { data, error } = await hbc | ||
| .getClient() | ||
| .from('user_profiles') | ||
| .update({ monkeytype_duel_settings: settings }) | ||
| .eq('user_id', user.user_id) | ||
| .select('monkeytype_duel_settings,created_at') | ||
| .single(); | ||
|
|
||
| if (error) { | ||
| console.error('[dashboard/monkeytype-settings] Update error:', error); | ||
| return res.status(500).json({ message: 'Failed to update settings' }); | ||
| } | ||
|
|
||
| return res.status(200).json({ | ||
| settings: data.monkeytype_duel_settings, | ||
| updatedAt: data.created_at, | ||
| }); | ||
| } catch (e) { | ||
| console.error('[dashboard/monkeytype-settings] Error:', e); | ||
| return res.status(500).json({ message: 'Internal server error' }); | ||
| } | ||
| } |
There was a problem hiding this comment.
This endpoint lacks test coverage. Similar endpoints in the codebase (e.g., /api/stamps/, /api/organizer/, /api/invite/) have comprehensive test suites. Consider adding tests to verify:
- Authentication validation (401 for unauthorized)
- Input validation (400 for invalid settings)
- Successful settings update (200 with correct response)
- Database error handling (500 errors)
- Method validation (405 for non-POST)
| export default async function handler( | ||
| req: NextApiRequest, | ||
| res: NextApiResponse<ResponseBody> | ||
| ) { | ||
| if (req.method !== 'POST') { | ||
| return res.status(405).json({ message: 'Method not allowed' }); | ||
| } | ||
|
|
||
| const user = await getAuthenticatedUser(req); | ||
| if (!user) { | ||
| return res.status(401).json({ message: 'Unauthorized' }); | ||
| } | ||
|
|
||
| try { | ||
| const hbc = container.resolve(HibiscusSupabaseClient); | ||
| hbc.setOptions({ useServiceKey: true }); | ||
|
|
||
| const { data: otp, error: otpError } = await hbc | ||
| .getClient() | ||
| .rpc('gen_monkeytype_otp'); | ||
|
|
||
| if (otpError || typeof otp !== 'string') { | ||
| console.error( | ||
| '[dashboard/monkeytype-otp/reset] OTP gen error:', | ||
| otpError | ||
| ); | ||
| return res.status(500).json({ message: 'Failed to generate OTP' }); | ||
| } | ||
|
|
||
| const { data, error } = await hbc | ||
| .getClient() | ||
| .from('user_profiles') | ||
| .update({ monkeytype_duel_otp: otp }) | ||
| .eq('user_id', user.user_id) | ||
| .select('monkeytype_duel_otp') | ||
| .single(); | ||
|
|
||
| if (error) { | ||
| console.error('[dashboard/monkeytype-otp/reset] Update error:', error); | ||
| return res.status(500).json({ message: 'Failed to reset OTP' }); | ||
| } | ||
|
|
||
| return res.status(200).json({ otp: data.monkeytype_duel_otp }); | ||
| } catch (e) { | ||
| console.error('[dashboard/monkeytype-otp/reset] Error:', e); | ||
| return res.status(500).json({ message: 'Internal server error' }); | ||
| } | ||
| } |
There was a problem hiding this comment.
This endpoint lacks test coverage. Similar endpoints in the codebase (e.g., /api/stamps/, /api/organizer/, /api/invite/) have comprehensive test suites. Consider adding tests to verify:
- Authentication validation (401 for unauthorized)
- Successful OTP generation and reset (200 with valid OTP)
- Database error handling during RPC call and update
- Method validation (405 for non-POST)
- OTP format validation (4-digit string with leading zeros)
| const { data: userProfile, error: userError } = await supabase | ||
| .from('user_profiles') | ||
| .select( | ||
| 'user_id,first_name,last_name,team_id,monkeytype_duel_settings,monkeytype_duel_otp' | ||
| ) | ||
| .eq('monkeytype_duel_otp', otp) | ||
| .maybeSingle(); | ||
|
|
||
| if (userError) { | ||
| console.error( | ||
| '[monkeytype-duel/authenticate] Profile query error:', | ||
| userError | ||
| ); | ||
| return res.status(500).json({ message: 'Failed to authenticate' }); | ||
| } |
There was a problem hiding this comment.
The query uses .maybeSingle() which will throw an error if multiple users have the same OTP. Since there's no UNIQUE constraint on monkeytype_duel_otp, this is a realistic scenario that could occur. The current error handling would return a generic "Failed to authenticate" message (500 status), when this should be treated as a configuration/data integrity issue. Consider handling the multiple-rows case explicitly or adding a UNIQUE constraint on the OTP column in the migration.
| const otp = (req.body as { otp?: unknown } | undefined)?.otp; | ||
| if (typeof otp !== 'string' || otp.trim().length === 0) { | ||
| return res.status(400).json({ message: '`otp` is required' }); | ||
| } | ||
|
|
||
| try { | ||
| const hbc = container.resolve(HibiscusSupabaseClient); | ||
| hbc.setOptions({ useServiceKey: true }); | ||
| const supabase = hbc.getClient(); | ||
|
|
||
| const { data: userProfile, error: userError } = await supabase | ||
| .from('user_profiles') | ||
| .select( | ||
| 'user_id,first_name,last_name,team_id,monkeytype_duel_settings,monkeytype_duel_otp' | ||
| ) | ||
| .eq('monkeytype_duel_otp', otp) |
There was a problem hiding this comment.
The validation checks otp.trim().length === 0 but doesn't actually trim the otp variable before using it in the database query. This means an OTP with leading/trailing whitespace like " 1234 " would pass validation but fail to match in the database. Consider trimming the OTP and storing it in a const: const trimmedOtp = otp.trim(); and then using trimmedOtp throughout.
- remove created_at from settings api route output - add unique constraint on user_profiles.monkeytype_duel_otp
- Introduced new API endpoints for Monkeytype duel: authenticate, finalists, and update leaderboard. - Updated user_profiles to include monkeytype_wpm.
TL;DR
This PR adds the fields
monkeytype_duel_otpandmonkeytype_duel_settingsto theuser_profilestable and creates the following routes in the dashboard app -POST /api/monkeytype-duel/settingsPOST /api/monkeytype-duel/reset-otpPOST /api/monkeytype-duel/authenticateWarning
MONKEYTYPE_DUEL_SECRETmust be set in the Dashboard runtime for/api/monkeytype-duel/authenticateto work.Before merging
DB changes
Migration:
supabase/migrations/20260120000001_add_user_profiles_otp_and_monkeytype_settings.sqlpublic.gen_monkeytype_otp(): returns a 4-digit string with leading zeros ("0000"–"9999").public.user_profilesmonkeytype_duel_otp(text)public.gen_monkeytype_otp()monkeytype_duel_settings(jsonb, NOT NULL, default{})Warning
Please run
supabase migration up. Since the Supabase generated client was already under source control, it has been updated (and committed) to reflect the latest database state.POST /api/monkeytype-duel/settingsPurpose: Update the current logged-in user’s
monkeytype_duel_settings.getAuthenticatedUser){ "settings": { "any": "json-object" } }Note: Validation is TODO until we are sure about the schema
{ "settings": { "any": "json-object" }, "updatedAt": "2026-01-20T00:00:00.000Z" }401if not logged in400ifsettingsisn’t a JSON object500on DB update failurePOST /api/monkeytype-duel/reset-otpPurpose: Regenerate and persist a new
monkeytype_duel_otpfor the current logged-in user.Note: calls DB function via RPC:
rpc('gen_monkeytype_otp')getAuthenticatedUser){ "otp": "1234" }401if not logged inPOST /api/monkeytype-duel/authenticatePurpose: Service-to-service auth for Monkeytype Duel integration: validate an OTP and return user identity + team basics + settings.
{ "otp": "1234" }{ "first_name": "Ada", "last_name": "Lovelace", "team": { "team_id": "uuid", "name": "Team Name", "created_at": "2026-01-20T00:00:00.000Z", "description": null, "organizer_id": "uuid" }, "monkeytype_duel_settings": {} }If the user has no team,
teamisnull.401if Bearer secret missing/wrong, or OTP invalid400ifotpmissing500if server misconfigured / DB errors