Skip to content

Conversation

@dotangad
Copy link
Collaborator

TL;DR

This PR adds the fields monkeytype_duel_otp and monkeytype_duel_settings to the user_profiles table and creates the following routes in the dashboard app -

  • POST /api/monkeytype-duel/settings
  • POST /api/monkeytype-duel/reset-otp
  • POST /api/monkeytype-duel/authenticate

Warning

MONKEYTYPE_DUEL_SECRET must be set in the Dashboard runtime for /api/monkeytype-duel/authenticate to work.

Before merging

  • Frontend
    • Display the OTP, along with a reset/shuffle button and a settings icon
    • Maybe: realtime preview of look & feel when editing monkeytype settings

DB changes

Migration: supabase/migrations/20260120000001_add_user_profiles_otp_and_monkeytype_settings.sql

  • New DB function - public.gen_monkeytype_otp(): returns a 4-digit string with leading zeros ("0000""9999").
  • New columns on public.user_profiles
    • monkeytype_duel_otp (text)
      • default: public.gen_monkeytype_otp()
      • backfilled for existing rows
    • 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/settings

Purpose: Update the current logged-in user’s monkeytype_duel_settings.

  • Auth: Dashboard session cookies (via getAuthenticatedUser)
  • Input: JSON body
{ "settings": { "any": "json-object" } }

Note: Validation is TODO until we are sure about the schema

  • Output (200):
{
  "settings": { "any": "json-object" },
  "updatedAt": "2026-01-20T00:00:00.000Z"
}
  • Errors:
    • 401 if not logged in
    • 400 if settings isn’t a JSON object
    • 500 on DB update failure

POST /api/monkeytype-duel/reset-otp

Purpose: Regenerate and persist a new monkeytype_duel_otp for the current logged-in user.

Note: calls DB function via RPC: rpc('gen_monkeytype_otp')

  • Auth: Dashboard session cookies (via getAuthenticatedUser)
  • Input: none
  • Output (200):
{ "otp": "1234" }
  • Errors:
    • 401 if not logged in

POST /api/monkeytype-duel/authenticate

Purpose: Service-to-service auth for Monkeytype Duel integration: validate an OTP and return user identity + team basics + settings.

  • Auth: Requires header
Authorization: Bearer ${MONKEYTYPE_DUEL_SECRET}
  • Input: JSON body
{ "otp": "1234" }
  • Output (200):
{
  "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, team is null.

  • Errors:
    • 401 if Bearer secret missing/wrong, or OTP invalid
    • 400 if otp missing
    • 500 if server misconfigured / DB errors

- `public.gen_monkeytype_otp`: generates 4 digit numeric code
- `monkeytype_duel_otp`: text
- `monkeytype_duel_settings`: jsonb
Copy link

Copilot AI left a 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_otp and monkeytype_duel_settings columns to the user_profiles table with a database function to generate 4-digit OTPs
  • Created /api/monkeytype-duel/settings endpoint for users to update their Monkeytype Duel settings
  • Created /api/monkeytype-duel/reset-otp and /api/monkeytype-duel/authenticate endpoints 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.

Comment on lines 45 to 56
.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,
});
Copy link

Copilot AI Jan 21, 2026

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:

  1. Selecting updated_at instead if it exists on the table, or
  2. Using the current timestamp from the server, or
  3. Removing the updatedAt field from the response if an accurate update timestamp isn't available

Copilot uses AI. Check for mistakes.
Comment on lines +50 to +51
if (!token || token !== expectedSecret) {
return res.status(401).json({ message: 'Unauthorized' });
Copy link

Copilot AI Jan 21, 2026

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.

Copilot uses AI. Check for mistakes.
Comment on lines 33 to 113
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' });
}
}
Copy link

Copilot AI Jan 21, 2026

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)

Copilot uses AI. Check for mistakes.
Comment on lines +15 to +21
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();
Copy link

Copilot AI Jan 21, 2026

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:

  1. Adding a UNIQUE constraint on monkeytype_duel_otp and handling regeneration on collision, or
  2. Increasing the OTP space (e.g., 6 digits = 1M possibilities), or
  3. Adding a compound uniqueness mechanism with expiration timestamps

Copilot uses AI. Check for mistakes.

-- 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')
Copy link

Copilot AI Jan 21, 2026

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;

Suggested change
SET monkeytype_duel_otp = lpad((floor(random() * 10000))::int::text, 4, '0')
SET monkeytype_duel_otp = public.gen_monkeytype_otp()

Copilot uses AI. Check for mistakes.
console.error('[dashboard/monkeytype-otp/reset] Update error:', error);
return res.status(500).json({ message: 'Failed to reset OTP' });
}

Copy link

Copilot AI Jan 21, 2026

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.

Suggested change
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' });
}

Copilot uses AI. Check for mistakes.
Comment on lines 11 to 61
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' });
}
}
Copy link

Copilot AI Jan 21, 2026

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)

Copilot uses AI. Check for mistakes.
Comment on lines +9 to +56
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' });
}
}
Copy link

Copilot AI Jan 21, 2026

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)

Copilot uses AI. Check for mistakes.
Comment on lines 64 to 78
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' });
}
Copy link

Copilot AI Jan 21, 2026

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.

Copilot uses AI. Check for mistakes.
Comment on lines 54 to 69
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)
Copy link

Copilot AI Jan 21, 2026

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.

Copilot uses AI. Check for mistakes.
@DeeprajPandey DeeprajPandey marked this pull request as ready for review February 3, 2026 16:49
@DeeprajPandey DeeprajPandey merged commit 7b2f5a3 into main Feb 3, 2026
1 of 4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants