diff --git a/my-sonicjs-app/src/index.ts b/my-sonicjs-app/src/index.ts
index 13fc6183d..5a7bfd392 100644
--- a/my-sonicjs-app/src/index.ts
+++ b/my-sonicjs-app/src/index.ts
@@ -60,3 +60,4 @@ export default {
boot: app.boot,
}),
};
+
diff --git a/my-sonicjs-app/wrangler.toml b/my-sonicjs-app/wrangler.toml
index 83a518e70..b6c5bc82f 100644
--- a/my-sonicjs-app/wrangler.toml
+++ b/my-sonicjs-app/wrangler.toml
@@ -13,8 +13,8 @@ workers_dev = true
# Note: database_name and database_id are automatically updated by GitHub Actions
[[d1_databases]]
binding = "DB"
-database_name = "sonicjs-worktree-lane711-api-key-copy-before-redirect"
-database_id = "a211809e-e558-4b82-b2f3-9b8de0d5e4d8"
+database_name = "sonicjs-worktree-lane711-menu-plugin-plan"
+database_id = "2bffa108-2c31-4710-9a04-8b4f97b16b72"
migrations_dir = "./migrations"
# R2 Bucket for media storage (using CI bucket)
diff --git a/packages/core/src/__tests__/services/menu-repository.sqlite.test.ts b/packages/core/src/__tests__/services/menu-repository.sqlite.test.ts
new file mode 100644
index 000000000..3dabd1763
--- /dev/null
+++ b/packages/core/src/__tests__/services/menu-repository.sqlite.test.ts
@@ -0,0 +1,199 @@
+// @ts-nocheck
+// Real-SQLite coverage for menu-plugin data layer.
+// Tests: idempotent seeding, plugin reconcile, soft-delete guards, reorder batch.
+import { describe, it, expect, beforeEach, afterEach } from 'vitest'
+import { createTestD1 } from '../utils/d1-sqlite'
+import {
+ listMenuItems,
+ buildSidebarTree,
+ upsertSystemItem,
+ upsertPluginItem,
+ reorderItems,
+ toggleVisibility,
+ updateItem,
+ deleteItem,
+} from '../../plugins/core-plugins/menu-plugin/services/menu-repository'
+
+const MENU_TYPE_SQL = `
+ INSERT INTO document_types (id, name, display_name, schema, queryable_fields, settings, source, schema_version, is_system, is_active, created_at, updated_at)
+ VALUES ('menu_item','menu_item','Menu Item','{}','[]','{}','plugin',1,0,1,1,1)
+`
+
+const MENU_FIELDS = [
+ { name: 'parent', path: '$.parent', kind: 'scalar', type: 'text', column: 'q_menu_parent' },
+ { name: 'visible', path: '$.visible', kind: 'scalar', type: 'boolean', column: 'q_menu_visible' },
+ { name: 'source', path: '$.source', kind: 'scalar', type: 'text', column: 'q_menu_source' },
+ { name: 'pluginId', path: '$.pluginId', kind: 'scalar', type: 'text', column: 'q_menu_plugin_id' },
+]
+
+function systemItemData(overrides = {}) {
+ return {
+ label: 'Content',
+ url: '/admin/content',
+ icon: 'document',
+ target: '_self',
+ isExternal: false,
+ visible: true,
+ parent: null,
+ source: 'system',
+ pluginId: null,
+ permissions: [],
+ lockedFields: ['url', 'parent'],
+ ...overrides,
+ }
+}
+
+describe('menu-repository — real SQLite', () => {
+ let db
+
+ beforeEach(async () => {
+ db = createTestD1()
+ db.raw.prepare(MENU_TYPE_SQL).run()
+ await db.applyScalarSchema('menu_item', MENU_FIELDS)
+ })
+
+ afterEach(() => db.close())
+
+ it('upsertSystemItem is idempotent across two boots — no duplicates', async () => {
+ const slug = 'menu:system:content'
+ const data = systemItemData()
+
+ await upsertSystemItem(db, slug, data, 10)
+ await upsertSystemItem(db, slug, data, 10)
+
+ const items = await listMenuItems(db)
+ const matching = items.filter((i) => i.slug === slug)
+ expect(matching).toHaveLength(1)
+ })
+
+ it('upsertPluginItem upserts on first call then only updates url+pluginId', async () => {
+ const slug = 'menu:plugin:email'
+ const data = {
+ label: 'Email',
+ url: '/admin/email',
+ icon: 'envelope',
+ target: '_self',
+ isExternal: false,
+ visible: true,
+ parent: null,
+ source: 'plugin',
+ pluginId: 'email',
+ permissions: [],
+ lockedFields: ['url', 'parent'],
+ }
+
+ await upsertPluginItem(db, slug, data, 100)
+
+ // Simulate admin renaming label — change label in DB directly
+ const items1 = await listMenuItems(db)
+ const item = items1.find((i) => i.slug === slug)
+ expect(item).toBeDefined()
+ db.raw.prepare(`UPDATE documents SET data = json_set(data, '$.label', ?) WHERE id = ?`)
+ .run('Email (Custom)', item.id)
+
+ // Re-upsert with original data — should NOT overwrite admin's custom label
+ await upsertPluginItem(db, slug, data, 100)
+
+ const items2 = await listMenuItems(db)
+ const updated = items2.find((i) => i.slug === slug)
+ expect(updated.label).toBe('Email (Custom)')
+ expect(updated.url).toBe('/admin/email')
+ })
+
+ it('reorderItems batch-updates sort_order (R1)', async () => {
+ await upsertSystemItem(db, 'menu:system:content', systemItemData({ label: 'Content' }), 10)
+ await upsertSystemItem(db, 'menu:system:users', systemItemData({ label: 'Users', url: '/admin/users' }), 20)
+
+ const before = await listMenuItems(db)
+ const [a, b] = before.map((i) => ({ id: i.id, sortOrder: i.sortOrder }))
+
+ // Swap sort orders
+ await reorderItems(db, [
+ { id: a.id, sortOrder: b.sortOrder, parent: null },
+ { id: b.id, sortOrder: a.sortOrder, parent: null },
+ ])
+
+ const after = await listMenuItems(db)
+ const aAfter = after.find((i) => i.id === a.id)
+ const bAfter = after.find((i) => i.id === b.id)
+ expect(aAfter.sortOrder).toBe(b.sortOrder)
+ expect(bAfter.sortOrder).toBe(a.sortOrder)
+ })
+
+ it('deleteItem blocks system source, allows user source', async () => {
+ await upsertSystemItem(db, 'menu:system:content', systemItemData({ source: 'system' }), 10)
+ const items = await listMenuItems(db)
+ const systemItem = items[0]
+
+ const sysResult = await deleteItem(db, systemItem.id)
+ expect(sysResult.ok).toBe(false)
+ expect(sysResult.reason).toBeDefined()
+
+ // Create a user item
+ const userSlug = 'menu:user:test'
+ await upsertSystemItem(db, userSlug, systemItemData({ source: 'user', lockedFields: [] }), 50)
+ const items2 = await listMenuItems(db)
+ const userItem = items2.find((i) => i.slug === userSlug)
+ expect(userItem).toBeDefined()
+
+ const userResult = await deleteItem(db, userItem.id)
+ expect(userResult.ok).toBe(true)
+
+ const items3 = await listMenuItems(db)
+ expect(items3.find((i) => i.id === userItem.id)).toBeUndefined()
+ })
+
+ it('toggleVisibility updates both column and data JSON', async () => {
+ await upsertSystemItem(db, 'menu:system:settings', systemItemData({ label: 'Settings', url: '/admin/settings' }), 40)
+ const items = await listMenuItems(db)
+ const item = items[0]
+
+ await toggleVisibility(db, item.id, false)
+
+ const row = db.raw.prepare('SELECT visible, data FROM documents WHERE id = ?').get(item.id)
+ expect(row.visible).toBeFalsy()
+ const data = JSON.parse(row.data)
+ expect(data.visible).toBeFalsy()
+ })
+
+ it('updateItem rejects locked fields', async () => {
+ await upsertSystemItem(db, 'menu:system:content', systemItemData(), 10)
+ const items = await listMenuItems(db)
+ const item = items[0]
+
+ // Should be rejected — url is locked
+ const rejected = await updateItem(db, item.id, { url: '/admin/other' }, ['url', 'parent'])
+ expect(rejected).toBe(false)
+
+ // Label change should succeed
+ const ok = await updateItem(db, item.id, { label: 'My Content' }, ['url', 'parent'])
+ expect(ok).toBe(true)
+
+ const updated = (await listMenuItems(db)).find((i) => i.id === item.id)
+ expect(updated.label).toBe('My Content')
+ // URL unchanged
+ expect(updated.url).toBe('/admin/content')
+ })
+
+ it('buildSidebarTree nests children under parents and sorts by sortOrder', async () => {
+ await upsertSystemItem(db, 'menu:system:parent', systemItemData({ label: 'Parent', url: '/admin/parent' }), 10)
+ const all = await listMenuItems(db)
+ const parentId = all[0].id
+
+ // Add child
+ await upsertSystemItem(db, 'menu:system:child', systemItemData({
+ label: 'Child',
+ url: '/admin/parent/child',
+ parent: parentId,
+ source: 'user',
+ lockedFields: [],
+ }), 5)
+
+ const items = await listMenuItems(db)
+ const tree = buildSidebarTree(items)
+
+ expect(tree).toHaveLength(1)
+ expect(tree[0].children).toHaveLength(1)
+ expect(tree[0].children[0].label).toBe('Child')
+ })
+})
diff --git a/packages/core/src/app.ts b/packages/core/src/app.ts
index 566b362fe..48ea2d897 100644
--- a/packages/core/src/app.ts
+++ b/packages/core/src/app.ts
@@ -48,6 +48,8 @@ import { requireAuth, requireRole, requireRbac } from './middleware/auth'
import { createAuth } from './auth/config'
import { adminRbacRoutes } from './routes/admin-rbac'
import { pluginMenuMiddleware } from './middleware/plugin-menu'
+import { menuMiddleware } from './middleware/menu'
+import { menuPlugin } from './plugins/core-plugins/menu-plugin'
import { analyticsPlugin } from './plugins/core-plugins/analytics'
import { eventsApiRoutes } from './plugins/core-plugins/analytics/routes/api'
import { globalVariablesPlugin } from './plugins/core-plugins/global-variables-plugin'
@@ -296,6 +298,7 @@ export function createSonicJSApp(config: SonicJSConfig = {}): SonicJSApp {
multiTenantPlugin,
lexicalEditorPlugin,
versioningPlugin,
+ menuPlugin,
]
const corePluginsAfterCatchAll = [emailPlugin, magicLinkPlugin, emailReconciliationPlugin]
@@ -505,6 +508,8 @@ export function createSonicJSApp(config: SonicJSConfig = {}): SonicJSApp {
// Plugin dynamic menu items for admin sidebar
app.use('/admin/*', pluginMenuMiddleware())
+ // Menu plugin sidebar replacement (DB-driven nav items)
+ app.use('/admin/*', menuMiddleware())
// RBAC-aware admin shell. Computes the signed-in user's effective permission
// set once, then (1) redirects the dashboard landing to the first section the
diff --git a/packages/core/src/db/migrations-bundle.ts b/packages/core/src/db/migrations-bundle.ts
index 4f4f314fa..72e75f532 100644
--- a/packages/core/src/db/migrations-bundle.ts
+++ b/packages/core/src/db/migrations-bundle.ts
@@ -1,7 +1,7 @@
/**
* AUTO-GENERATED FILE - DO NOT EDIT
* Generated by: scripts/generate-migrations.ts
- * Generated at: 2026-07-01T19:11:49.465Z
+ * Generated at: 2026-07-01T23:57:26.248Z
*
* This file contains all migration SQL bundled for use in Cloudflare Workers
* where filesystem access is not available at runtime.
diff --git a/packages/core/src/middleware/menu.ts b/packages/core/src/middleware/menu.ts
new file mode 100644
index 000000000..55e5ff6e6
--- /dev/null
+++ b/packages/core/src/middleware/menu.ts
@@ -0,0 +1,179 @@
+import type { Context, Next } from 'hono'
+import type { Bindings, Variables } from '../app'
+import { listMenuItems, buildSidebarTree } from '../plugins/core-plugins/menu-plugin/services/menu-repository'
+import type { SidebarItem } from '../plugins/core-plugins/menu-plugin/services/menu-repository'
+import { resolveIcon } from '../services/menu-icons'
+import { escapeHtml } from '../utils/sanitize'
+
+const MARKER_START = ''
+const MARKER_END = ''
+
+const EXTERNAL_ICON = ``
+
+const CHEVRON = ``
+
+function renderTopLevelItem(item: SidebarItem, currentPath: string): string {
+ const isActive =
+ currentPath === item.url ||
+ (item.url !== '/admin' && currentPath.startsWith(item.url))
+ const icon = resolveIcon(item.icon)
+ const label = escapeHtml(item.label)
+ const targetAttr = item.target === '_blank' ? ' target="_blank" rel="noopener noreferrer"' : ''
+ const externalAffordance = item.isExternal ? EXTERNAL_ICON : ''
+
+ if (item.children.length > 0) {
+ const accordionId = `acc-${item.id.replace(/[^a-z0-9]/gi, '-')}`
+ const isChildActive = item.children.some(
+ (c) => currentPath === c.url || currentPath.startsWith(c.url),
+ )
+ const accordionActive = isActive || isChildActive
+ return `
+
+ ${accordionActive ? '
' : ''}
+
+
+ ${item.children.map((child) => renderChildItem(child, currentPath)).join('')}
+
+
`
+ }
+
+ return `
+
+ ${isActive ? '' : ''}
+
+ ${icon}
+ ${label}${externalAffordance}
+
+ `
+}
+
+function renderChildItem(item: SidebarItem, currentPath: string): string {
+ const isActive =
+ currentPath === item.url ||
+ (item.url !== '/admin' && currentPath.startsWith(item.url))
+ const icon = resolveIcon(item.icon)
+ const label = escapeHtml(item.label)
+ const targetAttr = item.target === '_blank' ? ' target="_blank" rel="noopener noreferrer"' : ''
+ const externalAffordance = item.isExternal ? EXTERNAL_ICON : ''
+
+ return `
+
+ ${isActive ? '' : ''}
+
+ ${icon}
+ ${label}${externalAffordance}
+
+ `
+}
+
+const ACCORDION_SCRIPT = `
+`
+
+async function isMenuPluginActive(db: any): Promise {
+ try {
+ // Plugins live in documents (type_id='plugin', slug=pluginId) — not the legacy plugins table
+ const row = await db
+ .prepare(
+ `SELECT json_extract(data, '$.status') AS status
+ FROM documents
+ WHERE slug = 'menu' AND type_id = 'plugin' AND tenant_id = 'default'
+ AND is_current_draft = 1 AND deleted_at IS NULL
+ LIMIT 1`,
+ )
+ .first() as { status: string } | null
+ // Not yet in DB (never installed/activated) → off by default
+ if (!row) return false
+ return row.status === 'active'
+ } catch {
+ return false
+ }
+}
+
+export function menuMiddleware() {
+ return async (c: Context<{ Bindings: Bindings; Variables: Variables }>, next: Next) => {
+ const path = new URL(c.req.url).pathname
+ if (!path.startsWith('/admin')) {
+ return next()
+ }
+
+ await next()
+
+ if (!c.res.headers.get('content-type')?.includes('text/html')) return
+
+ // Revert to hardcoded sidebar when plugin is explicitly disabled
+ if (!(await isMenuPluginActive(c.env.DB))) return
+
+ let items: Awaited> = []
+ try {
+ items = await listMenuItems(c.env.DB)
+ } catch {
+ return
+ }
+
+ if (items.length === 0) return
+
+ const tree = buildSidebarTree(items)
+ const renderedItems = tree.map((item) => renderTopLevelItem(item, path)).join('')
+ const navItemsHtml = renderedItems + ACCORDION_SCRIPT
+
+ const status = c.res.status
+ const headers = new Headers(c.res.headers)
+ const html = await c.res.text()
+
+ // Replace ALL occurrences of the marker pair (desktop + mobile sidebars both use it)
+ const markerRegex = /[\s\S]*?/g
+ if (markerRegex.test(html)) {
+ const newHtml = html.replace(
+ /[\s\S]*?/g,
+ navItemsHtml,
+ )
+ c.res = new Response(newHtml, { status, headers })
+ } else {
+ c.res = new Response(html, { status, headers })
+ }
+ }
+}
diff --git a/packages/core/src/plugins/core-plugins/index.ts b/packages/core/src/plugins/core-plugins/index.ts
index 88910e555..1da3f345d 100644
--- a/packages/core/src/plugins/core-plugins/index.ts
+++ b/packages/core/src/plugins/core-plugins/index.ts
@@ -43,6 +43,7 @@ export { stripePlugin, createStripePlugin, SubscriptionService, StripeAPI, requi
export { dashboardPlugin, createDashboardPlugin } from './dashboard-plugin'
export { multiTenantPlugin, createMultiTenantPlugin, TenantService } from './multi-tenant-plugin'
export { versioningPlugin, createVersioningPlugin } from './versioning-plugin'
+export { menuPlugin, createMenuPlugin } from './menu-plugin'
// Core plugins list - now imported from auto-generated registry
export const CORE_PLUGIN_IDS = [
@@ -65,7 +66,8 @@ export const CORE_PLUGIN_IDS = [
'user-profiles',
'stripe',
'multi-tenant',
- 'versioning'
+ 'versioning',
+ 'menu',
] as const
export type CorePluginNames = (typeof CORE_PLUGIN_IDS)[number]
diff --git a/packages/core/src/plugins/core-plugins/menu-plugin/index.ts b/packages/core/src/plugins/core-plugins/menu-plugin/index.ts
new file mode 100644
index 000000000..107ae1ba5
--- /dev/null
+++ b/packages/core/src/plugins/core-plugins/menu-plugin/index.ts
@@ -0,0 +1,92 @@
+import { definePlugin } from '../../sdk'
+import { DocumentTypeRegistry } from '../../../services/document-type-registry'
+import { SYSTEM_MENU_ITEMS } from './services/menu-defaults'
+import { upsertSystemItem, listMenuItems, fetchPluginStatuses } from './services/menu-repository'
+import { reconcileMenuFromPlugins } from './services/menu-reconcile'
+import { adminMenuRoutes } from './routes/admin-menu'
+import { renderMenuSettingsContent } from './templates/admin-menu-list.template'
+import { z } from 'zod'
+import type { D1Database } from '@cloudflare/workers-types'
+
+export const menuPlugin = definePlugin({
+ id: 'menu',
+ version: '1.0.0',
+ name: 'Menu Manager',
+ description: 'Admin sidebar navigation manager.',
+ sonicjsVersionRange: '^3.0.0',
+ author: { name: 'SonicJS Team', email: 'team@sonicjs.com' },
+
+ register(app) {
+ app.route('/admin/menu', adminMenuRoutes as any)
+ },
+
+ async onBoot(ctx) {
+ const env = (ctx.env ?? {}) as Record
+ const db = env.DB as D1Database | undefined
+ if (!db) return
+
+ try {
+ // 1. Register menu_item document type
+ const typeRegistry = new DocumentTypeRegistry(db)
+ await typeRegistry.register({
+ id: 'menu_item',
+ name: 'menu_item',
+ displayName: 'Menu Item',
+ description: 'Admin sidebar navigation item',
+ schema: z.object({}),
+ pluginId: 'menu',
+ source: 'plugin',
+ queryableFields: [
+ { name: 'parent', path: '$.parent', kind: 'scalar', type: 'text', column: 'q_menu_parent' },
+ { name: 'visible', path: '$.visible', kind: 'scalar', type: 'boolean', column: 'q_menu_visible' },
+ { name: 'source', path: '$.source', kind: 'scalar', type: 'text', column: 'q_menu_source' },
+ { name: 'pluginId', path: '$.pluginId', kind: 'scalar', type: 'text', column: 'q_menu_plugin_id' },
+ ],
+ settings: {
+ baseGrants: { admin: ['read', 'create', 'update', 'delete', 'manage'] },
+ internal: true,
+ },
+ })
+
+ // 2. Seed system menu items (insert-only, idempotent)
+ for (const item of SYSTEM_MENU_ITEMS) {
+ await upsertSystemItem(db, item.id, {
+ label: item.label,
+ url: item.url,
+ icon: item.icon,
+ target: item.target,
+ isExternal: item.isExternal,
+ visible: item.visible,
+ parent: item.parent,
+ source: item.source,
+ pluginId: item.pluginId,
+ permissions: [...item.permissions],
+ lockedFields: [...item.lockedFields],
+ }, item.sortOrder)
+ }
+
+ // 3. Reconcile plugin-contributed menu items
+ await reconcileMenuFromPlugins(db)
+ } catch {
+ // DB might not be ready (pre-migration) — skip silently
+ }
+ },
+
+ settingsTabContent: {
+ async loadData(db: any) {
+ const items = await listMenuItems(db)
+ const pluginIds = [...new Set(items.filter(i => i.pluginId).map(i => i.pluginId as string))]
+ const pluginStatuses = await fetchPluginStatuses(db, pluginIds)
+ return { items, pluginStatuses }
+ },
+ render({ data }) {
+ const items = data?.items ?? []
+ const pluginStatuses = data?.pluginStatuses ?? {}
+ return renderMenuSettingsContent(items, pluginStatuses)
+ },
+ },
+})
+
+export function createMenuPlugin() {
+ return menuPlugin
+}
diff --git a/packages/core/src/plugins/core-plugins/menu-plugin/manifest.json b/packages/core/src/plugins/core-plugins/menu-plugin/manifest.json
new file mode 100644
index 000000000..cb066f3b7
--- /dev/null
+++ b/packages/core/src/plugins/core-plugins/menu-plugin/manifest.json
@@ -0,0 +1,17 @@
+{
+ "id": "menu",
+ "name": "Menu Manager",
+ "version": "1.0.0",
+ "description": "Manages the admin sidebar navigation. Admins can reorder, rename, hide built-in items, and add custom links.",
+ "author": "SonicJS Team",
+ "license": "MIT",
+ "category": "admin",
+ "tags": ["menu", "navigation", "admin", "sidebar"],
+ "dependencies": [],
+ "routes": [],
+ "permissions": {},
+ "adminMenu": null,
+ "iconEmoji": "📋",
+ "is_core": true,
+ "defaultSettings": {}
+}
diff --git a/packages/core/src/plugins/core-plugins/menu-plugin/routes/admin-menu.ts b/packages/core/src/plugins/core-plugins/menu-plugin/routes/admin-menu.ts
new file mode 100644
index 000000000..18a8f6b2c
--- /dev/null
+++ b/packages/core/src/plugins/core-plugins/menu-plugin/routes/admin-menu.ts
@@ -0,0 +1,245 @@
+import { Hono } from 'hono'
+import type { Bindings, Variables } from '../../../../app'
+import { requireAuth, requireRole } from '../../../../middleware'
+import { escapeHtml } from '../../../../utils/sanitize'
+import {
+ listMenuItems,
+ buildSidebarTree,
+ updateItem,
+ deleteItem,
+ toggleVisibility,
+ reorderItems,
+ fetchPluginStatuses,
+} from '../services/menu-repository'
+import { nanoid } from 'nanoid'
+import { D1Database } from '@cloudflare/workers-types'
+
+function sanitizeUrl(url: string): string {
+ const trimmed = url.trim()
+ // Reject javascript: and data: schemes — XSS vectors via href injection
+ if (/^javascript:/i.test(trimmed) || /^data:/i.test(trimmed)) return ''
+ return trimmed
+}
+
+// Template imports (will be created):
+import { renderMenuListPage } from '../templates/admin-menu-list.template'
+import { renderMenuFormPage } from '../templates/admin-menu-form.template'
+
+export const adminMenuRoutes = new Hono<{ Bindings: Bindings; Variables: Variables }>()
+
+adminMenuRoutes.use('*', requireAuth())
+adminMenuRoutes.use('*', requireRole(['admin']))
+
+adminMenuRoutes.get('/', async (c) => {
+ const db = c.env.DB
+ const user = c.get('user')
+ const items = await listMenuItems(db)
+ const tree = buildSidebarTree(items)
+ const pluginIds = [...new Set(items.filter(i => i.pluginId).map(i => i.pluginId as string))]
+ const pluginStatuses = await fetchPluginStatuses(db, pluginIds)
+ return c.html(renderMenuListPage({
+ items,
+ tree,
+ pluginStatuses,
+ user,
+ currentPath: '/admin/menu',
+ version: c.get('appVersion'),
+ dynamicMenuItems: c.get('pluginMenuItems'),
+ message: c.req.query('message'),
+ }))
+})
+
+adminMenuRoutes.get('/new', async (c) => {
+ const db = c.env.DB
+ const topLevelItems = await listMenuItems(db)
+ return c.html(renderMenuFormPage({
+ item: null,
+ topLevelItems: topLevelItems.filter(i => !i.parent),
+ user: c.get('user'),
+ currentPath: '/admin/menu',
+ version: c.get('appVersion'),
+ dynamicMenuItems: c.get('pluginMenuItems'),
+ }))
+})
+
+adminMenuRoutes.get('/:id', async (c) => {
+ const db = c.env.DB
+ const id = c.req.param('id')
+ const items = await listMenuItems(db)
+ const item = items.find(i => i.id === id)
+ if (!item) return c.notFound()
+ const topLevelItems = items.filter(i => !i.parent && i.id !== id)
+ return c.html(renderMenuFormPage({
+ item,
+ topLevelItems,
+ user: c.get('user'),
+ currentPath: '/admin/menu',
+ version: c.get('appVersion'),
+ dynamicMenuItems: c.get('pluginMenuItems'),
+ }))
+})
+
+adminMenuRoutes.post('/', async (c) => {
+ const db = c.env.DB
+ const form = await c.req.formData()
+ const label = String(form.get('label') ?? '').trim()
+ const url = String(form.get('url') ?? '').trim()
+ const icon = String(form.get('icon') ?? 'link').trim()
+ const target = form.get('target') === '_blank' ? '_blank' : '_self'
+ const parent = String(form.get('parent') ?? '').trim() || null
+ const visible = form.has('visible')
+
+ if (!label) {
+ return c.html(renderMenuFormPage({
+ item: null,
+ topLevelItems: [],
+ user: c.get('user'),
+ currentPath: '/admin/menu',
+ version: c.get('appVersion'),
+ dynamicMenuItems: c.get('pluginMenuItems'),
+ error: 'Label is required',
+ }), 400)
+ }
+
+ const now = Math.floor(Date.now() / 1000)
+ const id = nanoid()
+ const slug = `menu:user:${id}`
+ const safeUrl = sanitizeUrl(url)
+ const isExternal = /^https?:\/\//.test(safeUrl)
+ const data = JSON.stringify({
+ label,
+ url: safeUrl,
+ icon,
+ target,
+ isExternal,
+ visible,
+ parent,
+ source: 'user',
+ pluginId: null,
+ permissions: [],
+ lockedFields: [],
+ })
+
+ const items = await listMenuItems(db)
+ const maxSort = items.reduce((m, i) => Math.max(m, i.sortOrder), 0)
+ const sortOrder = maxSort + 10
+
+ await db.prepare(
+ `INSERT INTO documents (id, root_id, type_id, type_version, version_of_id, version_number,
+ is_current_draft, is_published, status, parent_root_id, slug, path, title, zone,
+ sort_order, visible, published_at, scheduled_at, expires_at, deleted_at,
+ tenant_id, locale, translation_group_id, data, metadata,
+ owner_id, created_by, updated_by, created_at, updated_at)
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`
+ ).bind(
+ id, id, 'menu_item', 1, null, 1,
+ 1, 1, 'published', '', slug, null, label, null,
+ sortOrder, visible ? 1 : 0, now, null, null, null,
+ 'default', 'default', '', data, '{}',
+ null, c.get('user')?.userId ?? 'system', c.get('user')?.userId ?? 'system', now, now
+ ).run()
+
+ return c.redirect('/admin/menu?message=Item+created')
+})
+
+adminMenuRoutes.put('/reorder', async (c) => {
+ const db = c.env.DB
+ const body = await c.req.json>()
+ if (!Array.isArray(body)) return c.json({ error: 'Invalid body' }, 400)
+ await reorderItems(db, body)
+ return c.json({ ok: true })
+})
+
+adminMenuRoutes.post('/:id/visibility', async (c) => {
+ const db = c.env.DB
+ const id = c.req.param('id')
+ const form = await c.req.formData()
+ const visible = form.get('visible') === 'true'
+ await toggleVisibility(db, id, visible)
+ return c.redirect('/admin/menu?message=Visibility+updated')
+})
+
+async function handleMenuItemUpdate(c: any) {
+ const db = c.env.DB
+ const id = c.req.param('id')
+ const form = await c.req.formData()
+
+ const items = await listMenuItems(db)
+ const item = items.find((i: any) => i.id === id)
+ if (!item) return c.json({ error: 'Not found' }, 404)
+
+ const locked = new Set(item.lockedFields)
+ const changes: Record = {}
+ if (form.has('label') && !locked.has('label')) changes.label = String(form.get('label')).trim()
+ if (form.has('icon') && !locked.has('icon')) changes.icon = String(form.get('icon')).trim()
+ if (form.has('target') && !locked.has('target')) changes.target = form.get('target') === '_blank' ? '_blank' : '_self'
+ if (form.has('url') && !locked.has('url')) changes.url = sanitizeUrl(String(form.get('url')))
+ if (form.has('parent')) changes.parent = String(form.get('parent')).trim() || null
+ // Checkbox: present = true, absent = false
+ changes.visible = form.has('visible')
+
+ const ok = await updateItem(db, id, changes, item.lockedFields)
+ if (!ok) return c.json({ error: 'Cannot modify locked fields' }, 403)
+
+ return c.redirect('/admin/menu?message=Item+updated')
+}
+
+adminMenuRoutes.put('/:id', handleMenuItemUpdate)
+
+// HTML form fallback (browsers can't PUT)
+adminMenuRoutes.post('/:id/update', handleMenuItemUpdate)
+
+adminMenuRoutes.delete('/:id', async (c) => {
+ const db = c.env.DB
+ const id = c.req.param('id')
+ const result = await deleteItem(db, id)
+ if (!result.ok) return c.json({ error: result.reason }, 403)
+ return c.redirect('/admin/menu?message=Item+deleted')
+})
+
+// HTML form fallback: browsers can't send DELETE from a form
+adminMenuRoutes.post('/:id/delete', async (c) => {
+ const db = c.env.DB
+ const id = c.req.param('id')
+ const result = await deleteItem(db, id)
+ if (!result.ok) return c.json({ error: result.reason }, 403)
+ return c.redirect('/admin/menu?message=Item+deleted')
+})
+
+adminMenuRoutes.post('/:id/move-up', async (c) => {
+ const db = c.env.DB
+ const id = c.req.param('id')
+ const allItems = await listMenuItems(db)
+ const cur = allItems.find((i) => i.id === id)
+ if (!cur) return c.redirect('/admin/menu')
+ const siblings = allItems.filter((i) => i.parent === cur.parent)
+ const idx = siblings.findIndex((i) => i.id === id)
+ const prev = siblings[idx - 1]
+ if (idx > 0 && prev) {
+ await reorderItems(db, [
+ { id: cur.id, sortOrder: prev.sortOrder, parent: cur.parent },
+ { id: prev.id, sortOrder: cur.sortOrder, parent: prev.parent },
+ ])
+ return c.redirect('/admin/menu?message=Reordered')
+ }
+ return c.redirect('/admin/menu')
+})
+
+adminMenuRoutes.post('/:id/move-down', async (c) => {
+ const db = c.env.DB
+ const id = c.req.param('id')
+ const allItems = await listMenuItems(db)
+ const cur = allItems.find((i) => i.id === id)
+ if (!cur) return c.redirect('/admin/menu')
+ const siblings = allItems.filter((i) => i.parent === cur.parent)
+ const idx = siblings.findIndex((i) => i.id === id)
+ const next = siblings[idx + 1]
+ if (idx !== -1 && idx < siblings.length - 1 && next) {
+ await reorderItems(db, [
+ { id: cur.id, sortOrder: next.sortOrder, parent: cur.parent },
+ { id: next.id, sortOrder: cur.sortOrder, parent: next.parent },
+ ])
+ return c.redirect('/admin/menu?message=Reordered')
+ }
+ return c.redirect('/admin/menu')
+})
diff --git a/packages/core/src/plugins/core-plugins/menu-plugin/services/menu-defaults.ts b/packages/core/src/plugins/core-plugins/menu-plugin/services/menu-defaults.ts
new file mode 100644
index 000000000..9ae65a59d
--- /dev/null
+++ b/packages/core/src/plugins/core-plugins/menu-plugin/services/menu-defaults.ts
@@ -0,0 +1,108 @@
+export interface SystemMenuItem {
+ id: string
+ label: string
+ url: string
+ icon: string
+ target: '_self'
+ isExternal: false
+ visible: true
+ parent: null
+ source: 'system'
+ pluginId: null
+ permissions: []
+ lockedFields: ['url']
+ sortOrder: number
+}
+
+export const SYSTEM_MENU_ITEMS: readonly SystemMenuItem[] = [
+ {
+ id: 'menu:system:content',
+ label: 'Content',
+ url: '/admin/content',
+ icon: 'document',
+ target: '_self',
+ isExternal: false,
+ visible: true,
+ parent: null,
+ source: 'system',
+ pluginId: null,
+ permissions: [],
+ lockedFields: ['url'],
+ sortOrder: 10,
+ },
+ {
+ id: 'menu:system:collections',
+ label: 'Collections',
+ url: '/admin/collections',
+ icon: 'collection',
+ target: '_self',
+ isExternal: false,
+ visible: true,
+ parent: null,
+ source: 'system',
+ pluginId: null,
+ permissions: [],
+ lockedFields: ['url'],
+ sortOrder: 20,
+ },
+ {
+ id: 'menu:system:users',
+ label: 'Users',
+ url: '/admin/users',
+ icon: 'users',
+ target: '_self',
+ isExternal: false,
+ visible: true,
+ parent: null,
+ source: 'system',
+ pluginId: null,
+ permissions: [],
+ lockedFields: ['url'],
+ sortOrder: 30,
+ },
+ {
+ id: 'menu:system:settings',
+ label: 'Settings',
+ url: '/admin/settings',
+ icon: 'cog',
+ target: '_self',
+ isExternal: false,
+ visible: true,
+ parent: null,
+ source: 'system',
+ pluginId: null,
+ permissions: [],
+ lockedFields: ['url'],
+ sortOrder: 40,
+ },
+ {
+ id: 'menu:system:plugins',
+ label: 'Plugins',
+ url: '/admin/plugins',
+ icon: 'collection',
+ target: '_self',
+ isExternal: false,
+ visible: true,
+ parent: null,
+ source: 'system',
+ pluginId: null,
+ permissions: [],
+ lockedFields: ['url'],
+ sortOrder: 50,
+ },
+ {
+ id: 'menu:system:menu',
+ label: 'Menu',
+ url: '/admin/menu',
+ icon: 'bars-3',
+ target: '_self',
+ isExternal: false,
+ visible: true,
+ parent: null,
+ source: 'system',
+ pluginId: null,
+ permissions: [],
+ lockedFields: ['url'],
+ sortOrder: 60,
+ },
+] as const
diff --git a/packages/core/src/plugins/core-plugins/menu-plugin/services/menu-reconcile.ts b/packages/core/src/plugins/core-plugins/menu-plugin/services/menu-reconcile.ts
new file mode 100644
index 000000000..b33539140
--- /dev/null
+++ b/packages/core/src/plugins/core-plugins/menu-plugin/services/menu-reconcile.ts
@@ -0,0 +1,198 @@
+import { D1Database } from '@cloudflare/workers-types'
+import { nanoid } from 'nanoid'
+import { getPluginMenu, type PluginMenuEntry } from '../../../../services/plugin-menu-singleton'
+import { PLUGIN_REGISTRY, type PluginRegistryEntry } from '../../../../plugins/manifest-registry'
+
+interface ActivePluginEntry {
+ pluginId: string
+ label: string
+ url: string
+ icon: string
+ sortOrder: number
+}
+
+async function upsertPluginRow(
+ db: D1Database,
+ slug: string,
+ entry: ActivePluginEntry,
+ visible: boolean,
+): Promise {
+ const now = Math.floor(Date.now() / 1000)
+
+ const existing = await db
+ .prepare(
+ `SELECT id FROM documents
+ WHERE slug = ? AND type_id = 'menu_item' AND tenant_id = 'default'
+ AND is_current_draft = 1 AND deleted_at IS NULL`,
+ )
+ .bind(slug)
+ .first<{ id: string }>()
+
+ if (existing) {
+ await db
+ .prepare(
+ `UPDATE documents
+ SET data = json_set(data, '$.url', ?, '$.pluginId', ?, '$.visible', ?),
+ visible = ?,
+ updated_at = ?
+ WHERE id = ? AND tenant_id = 'default'`,
+ )
+ .bind(entry.url, entry.pluginId, visible ? 1 : 0, visible ? 1 : 0, now, existing.id)
+ .run()
+ return
+ }
+
+ const id = nanoid()
+ const data = JSON.stringify({
+ label: entry.label,
+ url: entry.url,
+ icon: entry.icon,
+ target: '_self',
+ isExternal: false,
+ visible,
+ parent: null,
+ source: 'plugin',
+ pluginId: entry.pluginId,
+ permissions: [],
+ lockedFields: ['url'],
+ sortOrder: entry.sortOrder,
+ })
+
+ // 30 columns — matches documents INSERT in DocumentsService.create (R5)
+ await db
+ .prepare(
+ `INSERT INTO documents (id, root_id, type_id, type_version, version_of_id, version_number,
+ is_current_draft, is_published, status, parent_root_id, slug, path, title, zone,
+ sort_order, visible, published_at, scheduled_at, expires_at, deleted_at,
+ tenant_id, locale, translation_group_id, data, metadata,
+ owner_id, created_by, updated_by, created_at, updated_at)
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
+ )
+ .bind(
+ id, id, 'menu_item', 1, null, 1,
+ 1, 0, 'draft', '', slug, null, entry.label, null,
+ entry.sortOrder, visible ? 1 : 0, null, null, null, null,
+ 'default', 'default', '', data, '{}',
+ null, null, null, now, now,
+ )
+ .run()
+}
+
+async function deactivateStalePluginRows(
+ db: D1Database,
+ activePluginIds: Set,
+): Promise {
+ const now = Math.floor(Date.now() / 1000)
+
+ const rows = await db
+ .prepare(
+ `SELECT id, data FROM documents
+ WHERE type_id = 'menu_item' AND tenant_id = 'default'
+ AND is_current_draft = 1 AND deleted_at IS NULL
+ AND json_extract(data, '$.source') = 'plugin'`,
+ )
+ .all<{ id: string; data: string }>()
+
+ const stale = (rows.results ?? []).filter((row) => {
+ let pluginId: string | undefined
+ try {
+ pluginId = JSON.parse(row.data)?.pluginId as string | undefined
+ } catch {
+ return false
+ }
+ return pluginId !== undefined && !activePluginIds.has(pluginId)
+ })
+
+ if (stale.length === 0) return
+
+ await db.batch(
+ stale.map((row) =>
+ db
+ .prepare(
+ `UPDATE documents
+ SET data = json_set(data, '$.visible', 0), visible = 0, updated_at = ?
+ WHERE id = ? AND tenant_id = 'default'`,
+ )
+ .bind(now, row.id),
+ ),
+ )
+}
+
+async function fetchActivePluginIds(db: D1Database): Promise> {
+ try {
+ const rows = await db
+ .prepare(
+ `SELECT slug FROM documents
+ WHERE type_id = 'plugin' AND tenant_id = 'default'
+ AND is_current_draft = 1 AND deleted_at IS NULL
+ AND json_extract(data, '$.status') = 'active'`,
+ )
+ .all<{ slug: string }>()
+ return new Set((rows.results ?? []).map((r) => r.slug))
+ } catch {
+ return new Set()
+ }
+}
+
+export async function reconcileMenuFromPlugins(db: D1Database): Promise {
+ try {
+ // Source A: code-declared plugins via definePlugin({ menu: [...] })
+ const singletonEntries = getPluginMenu()
+
+ // Source B: manifest-registered plugins with adminMenu
+ const manifestEntries = (Object.values(PLUGIN_REGISTRY) as PluginRegistryEntry[]).filter(
+ (p): p is PluginRegistryEntry & { adminMenu: NonNullable } =>
+ p.adminMenu !== null,
+ )
+
+ // Merge into a map keyed by pluginId; manifest entries provide the base,
+ // singleton entries (code-declared) win if both define the same pluginId.
+ const byPluginId = new Map()
+
+ // Index manifest entries first (lower priority)
+ manifestEntries.forEach((p: PluginRegistryEntry, idx: number) => {
+ const menu = p.adminMenu!
+ byPluginId.set(p.id, {
+ pluginId: p.id,
+ label: menu.label,
+ url: menu.path,
+ icon: menu.icon,
+ sortOrder: 100 + idx * 10,
+ })
+ })
+
+ // Singleton entries override (higher priority — code-declared wins)
+ singletonEntries.forEach((entry: PluginMenuEntry, idx: number) => {
+ // Derive pluginId from path: last path segment after /admin/plugins/
+ // or fall back to a slugified label. The slug uniqueness is enforced by
+ // the caller using `menu:plugin:` so we do the same derivation.
+ const pathParts = entry.path.replace(/\/$/, '').split('/')
+ const pluginId =
+ pathParts[pathParts.length - 1] ||
+ entry.label.toLowerCase().replace(/\s+/g, '-')
+
+ byPluginId.set(pluginId, {
+ pluginId,
+ label: entry.label,
+ url: entry.path,
+ icon: entry.icon ?? 'puzzle-piece',
+ sortOrder: 100 + idx * 10,
+ })
+ })
+
+ // Only plugins explicitly active in DB get visible=true on first insert.
+ // Plugins not yet in DB (never installed) default to visible=false.
+ const activeIds = await fetchActivePluginIds(db)
+
+ // Upsert all registry plugin entries; visible depends on active status
+ for (const [pluginId, entry] of byPluginId) {
+ const slug = `menu:plugin:${pluginId}`
+ await upsertPluginRow(db, slug, entry, activeIds.has(pluginId))
+ }
+
+ // Deactivate rows whose plugin is inactive or removed from registry
+ await deactivateStalePluginRows(db, activeIds)
+ } catch {
+ // DB may not be ready at bootstrap; swallow silently
+ }
+}
diff --git a/packages/core/src/plugins/core-plugins/menu-plugin/services/menu-repository.ts b/packages/core/src/plugins/core-plugins/menu-plugin/services/menu-repository.ts
new file mode 100644
index 000000000..b6bf57c72
--- /dev/null
+++ b/packages/core/src/plugins/core-plugins/menu-plugin/services/menu-repository.ts
@@ -0,0 +1,467 @@
+import { D1Database } from '@cloudflare/workers-types'
+import { nanoid } from 'nanoid'
+
+export interface MenuItemData {
+ label: string
+ url: string
+ icon: string
+ target: '_self' | '_blank'
+ isExternal: boolean
+ visible: boolean
+ parent: string | null
+ source: 'system' | 'plugin' | 'user'
+ pluginId: string | null
+ permissions: string[]
+ lockedFields: string[]
+}
+
+export interface MenuItem extends MenuItemData {
+ id: string
+ slug: string
+ sortOrder: number
+ tenantId: string
+}
+
+export interface SidebarItem {
+ id: string
+ label: string
+ url: string
+ icon: string
+ target: '_self' | '_blank'
+ isExternal: boolean
+ visible: boolean
+ source: string
+ sortOrder: number
+ children: SidebarItem[]
+}
+
+export async function listMenuItems(
+ db: D1Database,
+ opts?: { source?: string }
+): Promise