Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1297,6 +1297,7 @@
"github-username-confirm-description": "Is this the GitHub profile you want to use?",
"github-username-dialog-description": "Enter your GitHub username and we will verify the profile before saving it.",
"github-username-error-invalid_username": "Enter a valid GitHub username.",
"github-username-error-already_linked": "This GitHub account is already linked to another Capgo account.",
"github-username-error-not_found": "We could not find that GitHub profile.",
"github-username-error-rate_limited": "GitHub is temporarily rate limited. Please try again later.",
"github-username-error-request_failed": "We could not load that GitHub profile. Please try again.",
Expand Down
84 changes: 84 additions & 0 deletions scripts/ops/users_github_id_unique_index.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
-- Required pre-deploy step: build the GitHub identity index without blocking
-- writes. Run this file with psql, which executes each statement in autocommit
-- mode and supports the conditional recovery command below. In SQL Editor,
-- check for and drop an invalid same-named index first, then run the CREATE
-- INDEX statement and validation block separately. CREATE INDEX CONCURRENTLY
-- cannot run inside a transaction.
--
-- Example (psql):
-- psql "$DATABASE_URL" -v ON_ERROR_STOP=1 \
-- -f scripts/ops/users_github_id_unique_index.sql

-- Keep recovery, creation, and validation in one serialized session. An
-- invalid index can also be a concurrent build that another deploy is still
-- running, so a second invocation must wait instead of dropping it.
SELECT pg_catalog.pg_advisory_lock(
pg_catalog.hashtextextended('public.users_github_id_key', 0)
);

-- A failed concurrent build leaves an invalid same-named index. Generate a
-- concurrent drop only for that recoverable state; valid indexes remain intact.
SELECT format(
'DROP INDEX CONCURRENTLY %I.%I',
index_namespace.nspname,
idx.relname
)
FROM pg_catalog.pg_class AS idx
JOIN pg_catalog.pg_namespace AS index_namespace
ON index_namespace.oid = idx.relnamespace
JOIN pg_catalog.pg_index AS index_meta
ON index_meta.indexrelid = idx.oid
WHERE index_namespace.nspname = 'public'
AND idx.relname = 'users_github_id_key'
AND NOT index_meta.indisvalid
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
\gexec
Comment thread
coderabbitai[bot] marked this conversation as resolved.

CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS users_github_id_key
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
ON public.users (github_id);
Comment thread
WcaleNieWolny marked this conversation as resolved.

DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_catalog.pg_class AS idx
JOIN pg_catalog.pg_namespace AS idx_ns
ON idx_ns.oid = idx.relnamespace
JOIN pg_catalog.pg_index AS index_meta
ON index_meta.indexrelid = idx.oid
JOIN pg_catalog.pg_class AS indexed_table
ON indexed_table.oid = index_meta.indrelid
JOIN pg_catalog.pg_namespace AS table_ns
ON table_ns.oid = indexed_table.relnamespace
JOIN pg_catalog.pg_attribute AS indexed_column
ON indexed_column.attrelid = indexed_table.oid
AND indexed_column.attname = 'github_id'
AND NOT indexed_column.attisdropped
JOIN pg_catalog.pg_am AS access_method
ON access_method.oid = idx.relam
WHERE idx_ns.nspname = 'public'
AND idx.relname = 'users_github_id_key'
AND table_ns.nspname = 'public'
AND indexed_table.relname = 'users'
AND index_meta.indisvalid
AND index_meta.indisready
AND index_meta.indisunique
AND index_meta.indislive
AND NOT index_meta.indnullsnotdistinct
AND index_meta.indpred IS NULL
AND index_meta.indexprs IS NULL
AND index_meta.indnkeyatts = 1
AND index_meta.indnatts = 1
AND index_meta.indkey[0] = indexed_column.attnum
AND access_method.amname = 'btree'
) THEN
RAISE EXCEPTION '%',
'Index public.users_github_id_key is not a valid, ready, unique '
|| 'btree index on public.users(github_id) with NULLS DISTINCT. '
|| 'Drop it and rerun this script.';
END IF;
END
$$;

SELECT pg_catalog.pg_advisory_unlock(
pg_catalog.hashtextextended('public.users_github_id_key', 0)
);
6 changes: 4 additions & 2 deletions src/pages/settings/account/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import iconFlag from '~icons/heroicons/flag?raw'
import iconName from '~icons/heroicons/user?raw'
import { getRecentEmailOtpVerification } from '~/services/emailOtp'
import { getFormatLocaleOptions, resolveFormatLocale } from '~/services/formatLocale'
import { getGitHubProfile, GitHubProfileError } from '~/services/githubProfile'
import { getGitHubProfile, GitHubProfileError, isGitHubAccountAlreadyLinkedError } from '~/services/githubProfile'
import { pickPhoto, takePhoto } from '~/services/photos'
import { getCurrentPlanNameOrg, isPayingOrg, useSupabase } from '~/services/supabase'
import { useDialogV2Store } from '~/stores/dialogv2'
Expand Down Expand Up @@ -131,7 +131,9 @@ async function confirmGitHubProfile() {

if (error || !user) {
githubProfile.value = null
githubProfileError.value = t('account-error')
githubProfileError.value = isGitHubAccountAlreadyLinkedError(error)
? t('github-username-error-already_linked')
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
: t('account-error')
return
}

Expand Down
7 changes: 7 additions & 0 deletions src/services/githubProfile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,13 @@ export function normalizeGitHubUsername(username: string) {
return username.trim()
}

export function isGitHubAccountAlreadyLinkedError(error: unknown): boolean {
return typeof error === 'object'
&& error !== null
&& 'code' in error
&& error.code === '23505'
}

export async function getGitHubProfile(username: string): Promise<GitHubProfile> {
const normalizedUsername = normalizeGitHubUsername(username)
if (!/^(?:[a-z\d]|[a-z\d](?:[a-z\d]|-(?=[a-z\d])){0,37}[a-z\d])$/i.test(normalizedUsername))
Expand Down
92 changes: 92 additions & 0 deletions supabase/migrations/20260802171300_enforce_unique_github_id.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
DO $$
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
DECLARE
duplicate_github_id bigint;
duplicate_count bigint;
BEGIN
SELECT github_id, COUNT(*)
INTO duplicate_github_id, duplicate_count
FROM public.users
WHERE github_id IS NOT NULL
GROUP BY github_id
HAVING COUNT(*) > 1
ORDER BY github_id
LIMIT 1;

IF duplicate_github_id IS NOT NULL THEN
RAISE EXCEPTION
'Cannot enforce unique GitHub account links: '
'github_id % is linked to % users',
duplicate_github_id,
duplicate_count;
END IF;
END
$$;

-- Production operators must prebuild this index concurrently with
-- scripts/ops/users_github_id_unique_index.sql before applying the migration.
-- Refuse the blocking fallback on every populated database. Fresh local/test
-- databases are empty here, so they remain self-contained without allowing a
-- missed production pre-deploy step to silently lock writes.
DO $$
BEGIN
IF pg_catalog.to_regclass('public.users_github_id_key') IS NULL
AND EXISTS (SELECT 1 FROM public.users LIMIT 1)
THEN
RAISE EXCEPTION '%',
'Prebuild public.users_github_id_key concurrently with '
|| 'scripts/ops/users_github_id_unique_index.sql before applying '
|| 'this migration to a populated database.';
END IF;
END
$$;
Comment thread
coderabbitai[bot] marked this conversation as resolved.

CREATE UNIQUE INDEX IF NOT EXISTS users_github_id_key
ON public.users (github_id);

DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_catalog.pg_class AS idx
JOIN pg_catalog.pg_namespace AS idx_ns
ON idx_ns.oid = idx.relnamespace
JOIN pg_catalog.pg_index AS index_meta
ON index_meta.indexrelid = idx.oid
JOIN pg_catalog.pg_class AS indexed_table
ON indexed_table.oid = index_meta.indrelid
JOIN pg_catalog.pg_namespace AS table_ns
ON table_ns.oid = indexed_table.relnamespace
JOIN pg_catalog.pg_attribute AS indexed_column
ON indexed_column.attrelid = indexed_table.oid
AND indexed_column.attname = 'github_id'
AND NOT indexed_column.attisdropped
JOIN pg_catalog.pg_am AS access_method
ON access_method.oid = idx.relam
WHERE idx_ns.nspname = 'public'
AND idx.relname = 'users_github_id_key'
AND table_ns.nspname = 'public'
AND indexed_table.relname = 'users'
AND index_meta.indisvalid
AND index_meta.indisready
AND index_meta.indisunique
AND index_meta.indislive
AND NOT index_meta.indnullsnotdistinct
AND index_meta.indpred IS NULL
AND index_meta.indexprs IS NULL
AND index_meta.indnkeyatts = 1
AND index_meta.indnatts = 1
AND index_meta.indkey[0] = indexed_column.attnum
AND access_method.amname = 'btree'
) THEN
RAISE EXCEPTION '%',
'Index public.users_github_id_key is not a valid, ready, unique '
|| 'btree index on public.users(github_id) with NULLS DISTINCT. '
|| 'Drop it and rerun scripts/ops/users_github_id_unique_index.sql '
|| 'before applying this migration.';
END IF;
END
$$;
Comment thread
coderabbitai[bot] marked this conversation as resolved.

ALTER TABLE public.users
ADD CONSTRAINT users_github_id_key
UNIQUE USING INDEX users_github_id_key;
122 changes: 121 additions & 1 deletion supabase/tests/65_test_github_user_id.sql
Original file line number Diff line number Diff line change
@@ -1,6 +1,31 @@
BEGIN;

SELECT plan(3);
SELECT plan(9);

SELECT tests.create_supabase_user(
'github_id_unique_first',
'github_id_unique_first@test.local'
);
SELECT tests.create_supabase_user(
'github_id_unique_second',
'github_id_unique_second@test.local'
);

INSERT INTO public.users (id, email, created_at, updated_at)
VALUES
(
tests.get_supabase_uid('github_id_unique_first'),
'github_id_unique_first@test.local',
NOW(),
NOW()
),
(
tests.get_supabase_uid('github_id_unique_second'),
'github_id_unique_second@test.local',
NOW(),
NOW()
)
ON CONFLICT (id) DO NOTHING;

SELECT ok(
EXISTS (
Expand Down Expand Up @@ -37,6 +62,101 @@ SELECT is(
'users.github_id is nullable'
);

SELECT is(
(
SELECT contype::text
FROM pg_constraint
WHERE conrelid = 'public.users'::regclass
AND conname = 'users_github_id_key'
),
'u',
'users.github_id has the named unique constraint'
);

SELECT ok(
EXISTS (
SELECT 1
FROM pg_catalog.pg_constraint AS unique_constraint
JOIN pg_catalog.pg_index AS index_meta
ON index_meta.indexrelid = unique_constraint.conindid
JOIN pg_catalog.pg_attribute AS indexed_column
ON indexed_column.attrelid = unique_constraint.conrelid
AND indexed_column.attname = 'github_id'
AND NOT indexed_column.attisdropped
JOIN pg_catalog.pg_class AS idx
ON idx.oid = index_meta.indexrelid
JOIN pg_catalog.pg_am AS access_method
ON access_method.oid = idx.relam
WHERE unique_constraint.conrelid = 'public.users'::regclass
AND unique_constraint.conname = 'users_github_id_key'
AND index_meta.indisvalid
AND index_meta.indisready
AND index_meta.indisunique
AND index_meta.indislive
AND NOT index_meta.indnullsnotdistinct
AND index_meta.indpred IS NULL
AND index_meta.indexprs IS NULL
AND index_meta.indnkeyatts = 1
AND index_meta.indnatts = 1
AND index_meta.indkey[0] = indexed_column.attnum
AND access_method.amname = 'btree'
),
'the constraint uses the validated GitHub ID index'
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

UPDATE public.users
SET github_id = 123456789
WHERE id = tests.get_supabase_uid('github_id_unique_first');

SELECT throws_ok(
$$
UPDATE public.users
SET github_id = 123456789
WHERE id = tests.get_supabase_uid('github_id_unique_second');
$$,
'23505',
'duplicate key value violates unique constraint "users_github_id_key"',
'different users cannot save the same non-null GitHub ID'
);

SELECT lives_ok(
$$
UPDATE public.users
SET github_id = NULL
WHERE id IN (
tests.get_supabase_uid('github_id_unique_first'),
tests.get_supabase_uid('github_id_unique_second')
);
$$,
'multiple users may have a null GitHub ID'
);

UPDATE public.users
SET github_id = 123456789
WHERE id = tests.get_supabase_uid('github_id_unique_first');

SELECT lives_ok(
$$
UPDATE public.users
SET github_id = 123456789
WHERE id = tests.get_supabase_uid('github_id_unique_first');
$$,
'a user may re-save their own GitHub ID'
);

SELECT lives_ok(
$$
UPDATE public.users
SET github_id = NULL
WHERE id = tests.get_supabase_uid('github_id_unique_first');

UPDATE public.users
SET github_id = 123456789
WHERE id = tests.get_supabase_uid('github_id_unique_second');
$$,
'clearing a GitHub link lets another user claim that ID'
);

SELECT * FROM finish(); -- noqa: AM04

ROLLBACK;
15 changes: 14 additions & 1 deletion tests/github-profile.unit.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { GitHubProfileError } from '../src/services/githubProfile'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { getGitHubProfile, normalizeGitHubUsername } from '../src/services/githubProfile'
import { getGitHubProfile, isGitHubAccountAlreadyLinkedError, normalizeGitHubUsername } from '../src/services/githubProfile'

const originalFetch = globalThis.fetch

Expand All @@ -9,6 +9,19 @@ afterEach(() => {
})

describe('github profile lookup', () => {
it('recognizes a PostgREST unique violation as an already-linked GitHub account', () => {
expect(isGitHubAccountAlreadyLinkedError({ code: '23505' })).toBe(true)
})

it('does not treat malformed or unrelated errors as an already-linked GitHub account', () => {
expect(isGitHubAccountAlreadyLinkedError(null)).toBe(false)
expect(isGitHubAccountAlreadyLinkedError(undefined)).toBe(false)
expect(isGitHubAccountAlreadyLinkedError('23505')).toBe(false)
expect(isGitHubAccountAlreadyLinkedError({})).toBe(false)
expect(isGitHubAccountAlreadyLinkedError({ code: 23505 })).toBe(false)
expect(isGitHubAccountAlreadyLinkedError({ code: '42501' })).toBe(false)
})

it('normalizes the entered username and requests the public GitHub API', async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({
id: 42,
Expand Down
Loading