-
Notifications
You must be signed in to change notification settings - Fork 0
Monkeytype Duel Integration #3
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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, | ||
| }); |
Copilot
AI
Jan 21, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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' }); |
Copilot
AI
Jan 21, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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' }); | ||
| } | ||
| } |
Copilot
AI
Jan 21, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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(); |
Copilot
AI
Jan 21, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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') |
Copilot
AI
Jan 21, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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' }); | ||
| } | ||
|
|
Copilot
AI
Jan 21, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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' }); | ||
| } | ||
| } |
Copilot
AI
Jan 21, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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' }); | ||
| } | ||
| } |
Copilot
AI
Jan 21, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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' }); | ||
| } |
Copilot
AI
Jan 21, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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) |
Copilot
AI
Jan 21, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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