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:
co-authored by
Claude Opus 4.6
parent
38b658205d
commit
cf77adaa0a
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
}
|
||||
Reference in New Issue
Block a user