From 1a6de701643fa49beb7cf1f4695a5f86d0f6914a Mon Sep 17 00:00:00 2001 From: Jimmy Thompson Date: Tue, 25 Aug 2026 10:04:21 +0100 Subject: [PATCH] fix: User plugin /api routes take precedence over catch all /api route Signed-off-by: Jimmy Thompson --- packages/core/src/__tests__/app.test.ts | 42 +++++++++++++++++++++++++ packages/core/src/app.ts | 42 +++++++++++++------------ 2 files changed, 64 insertions(+), 20 deletions(-) create mode 100644 packages/core/src/__tests__/app.test.ts diff --git a/packages/core/src/__tests__/app.test.ts b/packages/core/src/__tests__/app.test.ts new file mode 100644 index 000000000..e2e658a6c --- /dev/null +++ b/packages/core/src/__tests__/app.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; +import { createSonicJSApp } from '../app'; +import { createRedirectPlugin } from '../plugins/redirect-management'; + +describe('createSonicJSApp — plugin route ordering', () => { + it('GET /api/redirects reaches the redirect plugin, not the /:collection catch-all', async () => { + const app = createSonicJSApp({ + plugins: { + register: [createRedirectPlugin() as any], + }, + }); + + // Simulate a request with a minimal env (DB stub, development mode for open API access) + const res = await app.request( + '/api/redirects', + { + headers: { 'x-test-role': 'admin' }, + }, + { + DB: { + prepare: () => ({ + bind: (..._args: unknown[]) => ({ + all: async () => ({ results: [] }), + first: async () => null, + run: async () => ({ success: true }), + }), + all: async () => ({ results: [] }), + }), + batch: async () => [], + }, + ENVIRONMENT: 'development', + }, + ); + + const body = await res.json(); + + // The redirect plugin should handle this request. + // If the catch-all shadows it, we get { error: "Collection not found" } with 404. + expect(res.status).not.toBe(404); + expect(body).not.toHaveProperty('error', 'Collection not found'); + }); +}); diff --git a/packages/core/src/app.ts b/packages/core/src/app.ts index ff5209a43..838e933c4 100644 --- a/packages/core/src/app.ts +++ b/packages/core/src/app.ts @@ -628,29 +628,13 @@ export function createSonicJSApp(config: SonicJSConfig = {}): SonicJSApp { app.route('/api/media', apiMediaRoutes) app.route('/api/system', apiSystemRoutes) app.route('/api/documents', apiDocumentsRoutes) - app.route('/api', apiRoutes) - app.route('/admin/documents', adminDocumentsRoutes) - - - // Forms (admin builder, public rendering, API submission). Same as above — - // routes[] was replaced with register(app) in the definePlugin port. - registerPluginRoutes(app, [formsPlugin as any], { source: 'core' }) - - app.route('/admin/api', adminApiRoutes) - app.route('/admin/collections', adminCollectionsRoutes) - app.route('/admin/settings', adminSettingsRoutes) - app.route('/admin/api-reference', adminApiReferenceRoutes) - app.route('/admin/database-tools', createDatabaseToolsAdminRoutes()) - app.route('/admin/content', adminContentRoutes) - app.route('/admin/media', adminMediaRoutes) - // Security audit middleware - logs auth events (login, register, logout) - app.use('/auth/*', securityAuditMiddleware()) - // ── Plugin routes (before the /admin catch-all) ─────────────────────────── + // ── Plugin routes (before the /api catch-all) ───────────────────────────── // All plugin route mounting flows through registerPluginRoutes() (see // plugins/mount.ts), which mounts each plugin's declarative routes[] and/or - // synchronous register(app) hook. These MUST be mounted before the bare - // `/admin` catch-all so plugin-owned `/admin/` pages are not shadowed. + // synchronous register(app) hook. These MUST be mounted before both the bare + // `/api` catch-all (whose /:collection param shadows /api/* plugin routes) and + // the `/admin` catch-all so plugin-owned pages are not shadowed. // // `disableAll` turns off every plugin — core AND user — for a bare core app. if (!config.plugins?.disableAll) { @@ -667,6 +651,24 @@ export function createSonicJSApp(config: SonicJSConfig = {}): SonicJSApp { } } + app.route('/api', apiRoutes) + app.route('/admin/documents', adminDocumentsRoutes) + + + // Forms (admin builder, public rendering, API submission). Same as above — + // routes[] was replaced with register(app) in the definePlugin port. + registerPluginRoutes(app, [formsPlugin as any], { source: 'core' }) + + app.route('/admin/api', adminApiRoutes) + app.route('/admin/collections', adminCollectionsRoutes) + app.route('/admin/settings', adminSettingsRoutes) + app.route('/admin/api-reference', adminApiReferenceRoutes) + app.route('/admin/database-tools', createDatabaseToolsAdminRoutes()) + app.route('/admin/content', adminContentRoutes) + app.route('/admin/media', adminMediaRoutes) + // Security audit middleware - logs auth events (login, register, logout) + app.use('/auth/*', securityAuditMiddleware()) + // Public event tracking API — POST /api/events (open), GET /api/events (admin) app.route('/api/events', eventsApiRoutes)