diff --git a/exist-core/src/test/resources/standalone-webapp/WEB-INF/controller-config.xml b/exist-core/src/test/resources/standalone-webapp/WEB-INF/controller-config.xml
index 76b81502392..941e0e04f7d 100644
--- a/exist-core/src/test/resources/standalone-webapp/WEB-INF/controller-config.xml
+++ b/exist-core/src/test/resources/standalone-webapp/WEB-INF/controller-config.xml
@@ -26,6 +26,7 @@
+
diff --git a/exist-distribution/pom.xml b/exist-distribution/pom.xml
index 2369cda5832..10ceb64405c 100644
--- a/exist-distribution/pom.xml
+++ b/exist-distribution/pom.xml
@@ -64,6 +64,12 @@
${project.version}
runtime
+
+ ${project.groupId}
+ exist-services
+ ${project.version}
+ runtime
+
${project.groupId}
exist-start
diff --git a/exist-jetty-config/src/main/resources/standalone-webapp/WEB-INF/controller-config.xml b/exist-jetty-config/src/main/resources/standalone-webapp/WEB-INF/controller-config.xml
index cffb8bf1e86..7b4b3f839d4 100644
--- a/exist-jetty-config/src/main/resources/standalone-webapp/WEB-INF/controller-config.xml
+++ b/exist-jetty-config/src/main/resources/standalone-webapp/WEB-INF/controller-config.xml
@@ -18,6 +18,9 @@
+
+
+
diff --git a/exist-jetty-config/src/main/resources/webapp/WEB-INF/controller-config.xml b/exist-jetty-config/src/main/resources/webapp/WEB-INF/controller-config.xml
index 4bb83a9f548..68dbee9557d 100644
--- a/exist-jetty-config/src/main/resources/webapp/WEB-INF/controller-config.xml
+++ b/exist-jetty-config/src/main/resources/webapp/WEB-INF/controller-config.xml
@@ -28,6 +28,9 @@
-->
+
+
+
diff --git a/exist-jetty-config/src/main/resources/webapp/WEB-INF/web.xml b/exist-jetty-config/src/main/resources/webapp/WEB-INF/web.xml
index dc9885872ca..c4cd3606634 100644
--- a/exist-jetty-config/src/main/resources/webapp/WEB-INF/web.xml
+++ b/exist-jetty-config/src/main/resources/webapp/WEB-INF/web.xml
@@ -179,6 +179,24 @@
4
+
+
+ PackageManagementServlet
+ org.exist.http.servlets.PackageManagementServlet
+
+ use-default-user
+ false
+
+
+ 268435456
+ 268435456
+
+ 3
+
+
diff --git a/exist-services/pom.xml b/exist-services/pom.xml
new file mode 100644
index 00000000000..054e1ba66fe
--- /dev/null
+++ b/exist-services/pom.xml
@@ -0,0 +1,38 @@
+
+
+ 4.0.0
+
+
+ org.exist-db
+ exist-parent
+ 7.0.0-SNAPSHOT
+ ../exist-parent
+
+
+ exist-services
+ jar
+
+ eXist-db Platform Services
+
+ Built-in HTTP services for the eXist-db platform: package management REST API
+ and OpenAPI-based routing. These services are compiled into the platform and
+ require no XAR package installation.
+
+
+
+
+
+ org.exist-db
+ exist-core
+ ${project.version}
+
+
+
+
+ org.apache.logging.log4j
+ log4j-api
+
+
+
diff --git a/exist-services/src/main/java/org/exist/http/servlets/PackageManagementServlet.java b/exist-services/src/main/java/org/exist/http/servlets/PackageManagementServlet.java
new file mode 100644
index 00000000000..bcd40f75857
--- /dev/null
+++ b/exist-services/src/main/java/org/exist/http/servlets/PackageManagementServlet.java
@@ -0,0 +1,432 @@
+/*
+ * eXist-db Open Source Native XML Database
+ * Copyright (C) 2001 The eXist-db Authors
+ *
+ * info@exist-db.org
+ * http://www.exist-db.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+package org.exist.http.servlets;
+
+import com.fasterxml.jackson.core.JsonGenerator;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import jakarta.servlet.http.Part;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+import org.exist.EXistException;
+import org.exist.repo.PackageService;
+import org.exist.security.Subject;
+import org.exist.storage.DBBroker;
+import org.exist.storage.txn.Txn;
+import org.expath.pkg.repo.PackageException;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+/**
+ * Built-in REST API for package management.
+ *
+ * This servlet provides package CRUD operations (list, get, install, remove,
+ * update-check) as a built-in Java endpoint that does not depend on any XAR
+ * package. This ensures package management remains available even during
+ * package upgrades, solving the self-upgrade problem that affects XQuery-based
+ * package management implementations.
+ *
+ *
+ * Endpoints:
+ *
+ * - GET /api/packages — list all installed packages
+ * - GET /api/packages/{name} — get single package details
+ * - GET /api/packages/{name}/icon — get package icon
+ * - POST /api/packages/install — install from registry (JSON) or upload (multipart)
+ * - POST /api/packages/update-check — check for available updates
+ * - DELETE /api/packages/{name} — remove package
+ *
+ *
+ */
+public class PackageManagementServlet extends AbstractExistHttpServlet {
+
+ private static final Logger LOG = LogManager.getLogger(PackageManagementServlet.class);
+
+ private final PackageService packageService = new PackageService();
+
+ @Override
+ public Logger getLog() {
+ return LOG;
+ }
+
+ @Override
+ protected void doGet(final HttpServletRequest request, final HttpServletResponse response)
+ throws ServletException, IOException {
+ final Subject user = authenticate(request, response);
+ if (user == null) {
+ return;
+ }
+
+ final String pathInfo = normalizePath(request.getPathInfo());
+
+ try {
+ if (pathInfo == null || pathInfo.isEmpty() || pathInfo.equals("/")) {
+ // GET /api/packages — list all
+ handleListPackages(response);
+ } else if (pathInfo.endsWith("/icon")) {
+ // GET /api/packages/{name}/icon
+ final String name = pathInfo.substring(1, pathInfo.length() - "/icon".length());
+ handleGetIcon(response, name);
+ } else {
+ // GET /api/packages/{name}
+ final String name = pathInfo.substring(1);
+ handleGetPackage(response, name);
+ }
+ } catch (final PackageException e) {
+ writeError(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR,
+ "PACKAGE_ERROR", e.getMessage());
+ }
+ }
+
+ @Override
+ protected void doPost(final HttpServletRequest request, final HttpServletResponse response)
+ throws ServletException, IOException {
+ final Subject user = authenticate(request, response);
+ if (user == null) {
+ return;
+ }
+ if (!user.hasDbaRole()) {
+ writeError(response, HttpServletResponse.SC_FORBIDDEN,
+ "FORBIDDEN", "DBA role required for package installation");
+ return;
+ }
+
+ final String pathInfo = normalizePath(request.getPathInfo());
+
+ try {
+ if ("/install".equals(pathInfo)) {
+ handleInstall(request, response, user);
+ } else if ("/update-check".equals(pathInfo)) {
+ handleUpdateCheck(request, response);
+ } else {
+ writeError(response, HttpServletResponse.SC_BAD_REQUEST,
+ "BAD_REQUEST", "Unknown POST endpoint: " + pathInfo);
+ }
+ } catch (final PackageException e) {
+ writeError(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR,
+ "PACKAGE_ERROR", e.getMessage());
+ } catch (final EXistException e) {
+ writeError(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR,
+ "DATABASE_ERROR", e.getMessage());
+ }
+ }
+
+ @Override
+ protected void doDelete(final HttpServletRequest request, final HttpServletResponse response)
+ throws ServletException, IOException {
+ final Subject user = authenticate(request, response);
+ if (user == null) {
+ return;
+ }
+ if (!user.hasDbaRole()) {
+ writeError(response, HttpServletResponse.SC_FORBIDDEN,
+ "FORBIDDEN", "DBA role required for package removal");
+ return;
+ }
+
+ final String pathInfo = normalizePath(request.getPathInfo());
+ if (pathInfo == null || pathInfo.length() <= 1) {
+ writeError(response, HttpServletResponse.SC_BAD_REQUEST,
+ "BAD_REQUEST", "Package name required");
+ return;
+ }
+
+ final String nameOrAbbrev = pathInfo.substring(1);
+
+ try {
+ // Check for dependents first
+ final List dependents = packageService.findDependents(getPool(), nameOrAbbrev);
+ final boolean force = "true".equals(request.getParameter("force"));
+ if (!dependents.isEmpty() && !force) {
+ response.setStatus(HttpServletResponse.SC_CONFLICT);
+ response.setContentType("application/json");
+ try (final OutputStream os = response.getOutputStream();
+ final JsonGenerator gen = createJsonGenerator(os)) {
+ gen.writeStartObject();
+ gen.writeStringField("status", "error");
+ gen.writeStringField("code", "HAS_DEPENDENTS");
+ gen.writeStringField("message", "Cannot remove: other packages depend on " + nameOrAbbrev);
+ gen.writeArrayFieldStart("dependents");
+ for (final String dep : dependents) {
+ gen.writeString(dep);
+ }
+ gen.writeEndArray();
+ gen.writeEndObject();
+ }
+ return;
+ }
+
+ try (final DBBroker broker = getPool().get(Optional.of(user));
+ final Txn transaction = getPool().getTransactionManager().beginTransaction()) {
+ final Map result = packageService.removePackage(broker, transaction, nameOrAbbrev);
+ transaction.commit();
+ writeJsonMap(response, HttpServletResponse.SC_OK, result);
+ } catch (final EXistException e) {
+ writeError(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR,
+ "DATABASE_ERROR", e.getMessage());
+ }
+ } catch (final PackageException e) {
+ if (e.getMessage() != null && e.getMessage().contains("not found")) {
+ writeError(response, HttpServletResponse.SC_NOT_FOUND,
+ "PACKAGE_NOT_FOUND", e.getMessage());
+ } else {
+ writeError(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR,
+ "PACKAGE_ERROR", e.getMessage());
+ }
+ }
+ }
+
+ @Override
+ protected void doOptions(final HttpServletRequest request, final HttpServletResponse response)
+ throws ServletException, IOException {
+ response.setHeader("Access-Control-Allow-Origin", "*");
+ response.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
+ response.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
+ response.setStatus(HttpServletResponse.SC_OK);
+ }
+
+ // --- Handler methods ---
+
+ private void handleListPackages(final HttpServletResponse response)
+ throws IOException, PackageException {
+ final List