refactor: remove extension toggle system — compiled-in extensions are always active (#59)

The runtime toggle system (extension_toggles table, API routes, hooks, UI components)
added unnecessary complexity. Extensions controlled via extensions.config.json at build
time are now always active for all users. This removes ~835 lines of toggle-related code
including API routes, DB queries, the ExtensionToggleButton component, useEnabledExtensions
and useExtensionToggle hooks, and the toggle-check module. AI consent gating remains
unchanged.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-03-20 09:31:29 +01:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 38b658205d
commit cf77adaa0a
30 changed files with 115 additions and 833 deletions
@@ -15,10 +15,6 @@ vi.mock('@/lib/init', () => ({
ensureInitialized: vi.fn(),
}))
vi.mock('@/lib/extensions/toggle-check', () => ({
isExtensionEnabled: vi.fn(),
}))
vi.mock('@/lib/extensions/context-factory', () => ({
createExtensionContext: vi.fn().mockReturnValue({
userId: 'user-1',
@@ -28,11 +24,9 @@ vi.mock('@/lib/extensions/context-factory', () => ({
import { createClient } from '@/lib/supabase/server'
import { extensionRegistry } from '@/lib/extensions/registry'
import { isExtensionEnabled } from '@/lib/extensions/toggle-check'
import { GET, POST } from '../route'
const mockCreateClient = vi.mocked(createClient)
const mockIsEnabled = vi.mocked(isExtensionEnabled)
function createPathParams(path: string[]) {
return { params: Promise.resolve({ path }) }
@@ -89,29 +83,6 @@ describe('Extension Catch-All Route', () => {
expect(status).toBe(401)
})
it('returns 403 when extension is disabled', async () => {
extensionRegistry.register({
id: 'test-ext',
name: 'Test',
version: '1.0.0',
apiRoutes: [{ method: 'GET', path: '/data', handler: vi.fn() }],
})
const { supabase } = createQueuedMockSupabase()
supabase.auth.getUser.mockResolvedValue({
data: { user: { id: 'user-1' } },
error: null,
})
mockCreateClient.mockResolvedValue(supabase as never)
mockIsEnabled.mockResolvedValue(false)
const request = createMockRequest('/api/extensions/ext/test-ext/data')
const response = await GET(request, createPathParams(['test-ext', 'data']))
const { status } = await parseJsonResponse(response)
expect(status).toBe(403)
})
it('returns 404 for unmatched method/path', async () => {
extensionRegistry.register({
id: 'test-ext',
@@ -126,7 +97,6 @@ describe('Extension Catch-All Route', () => {
error: null,
})
mockCreateClient.mockResolvedValue(supabase as never)
mockIsEnabled.mockResolvedValue(true)
// GET doesn't match POST /data
const request = createMockRequest('/api/extensions/ext/test-ext/data')
@@ -154,7 +124,6 @@ describe('Extension Catch-All Route', () => {
error: null,
})
mockCreateClient.mockResolvedValue(supabase as never)
mockIsEnabled.mockResolvedValue(true)
const request = createMockRequest('/api/extensions/ext/enable-banking/banks')
const response = await GET(request, createPathParams(['enable-banking', 'banks']))
@@ -185,7 +154,6 @@ describe('Extension Catch-All Route', () => {
error: null,
})
mockCreateClient.mockResolvedValue(supabase as never)
mockIsEnabled.mockResolvedValue(true)
const request = createMockRequest('/api/extensions/ext/test-ext/connect', {
method: 'POST',
@@ -3,7 +3,6 @@ import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { extensionRegistry } from '@/lib/extensions/registry'
import { createExtensionContext } from '@/lib/extensions/context-factory'
import { isExtensionEnabled } from '@/lib/extensions/toggle-check'
import { hasAiConsent, isAiExtension } from '@/lib/extensions/ai-consent'
import type { ApiRouteDefinition } from '@/lib/extensions/types'
@@ -118,13 +117,6 @@ async function handleRequest(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Toggle check — use extension's declared sector, fallback to 'general'
const sector = extension.sector || 'general'
const enabled = await isExtensionEnabled(user.id, sector, extensionId)
if (!enabled) {
return NextResponse.json({ error: 'Extension is disabled' }, { status: 403 })
}
// AI consent check
if (isAiExtension(extensionId)) {
const consented = await hasAiConsent(supabase, user.id, extensionId)
@@ -1,59 +0,0 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { LEGACY_GENERAL_EXTENSIONS } from '@/lib/extensions/toggle-check'
export async function GET(
_request: Request,
{ params }: { params: Promise<{ sector: string; slug: string }> }
) {
const { sector, slug } = await params
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { data } = await supabase
.from('extension_toggles')
.select('*')
.eq('user_id', user.id)
.eq('sector_slug', sector)
.eq('extension_slug', slug)
.single()
if (data) {
return NextResponse.json({ data })
}
// No toggle row: legacy general extensions default to enabled
const defaultEnabled =
sector === 'general' && LEGACY_GENERAL_EXTENSIONS.includes(slug)
return NextResponse.json({ data: { enabled: defaultEnabled } })
}
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ sector: string; slug: string }> }
) {
const { sector, slug } = await params
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { error } = await supabase
.from('extension_toggles')
.delete()
.eq('user_id', user.id)
.eq('sector_slug', sector)
.eq('extension_slug', slug)
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ success: true })
}
@@ -1,105 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
createMockRequest,
parseJsonResponse,
createQueuedMockSupabase,
makeExtensionToggle,
} from '@/tests/helpers'
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
vi.mock('@/lib/supabase/server', () => ({
createClient: () => Promise.resolve(mockSupabase),
}))
import { GET, POST } from '../route'
describe('GET /api/extensions/toggles', () => {
const mockUser = { id: 'user-1', email: 'test@test.se' }
beforeEach(() => {
vi.clearAllMocks()
reset()
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
})
it('returns 401 when not authenticated', async () => {
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
const response = await GET()
const { status, body } = await parseJsonResponse(response)
expect(status).toBe(401)
expect(body).toEqual({ error: 'Unauthorized' })
})
it('returns enabled toggles for user', async () => {
const toggles = [
makeExtensionToggle({ extension_slug: 'receipt-ocr' }),
makeExtensionToggle({ extension_slug: 'ai-chat' }),
]
enqueue({ data: toggles, error: null })
const response = await GET()
const { status, body } = await parseJsonResponse<{ data: unknown[] }>(response)
expect(status).toBe(200)
expect(body.data).toEqual(toggles)
expect(mockSupabase.from).toHaveBeenCalledWith('extension_toggles')
})
})
describe('POST /api/extensions/toggles', () => {
const mockUser = { id: 'user-1', email: 'test@test.se' }
beforeEach(() => {
vi.clearAllMocks()
reset()
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
})
it('returns 401 when not authenticated', async () => {
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
const request = createMockRequest('/api/extensions/toggles', {
method: 'POST',
body: { sector_slug: 'general', extension_slug: 'receipt-ocr', enabled: true },
})
const response = await POST(request)
const { status, body } = await parseJsonResponse(response)
expect(status).toBe(401)
expect(body).toEqual({ error: 'Unauthorized' })
})
it('returns 400 when missing fields', async () => {
const request = createMockRequest('/api/extensions/toggles', {
method: 'POST',
body: { sector_slug: 'general' },
})
const response = await POST(request)
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(400)
expect(body.error).toBe('sector_slug, extension_slug, and enabled are required')
})
it('upserts toggle and returns data', async () => {
const toggle = makeExtensionToggle({
sector_slug: 'general',
extension_slug: 'receipt-ocr',
enabled: true,
})
enqueue({ data: toggle, error: null })
const request = createMockRequest('/api/extensions/toggles', {
method: 'POST',
body: { sector_slug: 'general', extension_slug: 'receipt-ocr', enabled: true },
})
const response = await POST(request)
const { status, body } = await parseJsonResponse<{ data: unknown }>(response)
expect(status).toBe(200)
expect(body.data).toEqual(toggle)
expect(mockSupabase.from).toHaveBeenCalledWith('extension_toggles')
})
})
-62
View File
@@ -1,62 +0,0 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
export async function GET() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { data, error } = await supabase
.from('extension_toggles')
.select('*')
.eq('user_id', user.id)
.eq('enabled', true)
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data })
}
export async function POST(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const { sector_slug, extension_slug, enabled } = body
if (!sector_slug || !extension_slug || typeof enabled !== 'boolean') {
return NextResponse.json(
{ error: 'sector_slug, extension_slug, and enabled are required' },
{ status: 400 }
)
}
const { data, error } = await supabase
.from('extension_toggles')
.upsert(
{
user_id: user.id,
sector_slug,
extension_slug,
enabled,
},
{ onConflict: 'user_id,sector_slug,extension_slug' }
)
.select()
.single()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data })
}