diff --git a/apps/web/src/components/admin/global-roles/RoleFormDialog.tsx b/apps/web/src/components/admin/global-roles/RoleFormDialog.tsx index 6b9623e0..e19ba1c7 100644 --- a/apps/web/src/components/admin/global-roles/RoleFormDialog.tsx +++ b/apps/web/src/components/admin/global-roles/RoleFormDialog.tsx @@ -1,6 +1,6 @@ -import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Shield } from "lucide-react"; -import { useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; @@ -28,8 +28,24 @@ import { expandWildcardPermissions, normalizePermissionsToWildcards, } from "@/lib/permissions"; +import { + collectPluginCustomPermissions, + type Plugin, + pluginsQueryOptions, +} from "@/lib/plugin-api"; + +import { + KNOWN_PERMISSIONS, + type KnownPermission, + PERMISSION_GROUPS, + toPluginKnownPermissions, +} from "./permissions"; -import { KNOWN_PERMISSIONS, PERMISSION_GROUPS } from "./permissions"; +// Stable reference so `allKnownPermissions` doesn't change identity on every +// render while the plugins query has no data yet (pending/error) — an +// inline `= []` default creates a new array each render, which re-triggers +// the effect below in an infinite loop. +const EMPTY_PLUGINS: Plugin[] = []; interface RoleFormDialogProps { role?: GlobalRole; @@ -46,17 +62,38 @@ export function RoleFormDialog({ const queryClient = useQueryClient(); const isEdit = !!role; - const [name, setName] = useState(role?.name ?? ""); - const [permissions, setPermissions] = useState>( - expandWildcardPermissions(role?.permissions, KNOWN_PERMISSIONS), + const { data: plugins = EMPTY_PLUGINS } = useQuery(pluginsQueryOptions); + const allKnownPermissions = useMemo( + () => [ + ...KNOWN_PERMISSIONS, + ...toPluginKnownPermissions( + collectPluginCustomPermissions(plugins, "global"), + ), + ], + [plugins], ); + + const [name, setName] = useState(role?.name ?? ""); + const [permissions, setPermissions] = useState>({}); const [error, setError] = useState(null); const [nameError, setNameError] = useState(null); + // Re-derive `permissions` whenever the dialog opens or the known-permission + // set changes (e.g. plugin data finishes loading after the dialog already + // opened), rather than only at first mount — otherwise plugin-declared + // permissions loaded after mount would never make it into the editor and + // saving the role would silently drop them. + useEffect(() => { + if (!open) return; + setPermissions( + expandWildcardPermissions(role?.permissions, allKnownPermissions), + ); + }, [open, allKnownPermissions, role?.permissions]); + const reset = () => { setName(role?.name ?? ""); setPermissions( - expandWildcardPermissions(role?.permissions, KNOWN_PERMISSIONS), + expandWildcardPermissions(role?.permissions, allKnownPermissions), ); setError(null); setNameError(null); @@ -70,7 +107,7 @@ export function RoleFormDialog({ name: name.trim(), permissions: normalizePermissionsToWildcards( permissions, - KNOWN_PERMISSIONS, + allKnownPermissions, ), }; if (isEdit && role) { @@ -113,6 +150,12 @@ export function RoleFormDialog({ }, }); + const permissionLabel = (permission: KnownPermission): string => + permission.rawLabel ?? t(permission.labelKey as never); + + const permissionDescription = (permission: KnownPermission): string => + permission.rawDescription ?? t(permission.descriptionKey as never); + const togglePermission = (key: string, checked: boolean) => { setPermissions((prev) => ({ ...prev, [key]: checked })); }; @@ -188,9 +231,10 @@ export function RoleFormDialog({
{PERMISSION_GROUPS.map((group, groupIndex) => { - const groupPerms = KNOWN_PERMISSIONS.filter( + const groupPerms = allKnownPermissions.filter( (permission) => permission.domain === group.domain, ); + if (groupPerms.length === 0) return null; const { Icon } = group; return (
@@ -210,10 +254,10 @@ export function RoleFormDialog({
- {t(permission.labelKey)} + {permissionLabel(permission)} - {t(permission.descriptionKey)} + {permissionDescription(permission)}
; label: string; badge?: string; + /** Match only the exact path, not any sub-route below it. Use this when + * a deeper route (e.g. a plugin page under this segment) has its own + * distinct nav item and shouldn't also light up this one. */ + exact?: boolean; }) { const location = useRouterState({ select: (s) => s.location.pathname }); - const isActive = location === to || location.startsWith(`${to}/`); + const isActive = exact + ? location === to + : location === to || location.startsWith(`${to}/`); return ( @@ -979,6 +989,82 @@ function ProjectNavItems({ ); } +// ── Plugin-contributed pages ──────────────────────────────────────────────── + +/** Sidebar nav items for plugin `project.page` extension points (e.g. a + * project-wide time-tracking view), routed to + * /projects/:projectId/plugins/:pluginId/:slug. */ +function PluginProjectPages({ projectId }: { projectId: string }) { + const { t } = useTranslation("appShell"); + const { getNavItems } = usePluginRegistry(); + const { hasProjectPermission } = useProjectPermissions(projectId); + const location = useRouterState({ select: (s) => s.location.pathname }); + const navItems = getNavItems("project").filter( + (item) => + !item.requiredPermission || hasProjectPermission(item.requiredPermission), + ); + if (navItems.length === 0) return null; + + return ( + + {t("nav.plugins")} + + + {navItems.map((item) => { + const Icon = resolvePluginIcon(item.icon); + const to = `/projects/${projectId}/plugins/${item.pluginId}/${item.slug}`; + const isActive = location === to || location.startsWith(`${to}/`); + return ( + + } + className={cn( + "relative transition-all duration-150", + isActive + ? "bg-primary/10 text-primary font-medium before:absolute before:left-0 before:inset-y-2 before:w-0.75 before:rounded-full before:bg-primary" + : "hover:bg-sidebar-accent/60", + )} + > + + {item.label} + + + ); + })} + + + + ); +} + +/** Admin-sidebar nav items for plugin `admin.page` extension points (e.g. a + * cross-project time-tracking summary), routed to + * /admin/plugins/:pluginId/:slug. Rendered inline in the existing + * "Administration" SidebarMenu, so no extra group wrapper here. `navItems` + * is pre-filtered by the caller (each item's own `requiredPermission`, if + * any) so this stays in sync with the `showAdminSection` computation that + * decides whether the enclosing group renders at all. */ +function PluginAdminPages({ navItems }: { navItems: PluginNavRegistration[] }) { + return ( + <> + {navItems.map((item) => { + const Icon = resolvePluginIcon(item.icon); + const to = `/admin/plugins/${item.pluginId}/${item.slug}`; + return ( + + ); + })} + + ); +} + // ── Project Interactions Section ─────────────────────────────────────────────── function ProjectInteractionsSection({ projectId, @@ -1269,6 +1355,7 @@ export function AppSidebar() { const { resolvedMode } = useThemeMode(); const { projectId } = useParams({ strict: false }); const { data: user } = useQuery(currentUserOptionalQueryOptions); + const { getNavItems } = usePluginRegistry(); const canAccessGlobalRoles = hasPermission("global_roles.read") || hasPermission("global_roles.write"); @@ -1280,8 +1367,21 @@ export function AppSidebar() { const canCreateProject = hasPermission("projects.create"); + // Plugin admin nav items are gated by their own declared + // `requiredPermission` (falling back to open access if the plugin didn't + // declare one), never by `canAccessPlugins` — a user shouldn't need + // `users.write` just to reach a plugin page whose author scoped it to a + // narrower, plugin-specific permission. + const adminPluginNavItems = getNavItems("admin").filter( + (item) => + !item.requiredPermission || hasPermission(item.requiredPermission), + ); + const showAdminSection = - canAccessGlobalRoles || canAccessUsers || canAccessPlugins; + canAccessGlobalRoles || + canAccessUsers || + canAccessPlugins || + adminPluginNavItems.length > 0; const isProjectContext = !!projectId; const isAnonymous = !user; @@ -1345,6 +1445,7 @@ export function AppSidebar() { componentProps={{ projectId }} /> + ) : ( @@ -1389,8 +1490,10 @@ export function AppSidebar() { to="/admin/plugins" icon={Puzzle} label={t("nav.plugins")} + exact /> ) : null} + diff --git a/apps/web/src/components/plugins/PluginPreferencesPanel.tsx b/apps/web/src/components/plugins/PluginPreferencesPanel.tsx index 29423cb3..7252bfbf 100644 --- a/apps/web/src/components/plugins/PluginPreferencesPanel.tsx +++ b/apps/web/src/components/plugins/PluginPreferencesPanel.tsx @@ -17,6 +17,8 @@ const EXTENSION_POINT_LABEL_KEYS = { "task.detail.section": "preferences.extensionPoints.taskDetail", "project.settings.tab": "preferences.extensionPoints.projectSettingsTab", view: "preferences.extensionPoints.customView", + "project.page": "preferences.extensionPoints.projectPage", + "admin.page": "preferences.extensionPoints.adminPage", } as const satisfies Record; const ALL_POINTS: ExtensionPointId[] = [ @@ -25,6 +27,8 @@ const ALL_POINTS: ExtensionPointId[] = [ "task.detail.section", "project.settings.tab", "view", + "project.page", + "admin.page", ]; interface DraggableItemProps { diff --git a/apps/web/src/components/projects/roles/ProjectRoleFormDialog.tsx b/apps/web/src/components/projects/roles/ProjectRoleFormDialog.tsx index 60fcec7d..a1e796f8 100644 --- a/apps/web/src/components/projects/roles/ProjectRoleFormDialog.tsx +++ b/apps/web/src/components/projects/roles/ProjectRoleFormDialog.tsx @@ -1,6 +1,6 @@ -import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Loader2, Shield } from "lucide-react"; -import { useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; @@ -22,6 +22,11 @@ import { expandWildcardPermissions, normalizePermissionsToWildcards, } from "@/lib/permissions"; +import { + collectPluginCustomPermissions, + type Plugin, + pluginsQueryOptions, +} from "@/lib/plugin-api"; import { createProjectRole, type ProjectRole, @@ -30,10 +35,18 @@ import { } from "@/lib/project-api"; import { + type KnownPermission, PROJECT_KNOWN_PERMISSIONS, PROJECT_PERMISSION_GROUPS, + toPluginKnownPermissions, } from "./permissions"; +// Stable reference so `allKnownPermissions` doesn't change identity on every +// render while the plugins query has no data yet (pending/error) — an +// inline `= []` default creates a new array each render, which re-triggers +// the effect below in an infinite loop. +const EMPTY_PLUGINS: Plugin[] = []; + interface ProjectRoleFormDialogProps { projectId: string; role?: ProjectRole; @@ -51,22 +64,43 @@ export function ProjectRoleFormDialog({ const queryClient = useQueryClient(); const isEdit = !!role; - const [name, setName] = useState(role?.role_name ?? ""); - const [permissions, setPermissions] = useState>( - expandWildcardPermissions( - role?.permissions as Record | undefined, - PROJECT_KNOWN_PERMISSIONS, - ), + const { data: plugins = EMPTY_PLUGINS } = useQuery(pluginsQueryOptions); + const allKnownPermissions = useMemo( + () => [ + ...PROJECT_KNOWN_PERMISSIONS, + ...toPluginKnownPermissions( + collectPluginCustomPermissions(plugins, "project"), + ), + ], + [plugins], ); + + const [name, setName] = useState(role?.role_name ?? ""); + const [permissions, setPermissions] = useState>({}); const [error, setError] = useState(null); const [nameError, setNameError] = useState(null); + // Re-derive `permissions` whenever the dialog opens or the known-permission + // set changes (e.g. plugin data finishes loading after the dialog already + // opened), rather than only at first mount — otherwise plugin-declared + // permissions loaded after mount would never make it into the editor and + // saving the role would silently drop them. + useEffect(() => { + if (!open) return; + setPermissions( + expandWildcardPermissions( + role?.permissions as Record | undefined, + allKnownPermissions, + ), + ); + }, [open, allKnownPermissions, role?.permissions]); + const reset = () => { setName(role?.role_name ?? ""); setPermissions( expandWildcardPermissions( role?.permissions as Record | undefined, - PROJECT_KNOWN_PERMISSIONS, + allKnownPermissions, ), ); setError(null); @@ -77,7 +111,7 @@ export function ProjectRoleFormDialog({ mutationFn: async () => { const normalized = normalizePermissionsToWildcards( permissions, - PROJECT_KNOWN_PERMISSIONS, + allKnownPermissions, ); if (isEdit && role) { return updateProjectRole(projectId, role.id, { @@ -125,6 +159,12 @@ export function ProjectRoleFormDialog({ }, }); + const permissionLabel = (permission: KnownPermission): string => + permission.rawLabel ?? t(permission.labelKey as never); + + const permissionDescription = (permission: KnownPermission): string => + permission.rawDescription ?? t(permission.descriptionKey as never); + const togglePermission = (key: string, checked: boolean) => { setPermissions((prev) => ({ ...prev, [key]: checked })); }; @@ -200,9 +240,10 @@ export function ProjectRoleFormDialog({
{PROJECT_PERMISSION_GROUPS.map((group, groupIndex) => { - const groupPerms = PROJECT_KNOWN_PERMISSIONS.filter( + const groupPerms = allKnownPermissions.filter( (p) => p.domain === group.domain, ); + if (groupPerms.length === 0) return null; const { Icon } = group; return (
@@ -220,10 +261,10 @@ export function ProjectRoleFormDialog({
- {t(permission.labelKey)} + {permissionLabel(permission)} - {t(permission.descriptionKey)} + {permissionDescription(permission)}
{ }); }); + it("expandWildcardPermissions matches plugin-declared permissions by their own key prefix, not the synthetic UI domain", () => { + // Plugin-declared permissions all share the UI domain "plugins" (see + // toPluginKnownPermissions), but the wildcard a role actually stores is + // keyed by the permission's own namespace (e.g. "time_logging.*") — + // domain-based matching would look for a non-existent "plugins.*". + const pluginPermissions: PermissionDefinition[] = [ + { key: "time_logging.view_all", domain: "plugins" }, + { key: "time_logging.manage_all", domain: "plugins" }, + { key: "checklist.manage", domain: "plugins" }, + ]; + + expect( + expandWildcardPermissions({ "time_logging.*": true }, pluginPermissions), + ).toEqual({ + "time_logging.view_all": true, + "time_logging.manage_all": true, + "checklist.manage": false, + }); + }); + it("normalizePermissionsToWildcards compacts fully selected domains and preserves partial domains", () => { expect( normalizePermissionsToWildcards( diff --git a/apps/web/src/lib/permissions.ts b/apps/web/src/lib/permissions.ts index fcf15548..5876c63b 100644 --- a/apps/web/src/lib/permissions.ts +++ b/apps/web/src/lib/permissions.ts @@ -28,6 +28,18 @@ export function hasAnyPermission( ); } +/** The part of a permission key before its last dot, e.g. "time_logging" for + * "time_logging.view_all". This is the namespace a `.write`-style wildcard + * grant actually covers — NOT `PermissionDefinition.domain`, which is only a + * UI-grouping label. For built-in permissions the two happen to coincide, + * but plugin-declared permissions all share the synthetic "plugins" domain + * while each has its own real key prefix, so domain-based wildcard checks + * silently fail for them. */ +function keyPrefix(key: string): string { + const lastDotIndex = key.lastIndexOf("."); + return lastDotIndex === -1 ? key : key.slice(0, lastDotIndex); +} + export function expandWildcardPermissions( source: PermissionMap | undefined, knownPermissions: PermissionDefinition[], @@ -38,10 +50,10 @@ export function expandWildcardPermissions( const hasGlobalWildcard = source["*"] === true; for (const permission of knownPermissions) { - const domainWildcard = `${permission.domain}.*`; + const prefixWildcard = `${keyPrefix(permission.key)}.*`; expanded[permission.key] = hasGlobalWildcard || - source[domainWildcard] === true || + source[prefixWildcard] === true || source[permission.key] === true; } @@ -56,22 +68,28 @@ export function normalizePermissionsToWildcards( return { "*": true }; } - const permissionsByDomain = new Map(); + // Group by each permission's own key prefix (see `keyPrefix`), NOT by + // `domain` — collapsing by the UI-grouping label would produce a bogus + // "plugins.*" key that (a) doesn't match any real permission check + // (hasPermission only understands `${realPrefix}.*`) and (b) would + // over-grant every other plugin's permissions sharing that UI group. + const permissionsByPrefix = new Map(); for (const permission of knownPermissions) { - const existing = permissionsByDomain.get(permission.domain) ?? []; + const prefix = keyPrefix(permission.key); + const existing = permissionsByPrefix.get(prefix) ?? []; existing.push(permission); - permissionsByDomain.set(permission.domain, existing); + permissionsByPrefix.set(prefix, existing); } const normalized: PermissionMap = {}; - for (const [domain, domainPermissions] of permissionsByDomain) { - const enabledPermissions = domainPermissions.filter( + for (const [prefix, prefixPermissions] of permissionsByPrefix) { + const enabledPermissions = prefixPermissions.filter( (permission) => source[permission.key] === true, ); if (enabledPermissions.length === 0) continue; - if (enabledPermissions.length === domainPermissions.length) { - normalized[`${domain}.*`] = true; + if (enabledPermissions.length === prefixPermissions.length) { + normalized[`${prefix}.*`] = true; continue; } diff --git a/apps/web/src/lib/plugin-api.ts b/apps/web/src/lib/plugin-api.ts index 826ec1fc..4fb85d35 100644 --- a/apps/web/src/lib/plugin-api.ts +++ b/apps/web/src/lib/plugin-api.ts @@ -21,9 +21,27 @@ export interface ExtensionPointRegistration { order?: number; } +export interface PluginNavItem { + scope: "project" | "admin"; + slug: string; + label: string; + component: string; + icon?: string; + /** + * Permission key (built-in, or one of this plugin's own + * `customPermissions`) required to see and access this nav item's page. + * Checked against the caller's global permissions for `scope: "admin"` + * or their project permissions for `scope: "project"`. If omitted, the + * page is reachable by anyone who can reach the enclosing sidebar + * section (all project members, or all admins). + */ + requiredPermission?: string; +} + export interface FrontendManifest { remoteEntryUrl?: string; extensionPoints?: ExtensionPointRegistration[]; + navItems?: PluginNavItem[]; } export interface PluginManifest { @@ -34,6 +52,23 @@ export interface PluginManifest { backend?: BackendManifest; frontend?: FrontendManifest; permissions?: string[]; + customPermissions?: PluginCustomPermission[]; +} + +/** + * A permission key a plugin declares beyond the host's built-in permission + * set (e.g. "time_logging.manage_all"). The host stores grants for these in + * the same JSONB permission map as built-in permissions, so declaring one + * here only makes it discoverable — it shows up in the project/global role + * editor and can be checked via requirePermissions or the plugin backend's + * CheckPermission host function. + */ +export interface PluginCustomPermission { + key: string; + label: string; + description?: string; + /** Which role editor this permission appears in. Defaults to "project". */ + scope?: "project" | "global"; } export interface Plugin { @@ -148,7 +183,9 @@ export type ExtensionPointId = | "sidebar.project.section" | "task.detail.section" | "project.settings.tab" - | "view"; + | "view" + | "project.page" + | "admin.page"; export interface PluginRegistration { pluginUUID: string; // The database UUID for API calls @@ -207,3 +244,127 @@ export function buildRegistryMap( return map; } + +// ── Nav item helpers ────────────────────────────────────────────────────────── + +export interface PluginNavRegistration { + pluginId: string; // reverse-DNS identifier, e.g. "com.paca.time-logging" + pluginName: string; + scope: "project" | "admin"; + slug: string; + label: string; + icon?: string; + /** See `PluginNavItem.requiredPermission`. */ + requiredPermission?: string; + /** The `project.page` / `admin.page` extension point registration this nav item routes to. */ + registration: PluginRegistration; +} + +/** + * Build the list of sidebar nav items contributed by enabled plugins for the + * given scope ("project" or "admin"). Each nav item must reference a + * component also registered at the matching `project.page`/`admin.page` + * extension point — nav items without a matching registration are skipped. + */ +export function buildNavItems( + plugins: Plugin[], + scope: "project" | "admin", +): PluginNavRegistration[] { + const point: ExtensionPointId = + scope === "project" ? "project.page" : "admin.page"; + const registryMap = buildRegistryMap(plugins); + const pageRegs = registryMap.get(point) ?? []; + + const items: PluginNavRegistration[] = []; + for (const plugin of plugins) { + if (!plugin.enabled) continue; + const navItems = plugin.manifest.frontend?.navItems ?? []; + for (const nav of navItems) { + if (nav.scope !== scope) continue; + const registration = pageRegs.find( + (r) => + r.pluginId === plugin.manifest.id && r.component === nav.component, + ); + if (!registration) continue; + items.push({ + pluginId: plugin.manifest.id, + pluginName: plugin.manifest.displayName, + scope: nav.scope, + slug: nav.slug, + label: nav.label, + icon: nav.icon, + requiredPermission: nav.requiredPermission, + registration, + }); + } + } + return items; +} + +// ── Custom permission helpers ───────────────────────────────────────────────── + +/** + * Collect plugin-declared custom permissions for a given scope ("project" or + * "global") from all enabled plugins. Used to extend the built-in + * PROJECT_KNOWN_PERMISSIONS / KNOWN_PERMISSIONS sets shown in the role + * editors so admins can grant plugin permissions to roles. + */ +export function collectPluginCustomPermissions( + plugins: Plugin[], + scope: "project" | "global", +): Array { + const result: Array< + PluginCustomPermission & { pluginId: string; pluginName: string } + > = []; + for (const plugin of plugins) { + if (!plugin.enabled) continue; + for (const perm of plugin.manifest.customPermissions ?? []) { + if ((perm.scope ?? "project") !== scope) continue; + result.push({ + ...perm, + pluginId: plugin.manifest.id, + pluginName: plugin.manifest.displayName, + }); + } + } + return result; +} + +export interface PluginKnownPermission { + key: string; + labelKey: string; + descriptionKey: string; + domain: string; + /** + * When set, the role editor renders this literal string instead of + * running `labelKey` through i18n. Used for plugin-declared custom + * permissions, whose label text comes from the plugin manifest (a + * plugin has no access to the host's i18n catalog) rather than a + * translation key. + */ + rawLabel?: string; + /** Same as `rawLabel` but for the description text. */ + rawDescription?: string; +} + +/** + * Convert plugin-declared custom permissions (as returned by + * `collectPluginCustomPermissions`) into role-editor-ready entries under the + * synthetic "plugins" domain, shared by both the project and global role + * editors. Label/description come straight from the plugin manifest + * (`rawLabel`/`rawDescription`) since plugins can't contribute host i18n keys. + */ +export function toPluginKnownPermissions( + pluginPermissions: Array, +): PluginKnownPermission[] { + return pluginPermissions.map((perm) => ({ + key: perm.key, + labelKey: "", + descriptionKey: "", + domain: "plugins", + rawLabel: perm.label, + rawDescription: perm.description + ? `${perm.description} (${perm.pluginName})` + : perm.pluginName, + })); +} diff --git a/apps/web/src/lib/plugins/icon-resolver.ts b/apps/web/src/lib/plugins/icon-resolver.ts new file mode 100644 index 00000000..81e9bb93 --- /dev/null +++ b/apps/web/src/lib/plugins/icon-resolver.ts @@ -0,0 +1,18 @@ +import { icons, Puzzle } from "lucide-react"; +import type { ComponentType } from "react"; + +/** + * Resolves a lucide-react icon by its PascalCase export name (as referenced + * in a plugin manifest's `navItems[].icon`, e.g. "Clock", "BarChart3"). + * Falls back to a generic puzzle-piece icon for unknown/omitted names so a + * typo in a plugin manifest never breaks the sidebar. + */ +export function resolvePluginIcon( + name?: string, +): ComponentType<{ className?: string }> { + if (!name) return Puzzle; + return ( + (icons as Record>)[name] ?? + Puzzle + ); +} diff --git a/apps/web/src/lib/plugins/registry.tsx b/apps/web/src/lib/plugins/registry.tsx index 54f4c5d5..77b36958 100644 --- a/apps/web/src/lib/plugins/registry.tsx +++ b/apps/web/src/lib/plugins/registry.tsx @@ -1,8 +1,10 @@ import { useQuery } from "@tanstack/react-query"; import { createContext, type ReactNode, useContext, useMemo } from "react"; import { + buildNavItems, buildRegistryMap, type ExtensionPointId, + type PluginNavRegistration, type PluginRegistration, pluginsQueryOptions, } from "@/lib/plugin-api"; @@ -12,11 +14,14 @@ import { interface PluginRegistryContextValue { /** Ordered registrations for a given extension point. */ getRegistrations: (point: ExtensionPointId) => PluginRegistration[]; + /** Sidebar nav items contributed by enabled plugins for the given scope. */ + getNavItems: (scope: "project" | "admin") => PluginNavRegistration[]; isLoading: boolean; } const PluginRegistryContext = createContext({ getRegistrations: () => [], + getNavItems: () => [], isLoading: false, }); @@ -26,13 +31,23 @@ export function PluginRegistryProvider({ children }: { children: ReactNode }) { const { data: plugins = [], isLoading } = useQuery(pluginsQueryOptions); const registryMap = useMemo(() => buildRegistryMap(plugins), [plugins]); + const projectNavItems = useMemo( + () => buildNavItems(plugins, "project"), + [plugins], + ); + const adminNavItems = useMemo( + () => buildNavItems(plugins, "admin"), + [plugins], + ); const value = useMemo( () => ({ getRegistrations: (point) => registryMap.get(point) ?? [], + getNavItems: (scope) => + scope === "project" ? projectNavItems : adminNavItems, isLoading, }), - [registryMap, isLoading], + [registryMap, projectNavItems, adminNavItems, isLoading], ); return ( diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index a8218614..b0ccf1ae 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -31,7 +31,9 @@ import { Route as AuthenticatedProjectsProjectIdInteractionsBacklogRouteImport } import { Route as AuthenticatedProjectsProjectIdDocsDocIdRouteImport } from './routes/_authenticated/projects/$projectId/docs/$docId' import { Route as AuthenticatedProjectsProjectIdConversationsConversationIdRouteImport } from './routes/_authenticated/projects/$projectId/conversations/$conversationId' import { Route as AuthenticatedProjectsProjectIdAutomationWorkflowIdRouteImport } from './routes/_authenticated/projects/$projectId/automation/$workflowId' +import { Route as AuthenticatedAdminPluginsPluginIdSlugRouteImport } from './routes/_authenticated/admin/plugins/$pluginId/$slug' import { Route as AuthenticatedProjectsProjectIdAgentsAgentIdIndexRouteImport } from './routes/_authenticated/projects/$projectId/agents/$agentId/index' +import { Route as AuthenticatedProjectsProjectIdPluginsPluginIdSlugRouteImport } from './routes/_authenticated/projects/$projectId/plugins/$pluginId/$slug' import { Route as AuthenticatedProjectsProjectIdInteractionsSprintsSprintIdRouteImport } from './routes/_authenticated/projects/$projectId/interactions/sprints/$sprintId' const ChangePasswordRoute = ChangePasswordRouteImport.update({ @@ -161,12 +163,24 @@ const AuthenticatedProjectsProjectIdAutomationWorkflowIdRoute = path: '/automation/$workflowId', getParentRoute: () => AuthenticatedProjectsProjectIdRoute, } as any) +const AuthenticatedAdminPluginsPluginIdSlugRoute = + AuthenticatedAdminPluginsPluginIdSlugRouteImport.update({ + id: '/admin/plugins/$pluginId/$slug', + path: '/admin/plugins/$pluginId/$slug', + getParentRoute: () => AuthenticatedRoute, + } as any) const AuthenticatedProjectsProjectIdAgentsAgentIdIndexRoute = AuthenticatedProjectsProjectIdAgentsAgentIdIndexRouteImport.update({ id: '/agents/$agentId/', path: '/agents/$agentId/', getParentRoute: () => AuthenticatedProjectsProjectIdRoute, } as any) +const AuthenticatedProjectsProjectIdPluginsPluginIdSlugRoute = + AuthenticatedProjectsProjectIdPluginsPluginIdSlugRouteImport.update({ + id: '/plugins/$pluginId/$slug', + path: '/plugins/$pluginId/$slug', + getParentRoute: () => AuthenticatedProjectsProjectIdRoute, + } as any) const AuthenticatedProjectsProjectIdInteractionsSprintsSprintIdRoute = AuthenticatedProjectsProjectIdInteractionsSprintsSprintIdRouteImport.update({ id: '/interactions/sprints/$sprintId', @@ -185,6 +199,7 @@ export interface FileRoutesByFullPath { '/admin/plugins/': typeof AuthenticatedAdminPluginsIndexRoute '/admin/users/': typeof AuthenticatedAdminUsersIndexRoute '/projects/$projectId/': typeof AuthenticatedProjectsProjectIdIndexRoute + '/admin/plugins/$pluginId/$slug': typeof AuthenticatedAdminPluginsPluginIdSlugRoute '/projects/$projectId/automation/$workflowId': typeof AuthenticatedProjectsProjectIdAutomationWorkflowIdRoute '/projects/$projectId/conversations/$conversationId': typeof AuthenticatedProjectsProjectIdConversationsConversationIdRoute '/projects/$projectId/docs/$docId': typeof AuthenticatedProjectsProjectIdDocsDocIdRoute @@ -197,6 +212,7 @@ export interface FileRoutesByFullPath { '/projects/$projectId/settings/': typeof AuthenticatedProjectsProjectIdSettingsIndexRoute '/projects/$projectId/team/': typeof AuthenticatedProjectsProjectIdTeamIndexRoute '/projects/$projectId/interactions/sprints/$sprintId': typeof AuthenticatedProjectsProjectIdInteractionsSprintsSprintIdRoute + '/projects/$projectId/plugins/$pluginId/$slug': typeof AuthenticatedProjectsProjectIdPluginsPluginIdSlugRoute '/projects/$projectId/agents/$agentId/': typeof AuthenticatedProjectsProjectIdAgentsAgentIdIndexRoute } export interface FileRoutesByTo { @@ -209,6 +225,7 @@ export interface FileRoutesByTo { '/admin/plugins': typeof AuthenticatedAdminPluginsIndexRoute '/admin/users': typeof AuthenticatedAdminUsersIndexRoute '/projects/$projectId': typeof AuthenticatedProjectsProjectIdIndexRoute + '/admin/plugins/$pluginId/$slug': typeof AuthenticatedAdminPluginsPluginIdSlugRoute '/projects/$projectId/automation/$workflowId': typeof AuthenticatedProjectsProjectIdAutomationWorkflowIdRoute '/projects/$projectId/conversations/$conversationId': typeof AuthenticatedProjectsProjectIdConversationsConversationIdRoute '/projects/$projectId/docs/$docId': typeof AuthenticatedProjectsProjectIdDocsDocIdRoute @@ -221,6 +238,7 @@ export interface FileRoutesByTo { '/projects/$projectId/settings': typeof AuthenticatedProjectsProjectIdSettingsIndexRoute '/projects/$projectId/team': typeof AuthenticatedProjectsProjectIdTeamIndexRoute '/projects/$projectId/interactions/sprints/$sprintId': typeof AuthenticatedProjectsProjectIdInteractionsSprintsSprintIdRoute + '/projects/$projectId/plugins/$pluginId/$slug': typeof AuthenticatedProjectsProjectIdPluginsPluginIdSlugRoute '/projects/$projectId/agents/$agentId': typeof AuthenticatedProjectsProjectIdAgentsAgentIdIndexRoute } export interface FileRoutesById { @@ -236,6 +254,7 @@ export interface FileRoutesById { '/_authenticated/admin/plugins/': typeof AuthenticatedAdminPluginsIndexRoute '/_authenticated/admin/users/': typeof AuthenticatedAdminUsersIndexRoute '/_authenticated/projects/$projectId/': typeof AuthenticatedProjectsProjectIdIndexRoute + '/_authenticated/admin/plugins/$pluginId/$slug': typeof AuthenticatedAdminPluginsPluginIdSlugRoute '/_authenticated/projects/$projectId/automation/$workflowId': typeof AuthenticatedProjectsProjectIdAutomationWorkflowIdRoute '/_authenticated/projects/$projectId/conversations/$conversationId': typeof AuthenticatedProjectsProjectIdConversationsConversationIdRoute '/_authenticated/projects/$projectId/docs/$docId': typeof AuthenticatedProjectsProjectIdDocsDocIdRoute @@ -248,6 +267,7 @@ export interface FileRoutesById { '/_authenticated/projects/$projectId/settings/': typeof AuthenticatedProjectsProjectIdSettingsIndexRoute '/_authenticated/projects/$projectId/team/': typeof AuthenticatedProjectsProjectIdTeamIndexRoute '/_authenticated/projects/$projectId/interactions/sprints/$sprintId': typeof AuthenticatedProjectsProjectIdInteractionsSprintsSprintIdRoute + '/_authenticated/projects/$projectId/plugins/$pluginId/$slug': typeof AuthenticatedProjectsProjectIdPluginsPluginIdSlugRoute '/_authenticated/projects/$projectId/agents/$agentId/': typeof AuthenticatedProjectsProjectIdAgentsAgentIdIndexRoute } export interface FileRouteTypes { @@ -263,6 +283,7 @@ export interface FileRouteTypes { | '/admin/plugins/' | '/admin/users/' | '/projects/$projectId/' + | '/admin/plugins/$pluginId/$slug' | '/projects/$projectId/automation/$workflowId' | '/projects/$projectId/conversations/$conversationId' | '/projects/$projectId/docs/$docId' @@ -275,6 +296,7 @@ export interface FileRouteTypes { | '/projects/$projectId/settings/' | '/projects/$projectId/team/' | '/projects/$projectId/interactions/sprints/$sprintId' + | '/projects/$projectId/plugins/$pluginId/$slug' | '/projects/$projectId/agents/$agentId/' fileRoutesByTo: FileRoutesByTo to: @@ -287,6 +309,7 @@ export interface FileRouteTypes { | '/admin/plugins' | '/admin/users' | '/projects/$projectId' + | '/admin/plugins/$pluginId/$slug' | '/projects/$projectId/automation/$workflowId' | '/projects/$projectId/conversations/$conversationId' | '/projects/$projectId/docs/$docId' @@ -299,6 +322,7 @@ export interface FileRouteTypes { | '/projects/$projectId/settings' | '/projects/$projectId/team' | '/projects/$projectId/interactions/sprints/$sprintId' + | '/projects/$projectId/plugins/$pluginId/$slug' | '/projects/$projectId/agents/$agentId' id: | '__root__' @@ -313,6 +337,7 @@ export interface FileRouteTypes { | '/_authenticated/admin/plugins/' | '/_authenticated/admin/users/' | '/_authenticated/projects/$projectId/' + | '/_authenticated/admin/plugins/$pluginId/$slug' | '/_authenticated/projects/$projectId/automation/$workflowId' | '/_authenticated/projects/$projectId/conversations/$conversationId' | '/_authenticated/projects/$projectId/docs/$docId' @@ -325,6 +350,7 @@ export interface FileRouteTypes { | '/_authenticated/projects/$projectId/settings/' | '/_authenticated/projects/$projectId/team/' | '/_authenticated/projects/$projectId/interactions/sprints/$sprintId' + | '/_authenticated/projects/$projectId/plugins/$pluginId/$slug' | '/_authenticated/projects/$projectId/agents/$agentId/' fileRoutesById: FileRoutesById } @@ -490,6 +516,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthenticatedProjectsProjectIdAutomationWorkflowIdRouteImport parentRoute: typeof AuthenticatedProjectsProjectIdRoute } + '/_authenticated/admin/plugins/$pluginId/$slug': { + id: '/_authenticated/admin/plugins/$pluginId/$slug' + path: '/admin/plugins/$pluginId/$slug' + fullPath: '/admin/plugins/$pluginId/$slug' + preLoaderRoute: typeof AuthenticatedAdminPluginsPluginIdSlugRouteImport + parentRoute: typeof AuthenticatedRoute + } '/_authenticated/projects/$projectId/agents/$agentId/': { id: '/_authenticated/projects/$projectId/agents/$agentId/' path: '/agents/$agentId' @@ -497,6 +530,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthenticatedProjectsProjectIdAgentsAgentIdIndexRouteImport parentRoute: typeof AuthenticatedProjectsProjectIdRoute } + '/_authenticated/projects/$projectId/plugins/$pluginId/$slug': { + id: '/_authenticated/projects/$projectId/plugins/$pluginId/$slug' + path: '/plugins/$pluginId/$slug' + fullPath: '/projects/$projectId/plugins/$pluginId/$slug' + preLoaderRoute: typeof AuthenticatedProjectsProjectIdPluginsPluginIdSlugRouteImport + parentRoute: typeof AuthenticatedProjectsProjectIdRoute + } '/_authenticated/projects/$projectId/interactions/sprints/$sprintId': { id: '/_authenticated/projects/$projectId/interactions/sprints/$sprintId' path: '/interactions/sprints/$sprintId' @@ -521,6 +561,7 @@ interface AuthenticatedProjectsProjectIdRouteChildren { AuthenticatedProjectsProjectIdSettingsIndexRoute: typeof AuthenticatedProjectsProjectIdSettingsIndexRoute AuthenticatedProjectsProjectIdTeamIndexRoute: typeof AuthenticatedProjectsProjectIdTeamIndexRoute AuthenticatedProjectsProjectIdInteractionsSprintsSprintIdRoute: typeof AuthenticatedProjectsProjectIdInteractionsSprintsSprintIdRoute + AuthenticatedProjectsProjectIdPluginsPluginIdSlugRoute: typeof AuthenticatedProjectsProjectIdPluginsPluginIdSlugRoute AuthenticatedProjectsProjectIdAgentsAgentIdIndexRoute: typeof AuthenticatedProjectsProjectIdAgentsAgentIdIndexRoute } @@ -552,6 +593,8 @@ const AuthenticatedProjectsProjectIdRouteChildren: AuthenticatedProjectsProjectI AuthenticatedProjectsProjectIdTeamIndexRoute, AuthenticatedProjectsProjectIdInteractionsSprintsSprintIdRoute: AuthenticatedProjectsProjectIdInteractionsSprintsSprintIdRoute, + AuthenticatedProjectsProjectIdPluginsPluginIdSlugRoute: + AuthenticatedProjectsProjectIdPluginsPluginIdSlugRoute, AuthenticatedProjectsProjectIdAgentsAgentIdIndexRoute: AuthenticatedProjectsProjectIdAgentsAgentIdIndexRoute, } @@ -569,6 +612,7 @@ interface AuthenticatedRouteChildren { AuthenticatedAdminGlobalRolesIndexRoute: typeof AuthenticatedAdminGlobalRolesIndexRoute AuthenticatedAdminPluginsIndexRoute: typeof AuthenticatedAdminPluginsIndexRoute AuthenticatedAdminUsersIndexRoute: typeof AuthenticatedAdminUsersIndexRoute + AuthenticatedAdminPluginsPluginIdSlugRoute: typeof AuthenticatedAdminPluginsPluginIdSlugRoute } const AuthenticatedRouteChildren: AuthenticatedRouteChildren = { @@ -581,6 +625,8 @@ const AuthenticatedRouteChildren: AuthenticatedRouteChildren = { AuthenticatedAdminGlobalRolesIndexRoute, AuthenticatedAdminPluginsIndexRoute: AuthenticatedAdminPluginsIndexRoute, AuthenticatedAdminUsersIndexRoute: AuthenticatedAdminUsersIndexRoute, + AuthenticatedAdminPluginsPluginIdSlugRoute: + AuthenticatedAdminPluginsPluginIdSlugRoute, } const AuthenticatedRouteWithChildren = AuthenticatedRoute._addFileChildren( diff --git a/apps/web/src/routes/_authenticated/admin/plugins/$pluginId/$slug.tsx b/apps/web/src/routes/_authenticated/admin/plugins/$pluginId/$slug.tsx new file mode 100644 index 00000000..1d237c3a --- /dev/null +++ b/apps/web/src/routes/_authenticated/admin/plugins/$pluginId/$slug.tsx @@ -0,0 +1,80 @@ +import { createFileRoute, notFound, redirect } from "@tanstack/react-router"; +import { AlertCircle } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { myPermissionsQueryOptions } from "@/lib/admin-api"; +import { hasPermission } from "@/lib/permissions"; +import { buildNavItems, pluginsQueryOptions } from "@/lib/plugin-api"; +import { RemoteComponent } from "@/lib/plugins/loader"; +import { usePluginRegistry } from "@/lib/plugins/registry"; + +export const Route = createFileRoute( + "/_authenticated/admin/plugins/$pluginId/$slug", +)({ + beforeLoad: async ({ + context: { queryClient }, + params: { pluginId, slug }, + }) => { + const [permissions, plugins] = await Promise.all([ + queryClient + .fetchQuery(myPermissionsQueryOptions) + .catch(() => [] as string[]), + queryClient.ensureQueryData(pluginsQueryOptions).catch(() => []), + ]); + + const navItem = buildNavItems(plugins, "admin").find( + (item) => item.pluginId === pluginId && item.slug === slug, + ); + // Nav items without a declared `requiredPermission` fall back to + // `users.write`, matching the blanket gate the built-in "Plugins" + // admin nav item (and this route, previously) already use. + const requiredPermission = navItem?.requiredPermission ?? "users.write"; + + if (!hasPermission(permissions, requiredPermission)) { + throw redirect({ to: "/home" }); + } + }, + loader: async ({ context: { queryClient } }) => { + await queryClient.ensureQueryData(pluginsQueryOptions); + }, + component: AdminPluginPage, +}); + +/** + * Full-page route that renders a plugin's `admin.page` extension-point + * component for the given plugin/nav-item slug — the admin/global-scope + * counterpart to `ProjectPluginPage`. Used for cross-project plugin + * dashboards (e.g. a "total logged time across all projects" summary). + */ +function AdminPluginPage() { + const { t } = useTranslation("errors"); + const { pluginId, slug } = Route.useParams(); + const { getNavItems, isLoading } = usePluginRegistry(); + + if (isLoading) return null; + + const navItem = getNavItems("admin").find( + (item) => item.pluginId === pluginId && item.slug === slug, + ); + + if (!navItem) { + throw notFound(); + } + + return ( +
+ + + + {t("pluginLoadFailedPrefix")}{" "} + {navItem.pluginName}{" "} + {t("pluginLoadFailedSuffix")} + +
+ } + /> +
+ ); +} diff --git a/apps/web/src/routes/_authenticated/projects/$projectId/plugins/$pluginId/$slug.tsx b/apps/web/src/routes/_authenticated/projects/$projectId/plugins/$pluginId/$slug.tsx new file mode 100644 index 00000000..c411223c --- /dev/null +++ b/apps/web/src/routes/_authenticated/projects/$projectId/plugins/$pluginId/$slug.tsx @@ -0,0 +1,85 @@ +import { createFileRoute, notFound, redirect } from "@tanstack/react-router"; +import { AlertCircle } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { hasPermission } from "@/lib/permissions"; +import { buildNavItems, pluginsQueryOptions } from "@/lib/plugin-api"; +import { RemoteComponent } from "@/lib/plugins/loader"; +import { usePluginRegistry } from "@/lib/plugins/registry"; +import { myProjectPermissionsQueryOptions } from "@/lib/project-api"; + +export const Route = createFileRoute( + "/_authenticated/projects/$projectId/plugins/$pluginId/$slug", +)({ + beforeLoad: async ({ + context: { queryClient }, + params: { projectId, pluginId, slug }, + }) => { + const plugins = await queryClient + .ensureQueryData(pluginsQueryOptions) + .catch(() => []); + const navItem = buildNavItems(plugins, "project").find( + (item) => item.pluginId === pluginId && item.slug === slug, + ); + // Nav items without a declared `requiredPermission` are reachable by + // any project member, matching the pre-existing behavior of embedded + // `` fragments. + if (!navItem?.requiredPermission) return; + + const permissionsMap = await queryClient + .fetchQuery(myProjectPermissionsQueryOptions(projectId)) + .catch(() => ({}) as Record); + const granted = Object.entries(permissionsMap) + .filter(([, v]) => v === true) + .map(([k]) => k); + + if (!hasPermission(granted, navItem.requiredPermission)) { + throw redirect({ to: "/projects/$projectId", params: { projectId } }); + } + }, + loader: async ({ context: { queryClient } }) => { + await queryClient.ensureQueryData(pluginsQueryOptions); + }, + component: ProjectPluginPage, +}); + +/** + * Full-page route that renders a plugin's `project.page` extension-point + * component for the given plugin/nav-item slug, resolved from the sidebar + * nav items contributed by enabled plugins. This is the routed counterpart + * to `` — instead of embedding a + * fragment inside a host page, the plugin owns the entire route. + */ +function ProjectPluginPage() { + const { t } = useTranslation("errors"); + const { projectId, pluginId, slug } = Route.useParams(); + const { getNavItems, isLoading } = usePluginRegistry(); + + if (isLoading) return null; + + const navItem = getNavItems("project").find( + (item) => item.pluginId === pluginId && item.slug === slug, + ); + + if (!navItem) { + throw notFound(); + } + + return ( +
+ + + + {t("pluginLoadFailedPrefix")}{" "} + {navItem.pluginName}{" "} + {t("pluginLoadFailedSuffix")} + +
+ } + /> +
+ ); +} diff --git a/deploy/docker-compose.dev.yml b/deploy/docker-compose.dev.yml index ea9a52c6..15e002d4 100644 --- a/deploy/docker-compose.dev.yml +++ b/deploy/docker-compose.dev.yml @@ -18,8 +18,6 @@ services: POSTGRES_USER: paca POSTGRES_PASSWORD: paca POSTGRES_DB: paca - ports: - - "5432:5432" volumes: - postgres_data:/var/lib/postgresql/data - ../services/api/migrations:/docker-entrypoint-initdb.d:ro diff --git a/services/api/internal/bootstrap/app.go b/services/api/internal/bootstrap/app.go index 321c6c6e..42b0a80a 100644 --- a/services/api/internal/bootstrap/app.go +++ b/services/api/internal/bootstrap/app.go @@ -224,6 +224,7 @@ func New(cfg *config.Config) (*App, error) { Log: log, Publisher: publisher, HTTPClient: &http.Client{Timeout: 30 * time.Second}, + Authorizer: authorizer, Config: map[string]string{ "ENCRYPTION_KEY": cfg.Security.EncryptionKey, "PUBLIC_URL": cfg.Server.PublicURL, diff --git a/services/api/internal/domain/plugin/entity.go b/services/api/internal/domain/plugin/entity.go index 41817fb4..99d09661 100644 --- a/services/api/internal/domain/plugin/entity.go +++ b/services/api/internal/domain/plugin/entity.go @@ -6,6 +6,8 @@ package plugindom import ( "encoding/json" + "fmt" + "strings" "time" "github.com/google/uuid" @@ -43,6 +45,63 @@ type PluginManifest struct { MCP *MCPManifest `json:"mcp,omitempty"` // Permissions lists the host function scopes the plugin requires. Permissions []string `json:"permissions,omitempty"` + // CustomPermissions lists project/global-scoped permission keys the + // plugin declares. Declared keys become checkable via requirePermissions + // and appear in the project/global role editor UI so admins can grant + // them to specific roles (e.g. "time_logging.manage_all"). + CustomPermissions []CustomPermission `json:"customPermissions,omitempty"` +} + +// CustomPermission describes a permission key a plugin declares beyond the +// host's built-in permission set. The host stores grants for these keys in +// the same JSONB permission map as built-in permissions (e.g. +// project_roles.permissions), so no schema change is needed to persist them +// — only to know they exist so the role editor can expose them and plugin +// route/backend checks can reference them by name. +type CustomPermission struct { + // Key is the permission's stable identifier, e.g. "time_logging.manage_all". + // Must be namespaced under the plugin's domain to avoid collisions with + // built-in permissions and other plugins. + Key string `json:"key"` + // Label is the human-readable name shown in the role editor. + Label string `json:"label"` + // Description explains what the permission grants. + Description string `json:"description,omitempty"` + // Scope determines which role editor the permission appears in: + // "project" (per-project roles) or "global" (global roles). Defaults to + // "project" when omitted. + Scope string `json:"scope,omitempty"` +} + +// pluginKeyNamespace derives the required custom-permission key prefix from a +// plugin's reverse-DNS ID: its last dot-separated segment, snake_cased (e.g. +// "com.paca.time-logging" -> "time_logging"). +func pluginKeyNamespace(pluginID string) string { + parts := strings.Split(pluginID, ".") + last := parts[len(parts)-1] + return strings.ReplaceAll(last, "-", "_") +} + +// Validate checks manifest invariants that can't be expressed in the JSON +// schema alone. In particular, it enforces that every declared custom +// permission key is namespaced under the plugin's own ID, so a plugin cannot +// declare a key (e.g. "users.write") that collides with a built-in +// permission or another plugin's custom permission. +func (m PluginManifest) Validate() error { + namespace := pluginKeyNamespace(m.ID) + prefix := namespace + "." + for _, perm := range m.CustomPermissions { + if perm.Key == "" { + return fmt.Errorf("customPermissions: key is required") + } + if !strings.HasPrefix(perm.Key, prefix) { + return fmt.Errorf("customPermissions: key %q must be namespaced under %q (expected prefix %q)", perm.Key, m.ID, prefix) + } + if perm.Scope != "" && perm.Scope != "project" && perm.Scope != "global" { + return fmt.Errorf("customPermissions: key %q has invalid scope %q", perm.Key, perm.Scope) + } + } + return nil } // MCPManifest describes the MCP (Model Context Protocol) side of the plugin. @@ -80,6 +139,40 @@ type FrontendManifest struct { RemoteEntryURL string `json:"remoteEntryUrl,omitempty"` // ExtensionPoints is the list of extension points the plugin registers into. ExtensionPoints []ExtensionPointRegistration `json:"extensionPoints,omitempty"` + // NavItems is the list of sidebar nav items the plugin registers. Each nav + // item routes to a full-page component registered at the "project.page" or + // "admin.page" extension point (see NavItem.Point). + NavItems []NavItem `json:"navItems,omitempty"` +} + +// NavItem describes a sidebar navigation entry contributed by the plugin. It +// routes to a full-page plugin component instead of an embedded fragment. +type NavItem struct { + // Scope determines which sidebar section the item appears in: + // "project" (per-project sidebar) or "admin" (admin sidebar). + Scope string `json:"scope"` + // Slug is the URL segment identifying this page, unique per plugin+scope, + // e.g. "time-tracking". Combined with the plugin ID to form the route: + // /projects/:projectId/plugins/:pluginId/:slug or + // /admin/plugins/:pluginId/:slug. + Slug string `json:"slug"` + // Label is the human-readable sidebar link text. + Label string `json:"label"` + // Icon is a lucide-react icon name (PascalCase), e.g. "Clock". + Icon string `json:"icon,omitempty"` + // Component is the exported React component name from the remote entry, + // registered at the "project.page" or "admin.page" extension point. + Component string `json:"component"` + // Order is the default display order within the sidebar section. + Order int `json:"order,omitempty"` + // RequiredPermission is the permission key (built-in or a key from this + // plugin's own CustomPermissions) the caller must hold to see and access + // this nav item's page. Checked with the same dot-wildcard semantics as + // requirePermissions, against the caller's global permission map for + // Scope "admin" or their project permission map for Scope "project". If + // omitted, the page is reachable by anyone who can already reach the + // enclosing sidebar section (all project members, or all admins). + RequiredPermission string `json:"requiredPermission,omitempty"` } // PluginRoute defines a single HTTP route exposed by the plugin backend. diff --git a/services/api/internal/platform/plugin/runtime.go b/services/api/internal/platform/plugin/runtime.go index d5c2e9a4..4e0bcb55 100644 --- a/services/api/internal/platform/plugin/runtime.go +++ b/services/api/internal/platform/plugin/runtime.go @@ -16,6 +16,7 @@ import ( plugindom "github.com/Paca-AI/api/internal/domain/plugin" "github.com/Paca-AI/api/internal/events" + "github.com/Paca-AI/api/internal/platform/authz" "github.com/google/uuid" "github.com/tetratelabs/wazero" "github.com/tetratelabs/wazero/api" @@ -68,6 +69,11 @@ type HostServices struct { // AllowedOutboundDomains is the allowlist for paca.http_request outbound // calls. When empty, all outbound HTTP is blocked. AllowedOutboundDomains []string + // Authorizer resolves effective permissions (built-in and plugin-declared + // custom permissions alike, since both are stored in the same permission + // map) for the paca.permission_check host function. May be nil, in which + // case permission_check always returns false. + Authorizer *authz.Authorizer } // EventPublisher abstracts the messaging.Publisher to avoid a circular import. @@ -859,6 +865,7 @@ func WithPluginRequest(ctx context.Context, payload *HTTPRequest) context.Contex type HTTPRequest struct { Method string `json:"method"` Path string `json:"path"` + Query map[string]string `json:"query"` ProjectID string `json:"project_id"` CallerID string `json:"caller_id"` UserID string `json:"user_id"` @@ -916,6 +923,55 @@ func (r *Runtime) registerHTTPFunctions(b wazero.HostModuleBuilder, _ plugindom. WithGoModuleFunction(api.GoModuleFunc(func(_ context.Context, _ api.Module, _ []uint64) { }), []api.ValueType{api.ValueTypeI32, api.ValueTypeI64, api.ValueTypeI64}, nil). Export("http_respond") + + // paca.permission_check(permissionPtr, permissionLen) -> (ok i32) + // + // Checks whether the current caller (from the request context set by + // WithPluginRequest) holds the given permission key, evaluated against the + // same effective permission set as requirePermissions route middleware: + // built-in permissions, LegacyPermissionsForRole, and any plugin-declared + // custom permission granted to the caller's project/global role. Scope + // (project vs global) is inferred from whether the request carries a + // project_id: project-scoped requests check project-role permissions, + // others check global-role permissions only. + // + // This lets plugin backend code enforce finer-grained authorization + // than the single all-or-nothing requirePermissions route gate allows — + // e.g. "is caller the record's author OR does caller hold + // time_logging.manage_all" — without a second host round-trip per check. + b.NewFunctionBuilder(). + WithGoModuleFunction(api.GoModuleFunc(func(ctx context.Context, m api.Module, stack []uint64) { + permission, _ := readString(m, stack[0], stack[1]) + req, _ := ctx.Value(pluginRequestKey{}).(*HTTPRequest) + if req == nil || permission == "" || r.services.Authorizer == nil { + stack[0] = 0 + return + } + + userID, err := uuid.Parse(req.UserID) + if err != nil { + stack[0] = 0 + return + } + + var projectID *uuid.UUID + if req.ProjectID != "" { + pid, err := uuid.Parse(req.ProjectID) + if err != nil { + stack[0] = 0 + return + } + projectID = &pid + } + + granted, err := r.services.Authorizer.HasPermissions(ctx, userID, projectID, req.CallerRole, authz.Permission(permission)) + if err != nil || !granted { + stack[0] = 0 + return + } + stack[0] = 1 + }), []api.ValueType{api.ValueTypeI64, api.ValueTypeI64}, []api.ValueType{api.ValueTypeI32}). + Export("permission_check") } // ------------------------------------------------------------------------- diff --git a/services/api/internal/service/plugin/plugin_service.go b/services/api/internal/service/plugin/plugin_service.go index b7f15e43..f612f54b 100644 --- a/services/api/internal/service/plugin/plugin_service.go +++ b/services/api/internal/service/plugin/plugin_service.go @@ -6,6 +6,7 @@ import ( "fmt" "time" + "github.com/Paca-AI/api/internal/apierr" plugindom "github.com/Paca-AI/api/internal/domain/plugin" "github.com/google/uuid" ) @@ -30,6 +31,9 @@ func (s *Service) InstallPlugin(ctx context.Context, input plugindom.InstallInpu if input.Name == "" { return nil, fmt.Errorf("plugin name is required") } + if err := input.Manifest.Validate(); err != nil { + return nil, apierr.New(apierr.CodeBadRequest, "invalid plugin manifest: "+err.Error()) + } now := time.Now() p := &plugindom.Plugin{ ID: uuid.New(), @@ -56,6 +60,9 @@ func (s *Service) UpdatePlugin(ctx context.Context, id uuid.UUID, input plugindo p.Version = *input.Version } if input.Manifest != nil { + if err := input.Manifest.Validate(); err != nil { + return nil, apierr.New(apierr.CodeBadRequest, "invalid plugin manifest: "+err.Error()) + } p.Manifest = *input.Manifest } if input.Enabled != nil { diff --git a/services/api/internal/transport/http/handler/plugin_handler.go b/services/api/internal/transport/http/handler/plugin_handler.go index 191b5a09..51d88517 100644 --- a/services/api/internal/transport/http/handler/plugin_handler.go +++ b/services/api/internal/transport/http/handler/plugin_handler.go @@ -677,9 +677,20 @@ func (h *PluginHandler) ProxyRequest(w http.ResponseWriter, r *http.Request) { } } + // Build flattened query-param map (first value per key), same convention + // as headers above. + rawQuery := r.URL.Query() + query := make(map[string]string, len(rawQuery)) + for k, vs := range rawQuery { + if len(vs) > 0 { + query[k] = vs[0] + } + } + req := &pluginrt.HTTPRequest{ Method: r.Method, Path: subPath, + Query: query, ProjectID: pathParams[projectParamName], CallerID: callerID, UserID: userIDStr, diff --git a/services/api/test/e2e/plugin_runtime_test.go b/services/api/test/e2e/plugin_runtime_test.go index 5addf92d..13940000 100644 --- a/services/api/test/e2e/plugin_runtime_test.go +++ b/services/api/test/e2e/plugin_runtime_test.go @@ -343,6 +343,36 @@ func TestE2EPluginRuntime_APICall_EchoesRequestBody(t *testing.T) { } } +// TestE2EPluginRuntime_APICall_QueryParamsPropagate is a regression test for +// a bug where the host silently dropped the incoming request's URL query +// string instead of forwarding it to the plugin: PluginHandler.ProxyRequest +// built pluginrt.HTTPRequest without ever reading r.URL.Query(), so +// req.QueryParam(...) inside every plugin always saw an empty map regardless +// of what the caller sent. Query-string-driven filtering/pagination on any +// plugin route was silently a no-op until this was fixed. +func TestE2EPluginRuntime_APICall_QueryParamsPropagate(t *testing.T) { + p := newPluginRuntimeE2EEnv(t, pluginrt.DefaultResourceLimits()) + token := p.issueAdminToken(t) + + p.installAndLoad(t, token, "com.paca.rt-query", []map[string]any{ + publicRoute(http.MethodGet, "/query"), + }, true) + + resp := p.doPlugin(t, http.MethodGet, + "/api/v1/plugins/com.paca.rt-query/query?member_id=abc&date_from=2026-07-01", "", nil) + defer func() { _ = resp.Body.Close() }() + assertStatus(t, resp, http.StatusOK) + + var got map[string]string + decodeJSON(t, resp, &got) + if got["member_id"] != "abc" { + t.Errorf("expected member_id=abc, got %v", got) + } + if got["date_from"] != "2026-07-01" { + t.Errorf("expected date_from=2026-07-01, got %v", got) + } +} + func TestE2EPluginRuntime_APICall_CallerIdentityPropagates(t *testing.T) { p := newPluginRuntimeE2EEnv(t, pluginrt.DefaultResourceLimits()) adminToken := p.issueAdminToken(t) diff --git a/services/api/test/e2e/testdata/echoplugin/main.go b/services/api/test/e2e/testdata/echoplugin/main.go index 5abd3338..86b75f1a 100644 --- a/services/api/test/e2e/testdata/echoplugin/main.go +++ b/services/api/test/e2e/testdata/echoplugin/main.go @@ -7,6 +7,7 @@ // // GET .../hello -> 200 {"message":"hello from plugin"} // GET .../whoami -> 200 {"caller_id":..,"user_id":..,"caller_role":..,"project_id":..} +// GET .../query -> 200, the request's query params echoed back as a JSON object // POST .../echo -> 200, body echoed back unchanged // anything else -> 404 {"error":"not found"} // @@ -70,6 +71,7 @@ func bytesAt(ptr, length uint32) []byte { type hostRequest struct { Method string `json:"method"` Path string `json:"path"` + Query map[string]string `json:"query"` ProjectID string `json:"project_id"` CallerID string `json:"caller_id"` UserID string `json:"user_id"` @@ -132,6 +134,14 @@ func route(req hostRequest) hostResponse { case path == "echo" && req.Method == "POST": return hostResponse{Status: 200, Headers: jsonHeaders(), Body: req.Body} + case path == "query" && req.Method == "GET": + q := req.Query + if q == nil { + q = map[string]string{} + } + body, _ := json.Marshal(q) + return hostResponse{Status: 200, Headers: jsonHeaders(), Body: body} + default: body, _ := json.Marshal(map[string]string{"error": "not found"}) return hostResponse{Status: 404, Headers: jsonHeaders(), Body: body}