feat: make extension system packageable via enriched ExtensionContext
Enrich ExtensionContext with supabase, emit(), settings, storage, log, and services so extensions can receive everything through dependency injection instead of importing core modules directly. - Add context factory and inject context into event handlers via registry - Move supplier invoice journal entry creation to core event handler - Add services.ingestTransactions to ExtensionContext for enable-banking - Create catch-all API route for extension-declared apiRoutes - Migrate 5 extensions to accept context with dynamic import fallbacks Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
59d935f2cc
commit
ef5a84a5d5
@@ -0,0 +1,200 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import {
|
||||
createQueuedMockSupabase,
|
||||
createMockRequest,
|
||||
parseJsonResponse,
|
||||
} from '@/tests/helpers'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: vi.fn(),
|
||||
}))
|
||||
|
||||
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',
|
||||
extensionId: 'test-ext',
|
||||
}),
|
||||
}))
|
||||
|
||||
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 }) }
|
||||
}
|
||||
|
||||
describe('Extension Catch-All Route', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
extensionRegistry.clear()
|
||||
})
|
||||
|
||||
it('returns 400 for empty path', async () => {
|
||||
const request = createMockRequest('/api/extensions/ext/')
|
||||
const response = await GET(request, createPathParams([]))
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
})
|
||||
|
||||
it('returns 404 for unknown extension', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
supabase.auth.getUser.mockResolvedValue({
|
||||
data: { user: { id: 'user-1' } },
|
||||
error: null,
|
||||
})
|
||||
mockCreateClient.mockResolvedValue(supabase as never)
|
||||
|
||||
const request = createMockRequest('/api/extensions/ext/nonexistent/foo')
|
||||
const response = await GET(request, createPathParams(['nonexistent', 'foo']))
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(404)
|
||||
})
|
||||
|
||||
it('returns 401 when not authenticated', 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: null },
|
||||
error: { message: 'Not authenticated' },
|
||||
})
|
||||
mockCreateClient.mockResolvedValue(supabase as never)
|
||||
|
||||
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(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',
|
||||
name: 'Test',
|
||||
version: '1.0.0',
|
||||
apiRoutes: [{ method: 'POST', 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(true)
|
||||
|
||||
// GET doesn't match POST /data
|
||||
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(404)
|
||||
})
|
||||
|
||||
it('dispatches to matching handler with context', async () => {
|
||||
const handler = vi.fn().mockResolvedValue(
|
||||
NextResponse.json({ banks: [] })
|
||||
)
|
||||
|
||||
extensionRegistry.register({
|
||||
id: 'enable-banking',
|
||||
name: 'Enable Banking',
|
||||
version: '1.0.0',
|
||||
apiRoutes: [{ method: 'GET', path: '/banks', handler }],
|
||||
})
|
||||
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
supabase.auth.getUser.mockResolvedValue({
|
||||
data: { user: { id: 'user-1' } },
|
||||
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']))
|
||||
const { status, body } = await parseJsonResponse<{ banks: unknown[] }>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.banks).toEqual([])
|
||||
expect(handler).toHaveBeenCalledWith(request, expect.objectContaining({
|
||||
extensionId: 'test-ext',
|
||||
}))
|
||||
})
|
||||
|
||||
it('dispatches POST requests correctly', async () => {
|
||||
const handler = vi.fn().mockResolvedValue(
|
||||
NextResponse.json({ ok: true })
|
||||
)
|
||||
|
||||
extensionRegistry.register({
|
||||
id: 'test-ext',
|
||||
name: 'Test',
|
||||
version: '1.0.0',
|
||||
apiRoutes: [{ method: 'POST', path: '/connect', handler }],
|
||||
})
|
||||
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
supabase.auth.getUser.mockResolvedValue({
|
||||
data: { user: { id: 'user-1' } },
|
||||
error: null,
|
||||
})
|
||||
mockCreateClient.mockResolvedValue(supabase as never)
|
||||
mockIsEnabled.mockResolvedValue(true)
|
||||
|
||||
const request = createMockRequest('/api/extensions/ext/test-ext/connect', {
|
||||
method: 'POST',
|
||||
body: { foo: 'bar' },
|
||||
})
|
||||
const response = await POST(request, createPathParams(['test-ext', 'connect']))
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(handler).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,73 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
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'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
/**
|
||||
* Catch-all route for extension-declared API routes.
|
||||
*
|
||||
* URL scheme: /api/extensions/ext/{extensionId}/{...routePath}
|
||||
* Example: /api/extensions/ext/enable-banking/banks → GET /banks
|
||||
*
|
||||
* - Looks up the extension in the registry
|
||||
* - Checks the extension toggle (disabled → 403)
|
||||
* - Matches method + path to registered apiRoutes
|
||||
* - Builds an ExtensionContext and passes it to the handler
|
||||
*/
|
||||
async function handleRequest(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ path: string[] }> }
|
||||
): Promise<Response> {
|
||||
const segments = await params
|
||||
|
||||
if (!segments.path || segments.path.length < 1) {
|
||||
return NextResponse.json({ error: 'Invalid extension route' }, { status: 400 })
|
||||
}
|
||||
|
||||
const [extensionId, ...rest] = segments.path
|
||||
const routePath = '/' + rest.join('/')
|
||||
const method = request.method as 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'
|
||||
|
||||
// Look up extension
|
||||
const extension = extensionRegistry.get(extensionId)
|
||||
if (!extension || !extension.apiRoutes || extension.apiRoutes.length === 0) {
|
||||
return NextResponse.json({ error: 'Extension not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Auth check
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Toggle check — disabled extensions return 403
|
||||
const enabled = await isExtensionEnabled(user.id, 'general', extensionId)
|
||||
if (!enabled) {
|
||||
return NextResponse.json({ error: 'Extension is disabled' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Find matching route
|
||||
const route = extension.apiRoutes.find(
|
||||
(r) => r.method === method && r.path === routePath
|
||||
)
|
||||
|
||||
if (!route) {
|
||||
return NextResponse.json({ error: 'Route not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Build context and dispatch
|
||||
const ctx = createExtensionContext(supabase, user.id, extensionId)
|
||||
return route.handler(request, ctx)
|
||||
}
|
||||
|
||||
export const GET = handleRequest
|
||||
export const POST = handleRequest
|
||||
export const PUT = handleRequest
|
||||
export const DELETE = handleRequest
|
||||
export const PATCH = handleRequest
|
||||
Reference in New Issue
Block a user