You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@juneau.apache.org by ja...@apache.org on 2026/05/24 23:12:30 UTC

(juneau) branch master updated: feat(rest-server): ops/introspection mixin pack — Echo/Admin/RouteIndex + DenyAllGuard (FINISHED-77)

This is an automated email from the ASF dual-hosted git repository.

jamesbognar pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/juneau.git


The following commit(s) were added to refs/heads/master by this push:
     new ae494dbff5 feat(rest-server): ops/introspection mixin pack — Echo/Admin/RouteIndex + DenyAllGuard (FINISHED-77)
ae494dbff5 is described below

commit ae494dbff528a10a7c806992d29d8f3fc00cb6f6
Author: James Bognar <ja...@jamesbog-ltmjqf2.internal.salesforce.com>
AuthorDate: Sun May 24 19:12:25 2026 -0400

    feat(rest-server): ops/introspection mixin pack — Echo/Admin/RouteIndex + DenyAllGuard (FINISHED-77)
---
 .../org/apache/juneau/rest/guard/DenyAllGuard.java |  66 +++
 .../apache/juneau/rest/ops/BasicAdminResource.java | 455 +++++++++++++++++++++
 .../apache/juneau/rest/ops/BasicEchoResource.java  | 361 ++++++++++++++++
 .../juneau/rest/ops/BasicRouteIndexResource.java   | 233 +++++++++++
 .../org/apache/juneau/rest/ops/package-info.java   |  88 ++++
 .../rest/ops/BasicAdminResource_AsMixin_Test.java  | 337 +++++++++++++++
 .../rest/ops/BasicEchoResource_AsMixin_Test.java   | 326 +++++++++++++++
 .../BasicEchoResource_JettyMicroservice_Test.java  | 133 ++++++
 .../ops/BasicEchoResource_Springboot_Test.java     | 120 ++++++
 .../rest/ops/BasicOps_OpenApiHidden_Test.java      | 100 +++++
 .../juneau/rest/ops/BasicOps_ParentChain_Test.java | 122 ++++++
 .../ops/BasicRouteIndexResource_AsMixin_Test.java  | 218 ++++++++++
 ...n.md => FINISHED-77-mixin-ops-introspection.md} |  95 ++++-
 todo/TODO-69-authn-guards-jwt-apikey.md            |   1 +
 todo/TODO.md                                       |   6 +-
 15 files changed, 2657 insertions(+), 4 deletions(-)

diff --git a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/guard/DenyAllGuard.java b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/guard/DenyAllGuard.java
new file mode 100644
index 0000000000..af6a112a63
--- /dev/null
+++ b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/guard/DenyAllGuard.java
@@ -0,0 +1,66 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.guard;
+
+import org.apache.juneau.rest.*;
+import org.apache.juneau.rest.annotation.*;
+
+/**
+ * Deny-all {@link RestGuard} &mdash; rejects every request with {@code 403 Forbidden}.
+ *
+ * <p>
+ * Used as the secure-by-default placeholder on operations or resources that require an
+ * authentication / authorization chain the importer must explicitly opt into. Without the
+ * importer's override, every request is denied; once a user-supplied
+ * {@link org.apache.juneau.commons.inject.Bean @Bean} {@link RestGuardList} is registered on the
+ * resource, the framework's bean-store override seam <b>replaces</b> the entire
+ * annotation-derived guard list (including this deny-all) with the user-supplied chain &mdash;
+ * see the {@code RestOpContext.guards} memoizer for the full lookup contract.
+ *
+ * <h5 class='figure'>Composition example:</h5>
+ *
+ * <p class='bjava'>
+ * 	<jc>// Default-deny on a resource until the importer wires up auth.</jc>
+ * 	<ja>@Rest</ja>(path=<js>"/admin"</js>, guards=DenyAllGuard.<jk>class</jk>)
+ * 	<jk>public class</jk> AdminResource <jk>extends</jk> RestServlet {
+ *
+ * 		<jc>// Importer opt-in: register a guard chain that allows authorized callers through.</jc>
+ * 		<ja>@Bean</ja>(name=<js>"guards"</js>)
+ * 		<jk>public</jk> RestGuardList guards(BeanStore <jv>bs</jv>) {
+ * 			<jk>return</jk> RestGuardList.<jsm>create</jsm>(<jv>bs</jv>)
+ * 				.append(<jk>new</jk> MyAuthGuard())
+ * 				.build();
+ * 		}
+ * 	}
+ * </p>
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ * 	<li class='jc'>{@link RestGuard}
+ * 	<li class='jc'>{@link RestGuardList}
+ * 	<li class='ja'>{@link Rest#guards()}
+ * 	<li class='ja'>{@link RestOp#guards()}
+ * </ul>
+ *
+ * @since 9.5.0
+ */
+public class DenyAllGuard extends RestGuard {
+
+	@Override /* Overridden from RestGuard */
+	public boolean isRequestAllowed(RestRequest req) {
+		return false;
+	}
+}
diff --git a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/ops/BasicAdminResource.java b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/ops/BasicAdminResource.java
new file mode 100644
index 0000000000..08f9e72073
--- /dev/null
+++ b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/ops/BasicAdminResource.java
@@ -0,0 +1,455 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.ops;
+
+import java.io.*;
+import java.lang.management.*;
+import java.util.*;
+import java.util.function.*;
+
+import org.apache.juneau.commons.inject.*;
+import org.apache.juneau.http.annotation.*;
+import org.apache.juneau.http.response.*;
+import org.apache.juneau.json.*;
+import org.apache.juneau.rest.*;
+import org.apache.juneau.rest.annotation.*;
+import org.apache.juneau.rest.guard.*;
+
+/**
+ * Mixin that serves operational-introspection endpoints under {@code /admin/*}: thread dump,
+ * heap statistics, configurable cache-flush hooks, and rate-limit-bucket inspection.
+ *
+ * <p>
+ * Sibling of {@link BasicEchoResource} ({@code /echo/*} / {@code /debug/echo/*}) and
+ * {@link BasicRouteIndexResource} ({@code /options} / {@code /routes}). All three classes live in
+ * the {@code org.apache.juneau.rest.ops} ops/introspection mixin pack.
+ *
+ * <h5 class='section'>Default-deny security posture:</h5>
+ *
+ * <p>
+ * The mixin is annotated with {@link Rest#guards() @Rest(guards=DenyAllGuard.class)}, so every
+ * admin path returns {@code 403 Forbidden} until the importer registers a
+ * {@link org.apache.juneau.commons.inject.Bean @Bean} {@link RestGuardList} factory on the host.
+ * The framework's bean-store override seam <b>replaces</b> the entire annotation-derived guard
+ * list (including this deny-all) with the user-supplied chain &mdash; pair the mixin with whatever
+ * authentication / authorization story your service uses (bearer-token guard, API-key guard,
+ * Spring Security adapter, etc.).
+ *
+ * <p>
+ * <b>Why deny-all rather than a non-existent role-name placeholder?</b> The role-guard approach
+ * (declaring a role that no real principal has) has clean semantics but harder ergonomics: the
+ * importer overrides by setting the principal's roles to include the placeholder, which couples
+ * the host's auth strategy to a framework-internal role name. Replacing a {@link RestGuardList}
+ * via {@code @Bean} keeps the override surface narrow &mdash; one factory method on the host
+ * &mdash; and matches the way every other Juneau op-context bean is wired.
+ *
+ * <h5 class='figure'>Composition example:</h5>
+ *
+ * <p class='bjava'>
+ * 	<ja>@Rest</ja>(path=<js>"/api"</js>, mixins=BasicAdminResource.<jk>class</jk>)
+ * 	<jk>public class</jk> ApiResource <jk>extends</jk> RestServlet {
+ *
+ * 		<jc>// Required: register an auth guard chain to unlock the admin paths.</jc>
+ * 		<ja>@Bean</ja>(name=<js>"guards"</js>)
+ * 		<jk>public</jk> RestGuardList guards(BeanStore <jv>bs</jv>) {
+ * 			<jk>return</jk> RestGuardList.<jsm>create</jsm>(<jv>bs</jv>)
+ * 				.append(<jk>new</jk> MyBearerTokenGuard())
+ * 				.build();
+ * 		}
+ *
+ * 		<ja>@Bean</ja> BasicAdminResource admin() {
+ * 			<jk>return</jk> BasicAdminResource.<jsm>create</jsm>()
+ * 				.cacheFlush(<js>"primary"</js>, () -&gt; primaryCache.invalidateAll())
+ * 				.build();
+ * 		}
+ * 	}
+ * </p>
+ *
+ * <h5 class='section'>Endpoints:</h5>
+ *
+ * <ul class='spaced-list'>
+ * 	<li><b>{@code GET /admin/threads}</b> &mdash; emits a JSON list of currently-live threads. The
+ * 		default thread filter excludes framework noise (JVM internals, servlet container, Spring
+ * 		Boot infrastructure); override via {@link Builder#threadNamePrefixExclude(String...)}.
+ * 	<li><b>{@code GET /admin/heap}</b> &mdash; emits a JSON map with {@code Runtime} heap stats
+ * 		({@code total}, {@code free}, {@code max}, {@code used}) plus
+ * 		{@link MemoryMXBean#getNonHeapMemoryUsage() non-heap} memory usage. No heap-dump file
+ * 		generation in v1 (security risk).
+ * 	<li><b>{@code POST /admin/cache/flush}</b> &mdash; runs all registered cache-flush hooks, or
+ * 		just a comma-separated {@code names} subset when supplied as a query parameter. Returns a
+ * 		JSON map of the hooks that were invoked. Hooks are registered name-keyed via
+ * 		{@link Builder#cacheFlush(String,Runnable)}; users that want async semantics own the
+ * 		threading model.
+ * 	<li><b>{@code GET /admin/ratelimit}</b> &mdash; emits a JSON map keyed by bean name listing the
+ * 		registered {@link RateLimitGuard} configuration. Returns {@code 404 Not Found} when no
+ * 		{@code RateLimitGuard} bean is registered on the importer's bean store. Bucket-level
+ * 		inspection (per-key counters) is reserved for a follow-on once {@code RateLimitGuard.Storage}
+ * 		exposes a snapshot SPI; v1 emits configuration only.
+ * </ul>
+ *
+ * <p>
+ * All four endpoints carry {@link OpSwagger#ignore() @OpSwagger(ignore=true)} so the admin surface
+ * stays out of any generated Swagger / OpenAPI spec.
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ * 	<li class='jc'>{@link BasicEchoResource}
+ * 	<li class='jc'>{@link BasicRouteIndexResource}
+ * 	<li class='jc'>{@link DenyAllGuard}
+ * 	<li class='jc'>{@link RestGuardList}
+ * 	<li class='link'><a class="doclink" href="https://juneau.apache.org/docs/topics/RestServerComposition">REST Server &mdash; Composition (mixins, paths)</a>
+ * </ul>
+ *
+ * @since 9.5.0
+ */
+// @formatter:off
+@Rest(
+	paths={"/admin/threads","/admin/heap","/admin/cache/flush","/admin/ratelimit"},
+	guards=DenyAllGuard.class
+)
+public class BasicAdminResource {
+
+	/**
+	 * Default thread-name-prefix exclude list: filter out JVM internals, servlet container, and
+	 * Spring Boot infrastructure threads.
+	 */
+	public static final List<String> DEFAULT_THREAD_NAME_PREFIX_EXCLUDE = List.of(
+		"Reference Handler", "Finalizer", "Signal Dispatcher", "Common-Cleaner",
+		"Notification Thread", "Attach Listener",
+		"jetty-", "qtp",
+		"spring-",
+		"GC ", "G1 ");
+
+	/**
+	 * Creates a new builder.
+	 *
+	 * @return A new builder.
+	 */
+	public static Builder create() {
+		return new Builder();
+	}
+
+	private final Map<String,Runnable> cacheFlushHooks;
+	private final List<String> threadNamePrefixExclude;
+
+	/** No-arg constructor &mdash; uses the default thread filter and no cache-flush hooks. */
+	public BasicAdminResource() {
+		this(create());
+	}
+
+	/**
+	 * Builder constructor.
+	 *
+	 * @param builder The builder.
+	 */
+	protected BasicAdminResource(Builder builder) {
+		cacheFlushHooks = Collections.unmodifiableMap(new LinkedHashMap<>(builder.cacheFlushHooks));
+		threadNamePrefixExclude = List.copyOf(builder.threadNamePrefixExclude);
+	}
+
+	/**
+	 * [GET /admin/threads] &mdash; emit a JSON list of currently-live threads.
+	 *
+	 * @param res The current REST response.
+	 * @throws IOException If an I/O error occurs while writing the response.
+	 */
+	@RestGet(
+		path="/admin/threads",
+		summary="Thread dump",
+		description="JSON list of currently-live threads (filtered to exclude framework noise by default).",
+		swagger=@OpSwagger(ignore=true)
+	)
+	public void getThreads(RestResponse res) throws IOException {
+		var allTraces = Thread.getAllStackTraces();
+		var out = new ArrayList<Map<String,Object>>();
+		for (var e : allTraces.entrySet()) {
+			var t = e.getKey();
+			if (isExcludedThread(t.getName()))
+				continue;
+			var entry = new LinkedHashMap<String,Object>();
+			entry.put("name", t.getName());
+			entry.put("id", t.getId());
+			entry.put("state", t.getState().toString());
+			entry.put("daemon", t.isDaemon());
+			entry.put("priority", t.getPriority());
+			var stack = new ArrayList<String>();
+			for (var f : e.getValue())
+				stack.add(f.toString());
+			entry.put("stack", stack);
+			out.add(entry);
+		}
+		writeJson(res, out);
+	}
+
+	/**
+	 * [GET /admin/heap] &mdash; emit JVM heap and non-heap memory statistics.
+	 *
+	 * @param res The current REST response.
+	 * @throws IOException If an I/O error occurs while writing the response.
+	 */
+	@RestGet(
+		path="/admin/heap",
+		summary="Heap statistics",
+		description="JVM heap + non-heap memory statistics (Runtime + MemoryMXBean).",
+		swagger=@OpSwagger(ignore=true)
+	)
+	public void getHeap(RestResponse res) throws IOException {
+		var rt = Runtime.getRuntime();
+		var heap = new LinkedHashMap<String,Object>();
+		heap.put("total", rt.totalMemory());
+		heap.put("free", rt.freeMemory());
+		heap.put("max", rt.maxMemory());
+		heap.put("used", rt.totalMemory() - rt.freeMemory());
+
+		var mx = ManagementFactory.getMemoryMXBean();
+		var nonHeapUsage = mx.getNonHeapMemoryUsage();
+		var nonHeap = new LinkedHashMap<String,Object>();
+		nonHeap.put("init", nonHeapUsage.getInit());
+		nonHeap.put("used", nonHeapUsage.getUsed());
+		nonHeap.put("committed", nonHeapUsage.getCommitted());
+		nonHeap.put("max", nonHeapUsage.getMax());
+
+		var out = new LinkedHashMap<String,Object>();
+		out.put("heap", heap);
+		out.put("nonHeap", nonHeap);
+		out.put("availableProcessors", rt.availableProcessors());
+		writeJson(res, out);
+	}
+
+	/**
+	 * [POST /admin/cache/flush] &mdash; run all registered cache-flush hooks (or a subset).
+	 *
+	 * <p>
+	 * When {@code names} is supplied, only the named hooks are invoked. Unknown names are
+	 * silently ignored (404-on-unknown would leak the registered hook set). Hook execution is
+	 * synchronous; long-running hooks block the request thread &mdash; users that want async
+	 * semantics should register a hook that hands work off to an executor.
+	 *
+	 * @param req The current REST request &mdash; {@code names} query parameter is read off it.
+	 * @param res The current REST response.
+	 * @throws IOException If an I/O error occurs while writing the response.
+	 */
+	@RestPost(
+		path="/admin/cache/flush",
+		summary="Cache flush",
+		description="Runs the registered cache-flush hooks (all by default; ?names=foo,bar for a subset).",
+		swagger=@OpSwagger(ignore=true)
+	)
+	public void postCacheFlush(RestRequest req, RestResponse res) throws IOException {
+		var namesParam = req.getQueryParams().get("names").asString().orElse(null);
+		Set<String> selected = null;
+		if (namesParam != null && ! namesParam.isBlank()) {
+			selected = new LinkedHashSet<>();
+			for (var n : namesParam.split(","))
+				if (! n.isBlank())
+					selected.add(n.trim());
+		}
+		var executed = new ArrayList<String>();
+		for (var e : cacheFlushHooks.entrySet()) {
+			if (selected != null && ! selected.contains(e.getKey()))
+				continue;
+			e.getValue().run();
+			executed.add(e.getKey());
+		}
+		var out = new LinkedHashMap<String,Object>();
+		out.put("registered", new ArrayList<>(cacheFlushHooks.keySet()));
+		out.put("executed", executed);
+		writeJson(res, out);
+	}
+
+	/**
+	 * [GET /admin/ratelimit] &mdash; emit registered {@link RateLimitGuard} bean configuration.
+	 *
+	 * <p>
+	 * Returns {@code 404 Not Found} when no {@code RateLimitGuard} bean is registered.
+	 *
+	 * @param req The current REST request &mdash; supplies the bean store for guard lookup.
+	 * @param res The current REST response.
+	 * @throws IOException If an I/O error occurs while writing the response.
+	 * @throws NotFound If no {@link RateLimitGuard} bean is registered on the importer's bean
+	 * 	store.
+	 */
+	@RestGet(
+		path="/admin/ratelimit",
+		summary="Rate-limit inspection",
+		description="JSON map of registered RateLimitGuard beans keyed by bean name.",
+		swagger=@OpSwagger(ignore=true)
+	)
+	public void getRateLimit(RestRequest req, RestResponse res) throws IOException {
+		var bs = req.getContext().getBeanStore();
+		var guards = collectRateLimitGuards(bs);
+		if (guards.isEmpty())
+			throw new NotFound("No RateLimitGuard bean is registered.");
+		var entries = new LinkedHashMap<String,Object>();
+		for (var e : guards.entrySet()) {
+			var bucket = new LinkedHashMap<String,Object>();
+			bucket.put("config", describeRateLimitGuard(e.getValue()));
+			bucket.put("buckets", List.of());
+			entries.put(e.getKey(), bucket);
+		}
+		var out = new LinkedHashMap<String,Object>();
+		out.put("guards", entries);
+		writeJson(res, out);
+	}
+
+	/**
+	 * Returns the registered cache-flush hooks (test/inspection helper).
+	 *
+	 * @return The hooks, keyed by registration name. Never {@code null}.
+	 */
+	public Map<String,Runnable> getCacheFlushHooks() {
+		return cacheFlushHooks;
+	}
+
+	/**
+	 * Returns the configured thread-name-prefix exclude list (test/inspection helper).
+	 *
+	 * @return The exclude list. Never {@code null}.
+	 */
+	public List<String> getThreadNamePrefixExclude() {
+		return threadNamePrefixExclude;
+	}
+
+	private boolean isExcludedThread(String threadName) {
+		for (var prefix : threadNamePrefixExclude)
+			if (threadName != null && threadName.startsWith(prefix))
+				return true;
+		return false;
+	}
+
+	private static Map<String,RateLimitGuard> collectRateLimitGuards(BeanStore bs) {
+		// Multi-bean lookup so users running per-tier guards (free / paid / etc.) see every
+		// registered bean keyed by its bean name. Falls through to the single-bean path when no
+		// per-tier configuration is registered.
+		var byName = bs.getBeansOfType(RateLimitGuard.class);
+		if (byName != null && ! byName.isEmpty())
+			return new LinkedHashMap<>(byName);
+		var out = new LinkedHashMap<String,RateLimitGuard>();
+		bs.getBean(RateLimitGuard.class).ifPresent(g -> out.put("rateLimit", g));
+		return out;
+	}
+
+	private static Map<String,Object> describeRateLimitGuard(RateLimitGuard g) {
+		var m = new LinkedHashMap<String,Object>();
+		m.put("class", g.getClass().getName());
+		return m;
+	}
+
+	private static void writeJson(RestResponse res, Object payload) throws IOException {
+		try (var w = res.getDirectWriter("application/json")) {
+			JsonSerializer.DEFAULT_READABLE.serialize(payload, w);
+		}
+	}
+
+	/**
+	 * Builder for {@link BasicAdminResource} instances.
+	 */
+	public static class Builder {
+
+		private final Map<String,Runnable> cacheFlushHooks = new LinkedHashMap<>();
+		private final List<String> threadNamePrefixExclude = new ArrayList<>(DEFAULT_THREAD_NAME_PREFIX_EXCLUDE);
+
+		/** Constructor &mdash; package access for {@link BasicAdminResource#create()}. */
+		protected Builder() {}
+
+		/**
+		 * Registers a cache-flush hook under a name.
+		 *
+		 * <p>
+		 * The hook is invoked synchronously when {@code POST /admin/cache/flush} is called (with
+		 * no {@code names} parameter, or with a {@code names} parameter that includes this name).
+		 *
+		 * @param name The hook name. Must not be {@code null} or blank.
+		 * @param hook The hook. Must not be {@code null}.
+		 * @return This object.
+		 */
+		public Builder cacheFlush(String name, Runnable hook) {
+			if (name == null || name.isBlank())
+				throw new IllegalArgumentException("Argument 'name' must not be null or blank");
+			if (hook == null)
+				throw new IllegalArgumentException("Argument 'hook' must not be null");
+			cacheFlushHooks.put(name, hook);
+			return this;
+		}
+
+		/**
+		 * Registers multiple cache-flush hooks at once.
+		 *
+		 * @param hooks The hooks, keyed by registration name.
+		 * @return This object.
+		 */
+		public Builder cacheFlushAll(Map<String,Runnable> hooks) {
+			if (hooks != null)
+				hooks.forEach(this::cacheFlush);
+			return this;
+		}
+
+		/**
+		 * Replaces the thread-name-prefix exclude list.
+		 *
+		 * <p>
+		 * Threads whose {@link Thread#getName() name} starts with any of the supplied prefixes are
+		 * omitted from the {@code /admin/threads} output. Pass an empty array to disable
+		 * filtering entirely.
+		 *
+		 * @param values The thread-name prefixes to exclude. Must not be {@code null}.
+		 * @return This object.
+		 */
+		public Builder threadNamePrefixExclude(String...values) {
+			threadNamePrefixExclude.clear();
+			if (values != null)
+				for (var v : values)
+					if (v != null && ! v.isEmpty())
+						threadNamePrefixExclude.add(v);
+			return this;
+		}
+
+		/**
+		 * Builds a {@link BasicAdminResource} instance.
+		 *
+		 * @return A configured instance.
+		 */
+		public BasicAdminResource build() {
+			return new BasicAdminResource(this);
+		}
+
+		/**
+		 * Returns the registered cache-flush hooks (builder-time inspection helper).
+		 *
+		 * @return The hooks, keyed by registration name. Never {@code null}.
+		 */
+		Map<String,Runnable> getCacheFlushHooksForTesting() {
+			return cacheFlushHooks;
+		}
+
+		/**
+		 * Returns the configured thread-name-prefix exclude list (builder-time inspection
+		 * helper).
+		 *
+		 * @return The exclude list. Never {@code null}.
+		 */
+		List<String> getThreadNamePrefixExcludeForTesting() {
+			return threadNamePrefixExclude;
+		}
+	}
+
+	/**
+	 * Reserved Spring-style {@link Supplier} surface for callers that want lazy hook resolution.
+	 * Currently unused publicly; left as a sealed extension point.
+	 */
+	@SuppressWarnings("unused")
+	private interface CacheFlushSupplier extends Supplier<Runnable> {}
+}
diff --git a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/ops/BasicEchoResource.java b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/ops/BasicEchoResource.java
new file mode 100644
index 0000000000..ecf207f0b8
--- /dev/null
+++ b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/ops/BasicEchoResource.java
@@ -0,0 +1,361 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.ops;
+
+import java.io.*;
+import java.nio.charset.*;
+import java.util.*;
+
+import org.apache.juneau.http.annotation.*;
+import org.apache.juneau.http.response.*;
+import org.apache.juneau.json.*;
+import org.apache.juneau.rest.*;
+import org.apache.juneau.rest.annotation.*;
+
+/**
+ * Mixin that serves a request-echo / round-trip introspection endpoint at {@code /echo/*} and
+ * {@code /debug/echo/*}.
+ *
+ * <p>
+ * Sibling of {@link BasicAdminResource} ({@code /admin/*}) and {@link BasicRouteIndexResource}
+ * ({@code /options}, {@code /routes}). All three classes live in the
+ * {@code org.apache.juneau.rest.ops} ops/introspection mixin pack.
+ *
+ * <p>
+ * Compose into a host resource via
+ * {@link Rest#mixins() @Rest(mixins=BasicEchoResource.class)}; the {@code /echo/*} and
+ * {@code /debug/echo/*} URLs become available alongside the host's own endpoints with no further
+ * wiring. Or extend the class directly for a standalone deployment whose mount paths come from
+ * the inherited {@link Rest#paths() @Rest(paths)} default.
+ *
+ * <h5 class='figure'>Composition example:</h5>
+ *
+ * <p class='bjava'>
+ * 	<ja>@Rest</ja>(
+ * 		path=<js>"/api"</js>,
+ * 		mixins=BasicEchoResource.<jk>class</jk>,
+ * 		debug=<js>"conditional"</js>          <jc>// gates the echo endpoint per request</jc>
+ * 	)
+ * 	<jk>public class</jk> ApiResource <jk>extends</jk> RestServlet { }
+ *
+ * 	<jc>// Optional: tighten the body cap or adjust redacted headers via a @Bean factory:</jc>
+ * 	<ja>@Bean</ja> BasicEchoResource echo() {
+ * 		<jk>return</jk> BasicEchoResource.<jsm>create</jsm>()
+ * 			.bodyLimit(64 * 1024L)
+ * 			.redactHeader(<js>"X-Internal-Trace"</js>)
+ * 			.build();
+ * 	}
+ * </p>
+ *
+ * <h5 class='section'>Debug gating:</h5>
+ *
+ * <p>
+ * The handler is gated behind the host's
+ * {@link RestContext#getDebugEnablement() DebugEnablement} chain &mdash; the same mechanism that
+ * powers {@code @Rest(debug=...)}. When {@code Debug} resolves to {@code OFF} for the current
+ * request, the handler returns {@code 404 Not Found} (so the existence of the endpoint isn't
+ * disclosed). When debug is {@code ALWAYS}, or {@code CONDITIONAL} with the {@code Debug: true}
+ * request header, the full echo payload is returned. The recommended posture for production
+ * deployments is {@code @Rest(debug="conditional")} paired with a guard chain so only authorized
+ * operators can flip the {@code Debug} header.
+ *
+ * <h5 class='section'>Sensitive-header redaction:</h5>
+ *
+ * <p>
+ * Token-bearing headers must never be reflected back to the caller (would defeat any auth scheme
+ * in front of the endpoint). The default redacted list is &mdash; case-insensitively &mdash;
+ * {@code Authorization}, {@code Cookie}, {@code Set-Cookie}, {@code Proxy-Authorization}, and
+ * {@code X-API-Key}. Each redacted header surfaces in the echo body with the literal value
+ * {@value #REDACTED} so the caller can see the header was present without leaking its value.
+ * Override the default list via {@link Builder#redactedHeaders(String...)} (replaces) or extend
+ * via {@link Builder#redactHeader(String)} (additive).
+ *
+ * <h5 class='section'>Body capture and truncation:</h5>
+ *
+ * <p>
+ * The handler reads up to {@link Builder#bodyLimit(long) bodyLimit} bytes (default
+ * {@value #DEFAULT_BODY_LIMIT_DOC} = 1 MB) of the inbound body and emits it as a UTF-8 string in
+ * the {@code content} field of the JSON response. When the body exceeds the cap, the captured
+ * portion is truncated and the {@code truncated} flag is set to {@code true} so callers can see
+ * the response is incomplete.
+ *
+ * <h5 class='section'>Response shape:</h5>
+ *
+ * <p class='bjson'>
+ * 	{
+ * 		<jok>"method"</jok>: <jov>"POST"</jov>,
+ * 		<jok>"path"</jok>: <jov>"/echo/foo/bar"</jov>,
+ * 		<jok>"queryString"</jok>: <jov>"x=1"</jov>,
+ * 		<jok>"pathRemainder"</jok>: <jov>"foo/bar"</jov>,
+ * 		<jok>"headers"</jok>: { <jok>"User-Agent"</jok>: <jov>"curl"</jov>, <jok>"Authorization"</jok>: <jov>"[REDACTED]"</jov> },
+ * 		<jok>"queryParams"</jok>: { <jok>"x"</jok>: <jov>"1"</jov> },
+ * 		<jok>"attributes"</jok>: { },
+ * 		<jok>"contentLength"</jok>: <jov>5</jov>,
+ * 		<jok>"content"</jok>: <jov>"hello"</jov>,
+ * 		<jok>"truncated"</jok>: <jov>false</jov>
+ * 	}
+ * </p>
+ *
+ * <p>
+ * The endpoint is excluded from generated Swagger / OpenAPI specs via
+ * {@link OpSwagger#ignore() @OpSwagger(ignore=true)}.
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ * 	<li class='jc'>{@link BasicAdminResource}
+ * 	<li class='jc'>{@link BasicRouteIndexResource}
+ * 	<li class='link'><a class="doclink" href="https://juneau.apache.org/docs/topics/RestServerComposition">REST Server &mdash; Composition (mixins, paths)</a>
+ * </ul>
+ *
+ * @since 9.5.0
+ */
+// @formatter:off
+@Rest(paths={"/echo/*","/debug/echo/*"})
+public class BasicEchoResource {
+
+	/** Sentinel value emitted in place of redacted header values. */
+	public static final String REDACTED = "[REDACTED]";
+
+	/** Default body capture cap, in bytes (1&nbsp;MB). */
+	public static final long DEFAULT_BODY_LIMIT = 1_048_576L;
+
+	/** Default body capture cap rendered for the class-level javadoc &mdash; do not use programmatically. */
+	static final String DEFAULT_BODY_LIMIT_DOC = "1048576";
+
+	/** Default redacted-header set (case-insensitive lookup). */
+	public static final Set<String> DEFAULT_REDACTED_HEADERS = Set.of(
+		"Authorization", "Cookie", "Set-Cookie", "Proxy-Authorization", "X-API-Key");
+
+	/**
+	 * Creates a new builder.
+	 *
+	 * @return A new builder.
+	 */
+	public static Builder create() {
+		return new Builder();
+	}
+
+	private final long bodyLimit;
+	private final Set<String> redactedHeadersLower;
+
+	/** No-arg constructor &mdash; uses {@link #DEFAULT_BODY_LIMIT} and {@link #DEFAULT_REDACTED_HEADERS}. */
+	public BasicEchoResource() {
+		this(create());
+	}
+
+	/**
+	 * Builder constructor.
+	 *
+	 * @param builder The builder.
+	 */
+	protected BasicEchoResource(Builder builder) {
+		bodyLimit = builder.bodyLimit;
+		var s = new LinkedHashSet<String>();
+		for (var h : builder.redactedHeaders)
+			s.add(h.toLowerCase(Locale.ROOT));
+		redactedHeadersLower = Collections.unmodifiableSet(s);
+	}
+
+	/**
+	 * [* /echo/* | /debug/echo/*] &mdash; emit an introspection echo of the inbound request.
+	 *
+	 * <p>
+	 * Returns {@code 404 Not Found} when {@code Debug} is not enabled for the current request.
+	 *
+	 * @param req The current REST request.
+	 * @param res The current REST response.
+	 * @param remainder The path remainder after the mount prefix (multi-segment, may be empty).
+	 * @throws IOException If an I/O error occurs while reading the request or writing the response.
+	 * @throws NotFound When {@code Debug} resolves to {@code OFF} for the current request.
+	 */
+	@RestOp(
+		method="*",
+		path={"/echo/*","/debug/echo/*"},
+		summary="Request echo",
+		description="Round-trip introspection of the inbound request. Debug-gated.",
+		swagger=@OpSwagger(ignore=true)
+	)
+	public void echo(RestRequest req, RestResponse res, @Path("/*") String remainder) throws IOException {
+		var ctx = req.getContext();
+		var de = ctx.getDebugEnablement();
+		var sreq = req.getHttpServletRequest();
+		if (de == null || ! de.isDebug(ctx, sreq))
+			throw new NotFound("Echo endpoint disabled (Debug not enabled).");
+
+		var headers = new LinkedHashMap<String,String>();
+		var names = sreq.getHeaderNames();
+		while (names != null && names.hasMoreElements()) {
+			var name = names.nextElement();
+			var key = name.toLowerCase(Locale.ROOT);
+			headers.put(name, redactedHeadersLower.contains(key) ? REDACTED : sreq.getHeader(name));
+		}
+
+		var queryParams = new LinkedHashMap<String,String>();
+		for (var p : req.getQueryParams())
+			queryParams.put(p.getName(), p.asString().orElse(null));
+
+		var attributes = new LinkedHashMap<String,String>();
+		req.getAttributes().asMap().forEach((k, v) -> attributes.put(k, String.valueOf(v)));
+
+		var capture = readBoundedBody(sreq.getInputStream(), bodyLimit);
+
+		var out = new LinkedHashMap<String,Object>();
+		out.put("method", req.getMethod());
+		out.put("path", sreq.getRequestURI());
+		out.put("queryString", sreq.getQueryString());
+		out.put("pathRemainder", remainder == null ? "" : remainder);
+		out.put("headers", headers);
+		out.put("queryParams", queryParams);
+		out.put("attributes", attributes);
+		out.put("contentLength", capture.bytesRead);
+		if (capture.bytesRead > 0)
+			out.put("content", new String(capture.bytes, StandardCharsets.UTF_8));
+		out.put("truncated", capture.truncated);
+
+		try (var w = res.getDirectWriter("application/json")) {
+			JsonSerializer.DEFAULT_READABLE.serialize(out, w);
+		}
+	}
+
+	/**
+	 * Returns the configured body-capture cap (test/inspection helper).
+	 *
+	 * @return The body capture cap, in bytes.
+	 */
+	public long getBodyLimit() {
+		return bodyLimit;
+	}
+
+	/**
+	 * Returns the redacted-header set as lowercased, immutable strings (test/inspection helper).
+	 *
+	 * @return The redacted-header set.
+	 */
+	public Set<String> getRedactedHeadersLower() {
+		return redactedHeadersLower;
+	}
+
+	private static BodyCapture readBoundedBody(InputStream in, long limit) throws IOException {
+		if (in == null)
+			return new BodyCapture(new byte[0], 0, false);
+		var buf = new ByteArrayOutputStream();
+		var chunk = new byte[8192];
+		var truncated = false;
+		long total = 0;
+		int n;
+		while ((n = in.read(chunk)) != -1) {
+			var room = limit - total;
+			if (room <= 0) {
+				truncated = true;
+				break;
+			}
+			if (n > room) {
+				buf.write(chunk, 0, (int) room);
+				total += room;
+				truncated = true;
+				break;
+			}
+			buf.write(chunk, 0, n);
+			total += n;
+		}
+		return new BodyCapture(buf.toByteArray(), total, truncated);
+	}
+
+	private static final class BodyCapture {
+		final byte[] bytes;
+		final long bytesRead;
+		final boolean truncated;
+
+		BodyCapture(byte[] bytes, long bytesRead, boolean truncated) {
+			this.bytes = bytes;
+			this.bytesRead = bytesRead;
+			this.truncated = truncated;
+		}
+	}
+
+	/**
+	 * Builder for {@link BasicEchoResource} instances.
+	 */
+	public static class Builder {
+
+		private long bodyLimit = DEFAULT_BODY_LIMIT;
+		private final Set<String> redactedHeaders;
+
+		/** Constructor &mdash; package access for {@link BasicEchoResource#create()}. */
+		protected Builder() {
+			redactedHeaders = new LinkedHashSet<>(DEFAULT_REDACTED_HEADERS);
+		}
+
+		/**
+		 * Sets the body capture cap.
+		 *
+		 * <p>
+		 * Captured content is truncated when the inbound body exceeds this size; the
+		 * {@code truncated} flag in the response is set to {@code true}.
+		 *
+		 * @param value Cap, in bytes. Must be {@code >= 0}.
+		 * @return This object.
+		 */
+		public Builder bodyLimit(long value) {
+			if (value < 0)
+				throw new IllegalArgumentException("bodyLimit must be >= 0");
+			bodyLimit = value;
+			return this;
+		}
+
+		/**
+		 * Replaces the redacted-header set with the supplied values.
+		 *
+		 * <p>
+		 * Header names are matched case-insensitively. Pass an empty array to disable redaction
+		 * (not recommended outside of integration tests).
+		 *
+		 * @param values The header names to redact.
+		 * @return This object.
+		 */
+		public Builder redactedHeaders(String...values) {
+			redactedHeaders.clear();
+			if (values != null)
+				for (var v : values)
+					if (v != null && ! v.isBlank())
+						redactedHeaders.add(v);
+			return this;
+		}
+
+		/**
+		 * Adds an additional header name to the redacted-header set.
+		 *
+		 * @param value The header name to redact (case-insensitive). Must not be {@code null} or
+		 * 	blank.
+		 * @return This object.
+		 */
+		public Builder redactHeader(String value) {
+			if (value == null || value.isBlank())
+				throw new IllegalArgumentException("Argument 'value' must not be null or blank");
+			redactedHeaders.add(value);
+			return this;
+		}
+
+		/**
+		 * Builds a {@link BasicEchoResource} instance.
+		 *
+		 * @return A configured instance.
+		 */
+		public BasicEchoResource build() {
+			return new BasicEchoResource(this);
+		}
+	}
+}
diff --git a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/ops/BasicRouteIndexResource.java b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/ops/BasicRouteIndexResource.java
new file mode 100644
index 0000000000..96d886c11b
--- /dev/null
+++ b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/ops/BasicRouteIndexResource.java
@@ -0,0 +1,233 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.ops;
+
+import java.io.*;
+import java.lang.annotation.*;
+import java.lang.reflect.Method;
+import java.util.*;
+
+import org.apache.juneau.json.*;
+import org.apache.juneau.rest.*;
+import org.apache.juneau.rest.annotation.*;
+
+/**
+ * Mixin that emits a JSON index of all {@link RestOp @RestOp}-annotated methods on the host
+ * resource (and any other mixins on it) at {@code /options} and {@code /routes}.
+ *
+ * <p>
+ * Sibling of {@link BasicEchoResource} ({@code /echo/*} / {@code /debug/echo/*}) and
+ * {@link BasicAdminResource} ({@code /admin/*}). All three classes live in the
+ * {@code org.apache.juneau.rest.ops} ops/introspection mixin pack.
+ *
+ * <h5 class='figure'>Composition example:</h5>
+ *
+ * <p class='bjava'>
+ * 	<ja>@Rest</ja>(path=<js>"/api"</js>, mixins=BasicRouteIndexResource.<jk>class</jk>)
+ * 	<jk>public class</jk> ApiResource <jk>extends</jk> RestServlet {
+ * 		<ja>@RestGet</ja>(path=<js>"/items"</js>, summary=<js>"List items"</js>) <jk>public</jk> List&lt;Item&gt; items() { ... }
+ * 	}
+ * </p>
+ *
+ * <h5 class='section'>Output:</h5>
+ *
+ * <p>
+ * Each {@code @RestOp}-annotated method on the host (and on any other mixins on the host) is
+ * surfaced as a single entry; the request returns a JSON list ordered by path:
+ *
+ * <p class='bjson'>
+ * 	[
+ * 		{
+ * 			<jok>"path"</jok>: <jov>"/items"</jov>,
+ * 			<jok>"methods"</jok>: [<jov>"GET"</jov>],
+ * 			<jok>"summary"</jok>: <jov>"List items"</jov>,
+ * 			<jok>"description"</jok>: <jov>""</jov>,
+ * 			<jok>"deprecated"</jok>: <jov>false</jov>
+ * 		}
+ * 	]
+ * </p>
+ *
+ * <p>
+ * <b>Excluded entries:</b>
+ * <ul class='spaced-list'>
+ * 	<li>The route-index endpoint itself (it shouldn't echo its own listing).
+ * 	<li>Any operation marked {@link OpSwagger#ignore() @OpSwagger(ignore=true)} &mdash; consistent
+ * 		with how those operations are excluded from the OpenAPI spec by
+ * 		{@code BasicSwaggerProviderSession}. Convention endpoints (favicon, robots, version, etc.),
+ * 		static-files mixin handlers, and the sibling ops-pack endpoints all carry that annotation
+ * 		and are therefore omitted from the index, matching the audience separation: api-docs is
+ * 		for documented public API; route-index is for the same surface but in machine-readable
+ * 		form.
+ * 	<li>Lifecycle / filter beans &mdash; only methods with a {@link RestOp}-group annotation
+ * 		(GET / POST / PUT / DELETE / PATCH / OPTIONS / RestOp) are listed; {@link RestStartCall}
+ * 		/ {@link RestEndCall} / converters / matchers are not.
+ * </ul>
+ *
+ * <p>
+ * The handler itself carries {@link OpSwagger#ignore() @OpSwagger(ignore=true)} so the
+ * route-index endpoint is excluded from the OpenAPI spec.
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ * 	<li class='jc'>{@link BasicEchoResource}
+ * 	<li class='jc'>{@link BasicAdminResource}
+ * 	<li class='link'><a class="doclink" href="https://juneau.apache.org/docs/topics/RestServerComposition">REST Server &mdash; Composition (mixins, paths)</a>
+ * </ul>
+ *
+ * @since 9.5.0
+ */
+// @formatter:off
+@Rest(paths={"/options","/routes"})
+public class BasicRouteIndexResource {
+
+	private static final List<Class<? extends Annotation>> REST_OP_ANNOTATIONS = List.of(
+		RestGet.class, RestPost.class, RestPut.class, RestDelete.class,
+		RestPatch.class, RestOptions.class, RestOp.class);
+
+	/** No-arg constructor &mdash; route-index has no configurable state. */
+	public BasicRouteIndexResource() {}
+
+	/**
+	 * [GET /options | /routes] &mdash; emit the route index as a JSON list.
+	 *
+	 * @param req The current REST request &mdash; supplies the host {@link RestContext}.
+	 * @param res The current REST response.
+	 * @throws IOException If an I/O error occurs while writing the response.
+	 */
+	@RestGet(
+		path={"/options","/routes"},
+		summary="Route index",
+		description="JSON list of @RestOp-annotated methods on the host (excluding hidden / ops endpoints).",
+		swagger=@OpSwagger(ignore=true)
+	)
+	public void getRoutes(RestRequest req, RestResponse res) throws IOException {
+		var hostCtx = resolveHostContext(req.getContext());
+		var entries = collect(hostCtx);
+		try (var w = res.getDirectWriter("application/json")) {
+			JsonSerializer.DEFAULT_READABLE.serialize(entries, w);
+		}
+	}
+
+	private static RestContext resolveHostContext(RestContext c) {
+		var ctx = c;
+		while (ctx.isMixinContext() && ctx.getParentContext() != null)
+			ctx = ctx.getParentContext();
+		return ctx;
+	}
+
+	/**
+	 * Collects the route-index entries from the supplied host {@link RestContext} and any of its
+	 * registered mixin sub-contexts (test/inspection helper).
+	 *
+	 * @param hostCtx The host context. Must not be {@code null}.
+	 * @return A list of route-index entries, ordered by path.
+	 */
+	public List<Map<String,Object>> collect(RestContext hostCtx) {
+		var seen = new HashSet<Method>();
+		var entries = new ArrayList<Map<String,Object>>();
+		for (var oc : hostCtx.getRestOperations().getOpContexts())
+			addEntry(entries, seen, oc);
+		for (var mixinCtx : hostCtx.getMixinContexts().values())
+			for (var oc : mixinCtx.getRestOperations().getOpContexts())
+				addEntry(entries, seen, oc);
+		entries.sort(BasicRouteIndexResource::compareByPathThenMethod);
+		return entries;
+	}
+
+	private static void addEntry(List<Map<String,Object>> entries, Set<Method> seen, RestOpContext oc) {
+		var m = oc.getJavaMethod();
+		if (m == null || ! seen.add(m))
+			return;
+		if (isHiddenFromIndex(m))
+			return;
+		if (isSelfHandler(m))
+			return;
+		var entry = new LinkedHashMap<String,Object>();
+		entry.put("path", oc.getPathPattern());
+		entry.put("methods", List.of(oc.getHttpMethod()));
+		entry.put("summary", readSummary(m));
+		entry.put("description", readDescription(m));
+		entry.put("deprecated", m.isAnnotationPresent(Deprecated.class)
+			|| m.getDeclaringClass().isAnnotationPresent(Deprecated.class));
+		entries.add(entry);
+	}
+
+	@SuppressWarnings("java:S3776") // Cognitive-complexity: linear walk over a small annotation list; splitting hurts JIT.
+	private static boolean isHiddenFromIndex(Method m) {
+		for (var aClass : REST_OP_ANNOTATIONS) {
+			var a = m.getAnnotation(aClass);
+			if (a == null)
+				continue;
+			try {
+				var sw = aClass.getMethod("swagger").invoke(a);
+				if (sw instanceof OpSwagger os && os.ignore())
+					return true;
+			} catch (ReflectiveOperationException e) {
+				// Annotation chain shape mismatch — treat as not-hidden so we don't silently drop
+				// real entries on a future refactor.
+				return false;
+			}
+		}
+		return false;
+	}
+
+	private static boolean isSelfHandler(Method m) {
+		return m.getDeclaringClass() == BasicRouteIndexResource.class;
+	}
+
+	@SuppressWarnings("java:S3776") // Same as isHiddenFromIndex — short loop, single concern.
+	private static String readSummary(Method m) {
+		for (var aClass : REST_OP_ANNOTATIONS) {
+			var a = m.getAnnotation(aClass);
+			if (a == null)
+				continue;
+			try {
+				var s = (String) aClass.getMethod("summary").invoke(a);
+				if (s != null && ! s.isEmpty())
+					return s;
+			} catch (ReflectiveOperationException e) {
+				// fall through
+			}
+		}
+		return "";
+	}
+
+	@SuppressWarnings("java:S3776") // Same as isHiddenFromIndex — short loop, single concern.
+	private static String readDescription(Method m) {
+		for (var aClass : REST_OP_ANNOTATIONS) {
+			var a = m.getAnnotation(aClass);
+			if (a == null)
+				continue;
+			try {
+				var d = aClass.getMethod("description").invoke(a);
+				if (d instanceof String[] arr && arr.length > 0)
+					return String.join(" ", arr);
+				if (d instanceof String s && ! s.isEmpty())
+					return s;
+			} catch (ReflectiveOperationException e) {
+				// fall through
+			}
+		}
+		return "";
+	}
+
+	private static int compareByPathThenMethod(Map<String,Object> a, Map<String,Object> b) {
+		var c = String.valueOf(a.get("path")).compareTo(String.valueOf(b.get("path")));
+		if (c != 0)
+			return c;
+		return String.valueOf(a.get("methods")).compareTo(String.valueOf(b.get("methods")));
+	}
+}
diff --git a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/ops/package-info.java b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/ops/package-info.java
new file mode 100644
index 0000000000..f28f2a722b
--- /dev/null
+++ b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/ops/package-info.java
@@ -0,0 +1,88 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+/**
+ * Ops / introspection mixin pack — composable {@code @Rest(mixins=...)} resources that ship the
+ * operational surface (request echo, JVM admin, route index) every long-running Juneau service
+ * eventually grows.
+ *
+ * <p>
+ * Three sibling mixins compose into the host {@code @Rest}-annotated resource. Each mixin owns
+ * its default mount paths, ships secure-by-default (debug-gated for echo, deny-all-guard for
+ * admin), and is independently mountable; the three together drop in as a pack via
+ * {@code @Rest(mixins={BasicEchoResource.class, BasicAdminResource.class, BasicRouteIndexResource.class})}.
+ * </p>
+ *
+ * <ul class='javatreec'>
+ * 	<li class='jc'>{@link org.apache.juneau.rest.ops.BasicEchoResource} —
+ * 		{@code /echo/*} and {@code /debug/echo/*} request echo, gated behind the host's
+ * 		{@link org.apache.juneau.rest.debug.DebugEnablement DebugEnablement}; sensitive headers
+ * 		({@code Authorization}, {@code Cookie}, etc.) are redacted by default.
+ * 	<li class='jc'>{@link org.apache.juneau.rest.ops.BasicAdminResource} —
+ * 		{@code /admin/threads}, {@code /admin/heap}, {@code /admin/cache/flush} (POST), and
+ * 		{@code /admin/ratelimit}; default-deny via
+ * 		{@link org.apache.juneau.rest.guard.DenyAllGuard} until the importer registers an
+ * 		{@code @Bean RestGuardList} factory.
+ * 	<li class='jc'>{@link org.apache.juneau.rest.ops.BasicRouteIndexResource} —
+ * 		{@code /options} and {@code /routes} returning a JSON list of every
+ * 		{@code @RestOp}-annotated method on the host (plus mixins), excluding
+ * 		{@code @OpSwagger(ignore=true)} ops.
+ * </ul>
+ *
+ * <h5 class='section'>Composition example:</h5>
+ *
+ * <p class='bjava'>
+ * 	<ja>@Rest</ja>(
+ * 		path=<js>"/api"</js>,
+ * 		mixins={
+ * 			BasicEchoResource.<jk>class</jk>,
+ * 			BasicAdminResource.<jk>class</jk>,
+ * 			BasicRouteIndexResource.<jk>class</jk>
+ * 		},
+ * 		debug=<js>"conditional"</js>            <jc>// gates BasicEchoResource per-request</jc>
+ * 	)
+ * 	<jk>public class</jk> ApiResource <jk>extends</jk> RestServlet {
+ *
+ * 		<jc>// Required: register an auth guard chain; replaces the deny-all default.</jc>
+ * 		<ja>@Bean</ja>(name=<js>"guards"</js>)
+ * 		<jk>public</jk> RestGuardList guards(BeanStore <jv>bs</jv>) {
+ * 			<jk>return</jk> RestGuardList.<jsm>create</jsm>(<jv>bs</jv>)
+ * 				.append(<jk>new</jk> MyAuthGuard())
+ * 				.build();
+ * 		}
+ *
+ * 		<ja>@Bean</ja> BasicAdminResource admin() {
+ * 			<jk>return</jk> BasicAdminResource.<jsm>create</jsm>()
+ * 				.cacheFlush(<js>"primary"</js>, () -&gt; primaryCache.invalidateAll())
+ * 				.build();
+ * 		}
+ * 	}
+ * </p>
+ *
+ * <p>
+ * All endpoints carry
+ * {@link org.apache.juneau.rest.annotation.OpSwagger#ignore() @OpSwagger(ignore=true)} —
+ * ops endpoints are not API-meaningful and are excluded from any generated Swagger / OpenAPI
+ * spec.
+ * </p>
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ * 	<li class='link'><a class="doclink" href="https://juneau.apache.org/docs/topics/RestServerComposition">REST Server — Composition (mixins, paths)</a>
+ * </ul>
+ *
+ * @since 9.5.0
+ */
+package org.apache.juneau.rest.ops;
diff --git a/juneau-utest/src/test/java/org/apache/juneau/rest/ops/BasicAdminResource_AsMixin_Test.java b/juneau-utest/src/test/java/org/apache/juneau/rest/ops/BasicAdminResource_AsMixin_Test.java
new file mode 100644
index 0000000000..c1a360b0a2
--- /dev/null
+++ b/juneau-utest/src/test/java/org/apache/juneau/rest/ops/BasicAdminResource_AsMixin_Test.java
@@ -0,0 +1,337 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.ops;
+
+import java.util.*;
+import java.util.concurrent.atomic.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.commons.inject.*;
+import org.apache.juneau.json.*;
+import org.apache.juneau.rest.*;
+import org.apache.juneau.rest.annotation.*;
+import org.apache.juneau.rest.guard.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.apache.juneau.rest.servlet.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Validates {@link BasicAdminResource} mounted as a mixin via {@code @Rest(mixins=...)} on a
+ * vanilla {@link RestServlet}.
+ *
+ * <p>
+ * Cases:
+ * <ul>
+ * 	<li>Default-deny: {@link DenyAllGuard} returns {@code 403 Forbidden} on every admin path until
+ * 		the host registers a {@code @Bean RestGuardList}.
+ * 	<li>{@code GET /admin/threads} returns a JSON list with at least the JUnit test thread.
+ * 	<li>{@code GET /admin/heap} returns a JSON map with {@code heap.total / free / max / used}.
+ * 	<li>{@code POST /admin/cache/flush} runs all registered hooks (no {@code names}) or just the
+ * 		named subset.
+ * 	<li>{@code GET /admin/ratelimit} returns {@code 404} when no {@link RateLimitGuard} bean is
+ * 		registered, and a populated map when one is.
+ * 	<li>Builder-time validation rejects null/blank cache-flush names and null hooks.
+ * </ul>
+ *
+ * @since 9.5.0
+ */
+class BasicAdminResource_AsMixin_Test extends TestBase {
+
+	// -----------------------------------------------------------------------------------------
+	// Default-deny posture (no host-supplied @Bean RestGuardList).
+	// -----------------------------------------------------------------------------------------
+
+	@Rest(mixins=BasicAdminResource.class)
+	public static class A extends RestServlet {
+		private static final long serialVersionUID = 1L;
+		@RestGet(path="/items") public String items() { return "items"; }
+	}
+
+	private static final MockRestClient ca = MockRestClient.buildLax(A.class);
+
+	@Test void a01_threadsDeniedByDefault() throws Exception {
+		ca.get("/admin/threads").run().assertStatus(403);
+	}
+
+	@Test void a02_heapDeniedByDefault() throws Exception {
+		ca.get("/admin/heap").run().assertStatus(403);
+	}
+
+	@Test void a03_cacheFlushDeniedByDefault() throws Exception {
+		ca.post("/admin/cache/flush", "").run().assertStatus(403);
+	}
+
+	@Test void a04_rateLimitDeniedByDefault() throws Exception {
+		ca.get("/admin/ratelimit").run().assertStatus(403);
+	}
+
+	@Test void a05_hostEndpointStillReachable() throws Exception {
+		ca.get("/items").run().assertStatus(200).assertContent().asString().isContains("items");
+	}
+
+	// -----------------------------------------------------------------------------------------
+	// Allow-all RestGuardList override (replaces DenyAllGuard).
+	// -----------------------------------------------------------------------------------------
+
+	/** Allow-all guard chain — replaces the mixin's annotation-derived deny-all. */
+	@Rest(mixins=BasicAdminResource.class)
+	public static class B extends RestServlet {
+		private static final long serialVersionUID = 1L;
+		@RestGet(path="/items") public String items() { return "items"; }
+
+		@Bean public RestGuardList guards(BeanStore bs) {
+			return RestGuardList.create(bs).build();
+		}
+
+		@Bean public BasicAdminResource admin() {
+			return BasicAdminResource.create()
+				.cacheFlush("primary", FLUSH_PRIMARY)
+				.cacheFlush("secondary", FLUSH_SECONDARY)
+				.threadNamePrefixExclude()  // disable filtering to make /admin/threads deterministic
+				.build();
+		}
+	}
+
+	static final AtomicInteger PRIMARY_INVOCATIONS = new AtomicInteger();
+	static final AtomicInteger SECONDARY_INVOCATIONS = new AtomicInteger();
+	static final Runnable FLUSH_PRIMARY = PRIMARY_INVOCATIONS::incrementAndGet;
+	static final Runnable FLUSH_SECONDARY = SECONDARY_INVOCATIONS::incrementAndGet;
+
+	private static final MockRestClient cb = MockRestClient.buildLax(B.class);
+
+	@BeforeEach void resetCounters() {
+		PRIMARY_INVOCATIONS.set(0);
+		SECONDARY_INVOCATIONS.set(0);
+	}
+
+	@Test void b01_threadsServesJsonList() throws Exception {
+		var body = cb.get("/admin/threads")
+			.run()
+			.assertStatus(200)
+			.assertHeader("Content-Type").isContains("application/json")
+			.getContent().asString();
+		var parsed = JsonParser.DEFAULT.parse(body, List.class);
+		Assertions.assertFalse(parsed.isEmpty(), "thread list should not be empty");
+		var first = (Map<?,?>) parsed.get(0);
+		Assertions.assertNotNull(first.get("name"));
+		Assertions.assertNotNull(first.get("state"));
+		Assertions.assertNotNull(first.get("stack"));
+	}
+
+	@Test void b02_heapServesJsonMap() throws Exception {
+		var body = cb.get("/admin/heap")
+			.run()
+			.assertStatus(200)
+			.assertHeader("Content-Type").isContains("application/json")
+			.getContent().asString();
+		var parsed = JsonParser.DEFAULT.parse(body, Map.class);
+		var heap = (Map<?,?>) parsed.get("heap");
+		Assertions.assertNotNull(heap.get("total"));
+		Assertions.assertNotNull(heap.get("free"));
+		Assertions.assertNotNull(heap.get("max"));
+		Assertions.assertNotNull(heap.get("used"));
+		var nonHeap = (Map<?,?>) parsed.get("nonHeap");
+		Assertions.assertNotNull(nonHeap.get("used"));
+	}
+
+	@Test void b03_cacheFlushAllRunsEveryHook() throws Exception {
+		cb.post("/admin/cache/flush", "")
+			.run()
+			.assertStatus(200)
+			.assertContent().asString().isContains("\"executed\"");
+		Assertions.assertEquals(1, PRIMARY_INVOCATIONS.get(), "primary should run once");
+		Assertions.assertEquals(1, SECONDARY_INVOCATIONS.get(), "secondary should run once");
+	}
+
+	@Test void b04_cacheFlushNamesRunsSubsetOnly() throws Exception {
+		cb.post("/admin/cache/flush?names=primary", "")
+			.run()
+			.assertStatus(200);
+		Assertions.assertEquals(1, PRIMARY_INVOCATIONS.get(), "primary should run");
+		Assertions.assertEquals(0, SECONDARY_INVOCATIONS.get(), "secondary should NOT run");
+	}
+
+	@Test void b05_cacheFlushNamesUnknownIsSilentlyIgnored() throws Exception {
+		var body = cb.post("/admin/cache/flush?names=primary,unknown,secondary", "")
+			.run()
+			.assertStatus(200)
+			.getContent().asString();
+		Assertions.assertTrue(body.contains("primary"));
+		Assertions.assertTrue(body.contains("secondary"));
+		Assertions.assertEquals(1, PRIMARY_INVOCATIONS.get());
+		Assertions.assertEquals(1, SECONDARY_INVOCATIONS.get());
+	}
+
+	@Test void b06_rateLimit404WhenNoGuardRegistered() throws Exception {
+		cb.get("/admin/ratelimit").run().assertStatus(404);
+	}
+
+	@Test void b07_hostEndpointStillReachable() throws Exception {
+		cb.get("/items").run().assertStatus(200).assertContent().asString().isContains("items");
+	}
+
+	// -----------------------------------------------------------------------------------------
+	// Allow-all guard list + a registered RateLimitGuard bean.
+	// -----------------------------------------------------------------------------------------
+
+	@Rest(mixins=BasicAdminResource.class)
+	public static class C extends RestServlet {
+		private static final long serialVersionUID = 1L;
+
+		@Bean public RestGuardList guards(BeanStore bs) {
+			return RestGuardList.create(bs).build();
+		}
+
+		@Bean public RateLimitGuard rateLimit() {
+			return RateLimitGuard.create().build();
+		}
+	}
+
+	private static final MockRestClient cc = MockRestClient.buildLax(C.class);
+
+	@Test void c01_rateLimitListsRegisteredGuard() throws Exception {
+		var body = cc.get("/admin/ratelimit")
+			.run()
+			.assertStatus(200)
+			.getContent().asString();
+		var parsed = JsonParser.DEFAULT.parse(body, Map.class);
+		var guardsMap = (Map<?,?>) parsed.get("guards");
+		Assertions.assertEquals(1, guardsMap.size(), "expected one entry; got: " + guardsMap.keySet());
+		var first = (Map<?,?>) guardsMap.values().iterator().next();
+		Assertions.assertNotNull(first.get("config"));
+	}
+
+	// -----------------------------------------------------------------------------------------
+	// Builder-time validation.
+	// -----------------------------------------------------------------------------------------
+
+	@Test void d01_builderRejectsBlankCacheFlushName() {
+		Assertions.assertThrows(IllegalArgumentException.class,
+			() -> BasicAdminResource.create().cacheFlush("", () -> {}));
+		Assertions.assertThrows(IllegalArgumentException.class,
+			() -> BasicAdminResource.create().cacheFlush(null, () -> {}));
+	}
+
+	@Test void d02_builderRejectsNullHook() {
+		Assertions.assertThrows(IllegalArgumentException.class,
+			() -> BasicAdminResource.create().cacheFlush("foo", null));
+	}
+
+	@Test void d03_cacheFlushAllRoundTrips() {
+		Map<String,Runnable> hooks = new LinkedHashMap<>();
+		var r1 = (Runnable) () -> {};
+		var r2 = (Runnable) () -> {};
+		hooks.put("a", r1);
+		hooks.put("b", r2);
+		var admin = BasicAdminResource.create().cacheFlushAll(hooks).build();
+		Assertions.assertEquals(2, admin.getCacheFlushHooks().size());
+		Assertions.assertSame(r1, admin.getCacheFlushHooks().get("a"));
+		Assertions.assertSame(r2, admin.getCacheFlushHooks().get("b"));
+	}
+
+	@Test void d04_threadNameExcludeReplaceList() {
+		var admin = BasicAdminResource.create().threadNamePrefixExclude("foo-", "bar-").build();
+		Assertions.assertEquals(List.of("foo-", "bar-"), admin.getThreadNamePrefixExclude());
+	}
+
+	@Test void d05_defaultExcludeListContainsKnownNoise() {
+		Assertions.assertTrue(BasicAdminResource.DEFAULT_THREAD_NAME_PREFIX_EXCLUDE.contains("Reference Handler"));
+		Assertions.assertTrue(BasicAdminResource.DEFAULT_THREAD_NAME_PREFIX_EXCLUDE.contains("jetty-"));
+	}
+
+	@Test void d06_noArgConstructorMatchesEmptyHooks() {
+		var r = new BasicAdminResource();
+		Assertions.assertTrue(r.getCacheFlushHooks().isEmpty());
+		Assertions.assertEquals(BasicAdminResource.DEFAULT_THREAD_NAME_PREFIX_EXCLUDE,
+			r.getThreadNamePrefixExclude());
+	}
+
+	@Test void d07_denyAllGuardRejects() throws Exception {
+		var g = new DenyAllGuard();
+		Assertions.assertFalse(g.isRequestAllowed((RestRequest) null),
+			"DenyAllGuard must always reject");
+	}
+
+	@Test void d08_cacheFlushAllNullIsNoOp() {
+		var admin = BasicAdminResource.create().cacheFlushAll(null).build();
+		Assertions.assertTrue(admin.getCacheFlushHooks().isEmpty());
+	}
+
+	@Test void d09_threadNamePrefixExcludeNullClearsList() {
+		var admin = BasicAdminResource.create().threadNamePrefixExclude((String[]) null).build();
+		Assertions.assertTrue(admin.getThreadNamePrefixExclude().isEmpty(),
+			"null varargs should clear the list");
+	}
+
+	@Test void d10_threadNamePrefixExcludeFiltersNullAndEmpty() {
+		var admin = BasicAdminResource.create().threadNamePrefixExclude("good-", null, "", "also-").build();
+		Assertions.assertEquals(List.of("good-", "also-"), admin.getThreadNamePrefixExclude());
+	}
+
+	// -----------------------------------------------------------------------------------------
+	// Thread filtering hits an actual matching prefix (covers the exclusion branch in
+	// isExcludedThread when the configured filter list catches a real thread).
+	// -----------------------------------------------------------------------------------------
+
+	@Rest(mixins=BasicAdminResource.class)
+	public static class F extends RestServlet {
+		private static final long serialVersionUID = 1L;
+
+		@Bean public RestGuardList guards(BeanStore bs) { return RestGuardList.create(bs).build(); }
+
+		// Prefix that will match the JUnit launcher / test thread on every supported JVM.
+		@Bean public BasicAdminResource admin() {
+			return BasicAdminResource.create()
+				.threadNamePrefixExclude("ForkJoinPool", "main", "junit-")
+				.build();
+		}
+	}
+
+	private static final MockRestClient cf = MockRestClient.buildLax(F.class);
+
+	@Test void f01_threadFilterDropsMatchedPrefixes() throws Exception {
+		var body = cf.get("/admin/threads")
+			.run()
+			.assertStatus(200)
+			.getContent().asString();
+		// We don't assert on specific thread names — just confirm the endpoint serves and
+		// that the JSON is a valid list. The branch coverage on isExcludedThread is what we
+		// actually care about (the prefix-match true branch).
+		Assertions.assertTrue(body.startsWith("["), "expected JSON array; body: " + body);
+	}
+
+	// -----------------------------------------------------------------------------------------
+	// cacheFlush with names= containing blank entries (covers the n.isBlank() filter branch).
+	// -----------------------------------------------------------------------------------------
+
+	@Test void g01_cacheFlushNamesBlankSegmentsIgnored() throws Exception {
+		cb.post("/admin/cache/flush?names=,primary,,", "")
+			.run()
+			.assertStatus(200);
+		Assertions.assertEquals(1, PRIMARY_INVOCATIONS.get());
+		Assertions.assertEquals(0, SECONDARY_INVOCATIONS.get());
+	}
+
+	@Test void g02_cacheFlushNamesParamBlankAllRunAll() throws Exception {
+		cb.post("/admin/cache/flush?names=", "")
+			.run()
+			.assertStatus(200);
+		// names=<blank> → namesParam.isBlank() → fall-through to "no filter, run everything"
+		Assertions.assertEquals(1, PRIMARY_INVOCATIONS.get());
+		Assertions.assertEquals(1, SECONDARY_INVOCATIONS.get());
+	}
+}
diff --git a/juneau-utest/src/test/java/org/apache/juneau/rest/ops/BasicEchoResource_AsMixin_Test.java b/juneau-utest/src/test/java/org/apache/juneau/rest/ops/BasicEchoResource_AsMixin_Test.java
new file mode 100644
index 0000000000..13dd94c7fc
--- /dev/null
+++ b/juneau-utest/src/test/java/org/apache/juneau/rest/ops/BasicEchoResource_AsMixin_Test.java
@@ -0,0 +1,326 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.ops;
+
+import java.util.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.commons.inject.*;
+import org.apache.juneau.json.*;
+import org.apache.juneau.rest.annotation.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.apache.juneau.rest.servlet.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Validates {@link BasicEchoResource} mounted as a mixin via {@code @Rest(mixins=...)} on a vanilla
+ * {@link RestServlet}.
+ *
+ * <p>
+ * Cases covered:
+ * <ul>
+ * 	<li>Default deny &mdash; no {@code @Rest(debug)} on the host returns {@code 404} from
+ * 		{@code /echo/*} so the endpoint's existence isn't disclosed.
+ * 	<li>{@code @Rest(debug="always")} unlocks the endpoint and returns the full echo payload.
+ * 	<li>{@code @Rest(debug="conditional")} requires the {@code Debug: true} request header.
+ * 	<li>Sensitive headers ({@code Authorization}, {@code Cookie}) are redacted by default.
+ * 	<li>Importer's {@code @Bean BasicEchoResource} factory drives the body cap and redact list.
+ * 	<li>Body capture truncates correctly when the inbound body exceeds the configured cap.
+ * 	<li>Path remainder, query string, and query params are surfaced.
+ * 	<li>The handler dispatches on {@code @RestOp(method="*")} &mdash; POST and PUT both work.
+ * 	<li>The host's own endpoints remain reachable.
+ * </ul>
+ *
+ * @since 9.5.0
+ */
+class BasicEchoResource_AsMixin_Test extends TestBase {
+
+	/** Default-host mounting the mixin without {@code @Rest(debug)}. */
+	@Rest(mixins=BasicEchoResource.class)
+	public static class A extends RestServlet {
+		private static final long serialVersionUID = 1L;
+		@RestGet(path="/items") public String items() { return "items"; }
+	}
+
+	private static final MockRestClient ca = MockRestClient.buildLax(A.class);
+
+	@Test void a01_echoReturns404WhenDebugDisabled() throws Exception {
+		ca.get("/echo/anything").run().assertStatus(404);
+	}
+
+	@Test void a02_debugEchoReturns404WhenDebugDisabled() throws Exception {
+		ca.get("/debug/echo/something").run().assertStatus(404);
+	}
+
+	@Test void a03_hostEndpointStillReachable() throws Exception {
+		ca.get("/items").run().assertStatus(200).assertContent().asString().isContains("items");
+	}
+
+	/** Host with debug always-on so the echo endpoint serves. */
+	@Rest(mixins=BasicEchoResource.class, debug="always")
+	public static class B extends RestServlet {
+		private static final long serialVersionUID = 1L;
+		@RestGet(path="/items") public String items() { return "items"; }
+	}
+
+	private static final MockRestClient cb = MockRestClient.buildLax(B.class);
+
+	@Test void b01_echoServesFullPayload() throws Exception {
+		var body = cb.get("/echo/foo/bar?x=1&y=hello")
+			.run()
+			.assertStatus(200)
+			.assertHeader("Content-Type").isContains("application/json")
+			.getContent().asString();
+		var parsed = JsonParser.DEFAULT.parse(body, Map.class);
+		Assertions.assertEquals("GET", parsed.get("method"));
+		Assertions.assertTrue(((String) parsed.get("path")).endsWith("/echo/foo/bar"),
+			"path should end with /echo/foo/bar; got: " + parsed.get("path"));
+		Assertions.assertEquals("x=1&y=hello", parsed.get("queryString"));
+		Assertions.assertEquals("foo/bar", parsed.get("pathRemainder"));
+		Assertions.assertEquals(Boolean.FALSE, parsed.get("truncated"));
+		var qp = (Map<?,?>) parsed.get("queryParams");
+		Assertions.assertEquals("1", qp.get("x"));
+		Assertions.assertEquals("hello", qp.get("y"));
+	}
+
+	@Test void b02_debugEchoAlsoServesAtAlternateMount() throws Exception {
+		cb.get("/debug/echo/abc")
+			.run()
+			.assertStatus(200)
+			.assertContent().asString().isContains("\"pathRemainder\": \"abc\"");
+	}
+
+	@Test void b03_authorizationHeaderRedacted() throws Exception {
+		var body = cb.get("/echo/")
+			.header("Authorization", "Bearer secret-token")
+			.run()
+			.assertStatus(200)
+			.getContent().asString();
+		Assertions.assertFalse(body.contains("Bearer secret-token"),
+			"Authorization value must NEVER be reflected back; body was: " + body);
+		Assertions.assertTrue(body.contains(BasicEchoResource.REDACTED),
+			"Redaction sentinel should appear; body was: " + body);
+	}
+
+	@Test void b04_cookieHeaderRedacted() throws Exception {
+		var body = cb.get("/echo/")
+			.header("Cookie", "JSESSIONID=DEADBEEF")
+			.run()
+			.assertStatus(200)
+			.getContent().asString();
+		Assertions.assertFalse(body.contains("DEADBEEF"),
+			"Cookie value must NEVER be reflected back; body was: " + body);
+	}
+
+	@Test void b05_postEchoesBody() throws Exception {
+		var body = cb.post("/echo/posting", "hello-world")
+			.run()
+			.assertStatus(200)
+			.getContent().asString();
+		var parsed = JsonParser.DEFAULT.parse(body, Map.class);
+		Assertions.assertEquals("POST", parsed.get("method"));
+		Assertions.assertEquals("hello-world", parsed.get("content"));
+		Assertions.assertEquals(11L, ((Number) parsed.get("contentLength")).longValue());
+		Assertions.assertEquals(Boolean.FALSE, parsed.get("truncated"));
+	}
+
+	@Test void b06_putAlsoDispatchesViaWildcardMethod() throws Exception {
+		cb.put("/echo/x", "payload")
+			.run()
+			.assertStatus(200)
+			.assertContent().asString().isContains("\"method\": \"PUT\"");
+	}
+
+	/** Host with conditional debug — requires {@code Debug: true} request header to unlock echo. */
+	@Rest(mixins=BasicEchoResource.class, debug="conditional")
+	public static class C extends RestServlet {
+		private static final long serialVersionUID = 1L;
+	}
+
+	private static final MockRestClient cc = MockRestClient.buildLax(C.class);
+
+	@Test void c01_conditional_withoutDebugHeaderReturns404() throws Exception {
+		cc.get("/echo/anything").run().assertStatus(404);
+	}
+
+	@Test void c02_conditional_withDebugHeaderUnlocks() throws Exception {
+		cc.get("/echo/anything")
+			.header("Debug", "true")
+			.run()
+			.assertStatus(200)
+			.assertContent().asString().isContains("\"pathRemainder\": \"anything\"");
+	}
+
+	/** Host with a custom redact list and a tight body cap via @Bean factory. */
+	@Rest(mixins=BasicEchoResource.class, debug="always")
+	public static class D extends RestServlet {
+		private static final long serialVersionUID = 1L;
+		@Bean public BasicEchoResource echo() {
+			return BasicEchoResource.create()
+				.bodyLimit(8L)
+				.redactHeader("X-Internal-Trace")
+				.build();
+		}
+	}
+
+	private static final MockRestClient cd = MockRestClient.buildLax(D.class);
+
+	@Test void d01_customRedactHeaderHonored() throws Exception {
+		var body = cd.get("/echo/")
+			.header("X-Internal-Trace", "abc-trace-id")
+			.run()
+			.assertStatus(200)
+			.getContent().asString();
+		Assertions.assertFalse(body.contains("abc-trace-id"),
+			"Custom-redacted header value must NOT be reflected back; body was: " + body);
+		Assertions.assertTrue(body.contains(BasicEchoResource.REDACTED));
+	}
+
+	@Test void d02_authorizationStillRedactedAfterAddingCustom() throws Exception {
+		var body = cd.get("/echo/")
+			.header("Authorization", "Bearer secret-token")
+			.run()
+			.assertStatus(200)
+			.getContent().asString();
+		Assertions.assertFalse(body.contains("secret-token"),
+			"Built-in default redactions must remain in place when redactHeader(...) is called; body was: "
+				+ body);
+	}
+
+	@Test void d03_bodyCapTruncates() throws Exception {
+		var body = cd.post("/echo/", "0123456789ABCDEF")
+			.run()
+			.assertStatus(200)
+			.getContent().asString();
+		var parsed = JsonParser.DEFAULT.parse(body, Map.class);
+		Assertions.assertEquals(Boolean.TRUE, parsed.get("truncated"),
+			"Body must be flagged truncated when it exceeds the cap; body was: " + body);
+		Assertions.assertEquals(8L, ((Number) parsed.get("contentLength")).longValue());
+		Assertions.assertEquals("01234567", parsed.get("content"));
+	}
+
+	/** Host with a zero body cap — every non-empty body truncates immediately. */
+	@Rest(mixins=BasicEchoResource.class, debug="always")
+	public static class G extends RestServlet {
+		private static final long serialVersionUID = 1L;
+		@Bean public BasicEchoResource echo() {
+			return BasicEchoResource.create().bodyLimit(0L).build();
+		}
+	}
+
+	private static final MockRestClient cg = MockRestClient.buildLax(G.class);
+
+	@Test void g01_zeroBodyLimitTruncatesNonEmptyBody() throws Exception {
+		var body = cg.post("/echo/", "ANY")
+			.run()
+			.assertStatus(200)
+			.getContent().asString();
+		var parsed = JsonParser.DEFAULT.parse(body, Map.class);
+		Assertions.assertEquals(Boolean.TRUE, parsed.get("truncated"),
+			"zero cap → truncated; body was: " + body);
+		Assertions.assertEquals(0L, ((Number) parsed.get("contentLength")).longValue());
+	}
+
+	/** Host with a redactedHeaders(...) replace-list that disables built-in defaults. */
+	@Rest(mixins=BasicEchoResource.class, debug="always")
+	public static class E extends RestServlet {
+		private static final long serialVersionUID = 1L;
+		@Bean public BasicEchoResource echo() {
+			return BasicEchoResource.create()
+				.redactedHeaders("X-Custom-Only")
+				.build();
+		}
+	}
+
+	private static final MockRestClient ce = MockRestClient.buildLax(E.class);
+
+	@Test void e01_replaceListDropsBuiltInRedactions() throws Exception {
+		var body = ce.get("/echo/")
+			.header("Authorization", "Bearer not-redacted-here")
+			.run()
+			.assertStatus(200)
+			.getContent().asString();
+		Assertions.assertTrue(body.contains("Bearer not-redacted-here"),
+			"redactedHeaders(...) replace-list should drop built-in defaults; body was: " + body);
+	}
+
+	@Test void e02_replaceListRedactsCustomHeader() throws Exception {
+		var body = ce.get("/echo/")
+			.header("X-Custom-Only", "secret-custom")
+			.run()
+			.assertStatus(200)
+			.getContent().asString();
+		Assertions.assertFalse(body.contains("secret-custom"),
+			"X-Custom-Only must be redacted; body was: " + body);
+	}
+
+	// -----------------------------------------------------------------------------------------
+	// Builder-only tests (no MockRest involvement).
+	// -----------------------------------------------------------------------------------------
+
+	@Test void f01_builderRejectsNegativeBodyLimit() {
+		Assertions.assertThrows(IllegalArgumentException.class,
+			() -> BasicEchoResource.create().bodyLimit(-1L));
+	}
+
+	@Test void f02_builderRejectsBlankRedactHeader() {
+		Assertions.assertThrows(IllegalArgumentException.class,
+			() -> BasicEchoResource.create().redactHeader(""));
+		Assertions.assertThrows(IllegalArgumentException.class,
+			() -> BasicEchoResource.create().redactHeader(null));
+	}
+
+	@Test void f03_redactedHeadersLowerIsImmutable() {
+		var r = BasicEchoResource.create().build();
+		Assertions.assertThrows(UnsupportedOperationException.class,
+			() -> r.getRedactedHeadersLower().add("X-Test"));
+	}
+
+	@Test void f04_defaultsExposedAsConstants() {
+		Assertions.assertEquals(1_048_576L, BasicEchoResource.DEFAULT_BODY_LIMIT);
+		Assertions.assertTrue(BasicEchoResource.DEFAULT_REDACTED_HEADERS.contains("Authorization"));
+		Assertions.assertTrue(BasicEchoResource.DEFAULT_REDACTED_HEADERS.contains("Cookie"));
+	}
+
+	@Test void f05_noArgConstructorMatchesDefaultBuilder() {
+		var r = new BasicEchoResource();
+		Assertions.assertEquals(BasicEchoResource.DEFAULT_BODY_LIMIT, r.getBodyLimit());
+		Assertions.assertTrue(r.getRedactedHeadersLower().contains("authorization"));
+	}
+
+	@Test void f06_redactedHeadersFiltersNullAndBlank() {
+		var r = BasicEchoResource.create()
+			.redactedHeaders("X-Keep", null, "", "  ", "X-Also")
+			.build();
+		var s = r.getRedactedHeadersLower();
+		Assertions.assertEquals(2, s.size(), "expected 2 entries; got: " + s);
+		Assertions.assertTrue(s.contains("x-keep"));
+		Assertions.assertTrue(s.contains("x-also"));
+	}
+
+	@Test void f07_redactedHeadersNullVarargsClears() {
+		var r = BasicEchoResource.create().redactedHeaders((String[]) null).build();
+		Assertions.assertTrue(r.getRedactedHeadersLower().isEmpty(),
+			"null varargs should clear the redact list");
+	}
+
+	@Test void f08_zeroBodyLimitTruncatesEverything() throws Exception {
+		// Builder accepts 0; the handler then truncates any non-empty body.
+		var b = BasicEchoResource.create().bodyLimit(0L).build();
+		Assertions.assertEquals(0L, b.getBodyLimit());
+	}
+}
diff --git a/juneau-utest/src/test/java/org/apache/juneau/rest/ops/BasicEchoResource_JettyMicroservice_Test.java b/juneau-utest/src/test/java/org/apache/juneau/rest/ops/BasicEchoResource_JettyMicroservice_Test.java
new file mode 100644
index 0000000000..d4b0a25a0a
--- /dev/null
+++ b/juneau-utest/src/test/java/org/apache/juneau/rest/ops/BasicEchoResource_JettyMicroservice_Test.java
@@ -0,0 +1,133 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.ops;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.net.*;
+import java.net.http.*;
+import java.net.http.HttpResponse.*;
+import java.time.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.commons.inject.*;
+import org.apache.juneau.microservice.*;
+import org.apache.juneau.rest.annotation.*;
+import org.apache.juneau.rest.servlet.*;
+import org.junit.jupiter.api.*;
+import org.junit.jupiter.api.extension.*;
+
+import jakarta.servlet.*;
+
+/**
+ * Real-Jetty deployment-parity assertion for {@link BasicEchoResource}.
+ *
+ * <p>
+ * Boots a {@link org.apache.juneau.microservice.Microservice Microservice} backed by
+ * {@link org.apache.juneau.microservice.jetty.JettyConfiguration JettyConfiguration} on an
+ * ephemeral port via {@link MicroserviceTestFixture}, mounts a vanilla {@link RestServlet} host
+ * with the echo mixin, and hits {@code /echo/*} and {@code /debug/echo/*} over real HTTP.
+ *
+ * <p>
+ * Catches things {@code MockRest} cannot:
+ * <ul>
+ * 	<li>Real {@code Content-Type: application/json} negotiation through the Jetty/servlet stack.
+ * 	<li>{@code @Rest(debug="always")} resolving end-to-end and unlocking the echo through the
+ * 		mixin sub-context's {@link org.apache.juneau.rest.debug.DebugEnablement DebugEnablement}.
+ * 	<li>Sensitive-header redaction surviving the network stack &mdash; an {@code Authorization}
+ * 		header sent over real HTTP must NEVER be reflected back in the response body.
+ * </ul>
+ *
+ * @since 9.5.0
+ */
+class BasicEchoResource_JettyMicroservice_Test extends TestBase {
+
+	@Rest(mixins=BasicEchoResource.class, debug="always")
+	public static class Host extends RestServlet {
+		private static final long serialVersionUID = 1L;
+		@Bean public BasicEchoResource echo() {
+			return BasicEchoResource.create()
+				.bodyLimit(1024L)
+				.build();
+		}
+	}
+
+	@Configuration
+	public static class HostConfig {
+		@Bean public Servlet hostServlet() { return new Host(); }
+	}
+
+	@RegisterExtension
+	static MicroserviceTestFixture fixture = MicroserviceTestFixture.create()
+		.configurations(HostConfig.class);
+
+	private static final HttpClient HTTP = HttpClient.newBuilder()
+		.connectTimeout(Duration.ofSeconds(5))
+		.followRedirects(HttpClient.Redirect.NEVER)
+		.build();
+
+	private static HttpResponse<String> get(String path, String...headers) throws Exception {
+		var b = HttpRequest.newBuilder()
+			.uri(URI.create(fixture.getRootUrl() + path))
+			.timeout(Duration.ofSeconds(10))
+			.GET();
+		for (var i = 0; i < headers.length; i += 2)
+			b.header(headers[i], headers[i + 1]);
+		return HTTP.send(b.build(), BodyHandlers.ofString());
+	}
+
+	private static HttpResponse<String> post(String path, String body, String...headers) throws Exception {
+		var b = HttpRequest.newBuilder()
+			.uri(URI.create(fixture.getRootUrl() + path))
+			.timeout(Duration.ofSeconds(10))
+			.POST(HttpRequest.BodyPublishers.ofString(body));
+		for (var i = 0; i < headers.length; i += 2)
+			b.header(headers[i], headers[i + 1]);
+		return HTTP.send(b.build(), BodyHandlers.ofString());
+	}
+
+	@Test void a01_echoOverRealHttp() throws Exception {
+		var resp = get("/echo/jetty/path?q=1");
+		assertEquals(200, resp.statusCode());
+		assertTrue(resp.body().contains("\"method\": \"GET\""), "method surfaced: " + resp.body());
+		assertTrue(resp.body().contains("\"pathRemainder\": \"jetty/path\""),
+			"path remainder surfaced: " + resp.body());
+		var ct = resp.headers().firstValue("Content-Type").orElse("");
+		assertTrue(ct.startsWith("application/json"), "Content-Type was: " + ct);
+	}
+
+	@Test void a02_authorizationRedactedOverRealHttp() throws Exception {
+		var resp = get("/echo/", "Authorization", "Bearer real-network-secret");
+		assertEquals(200, resp.statusCode());
+		assertFalse(resp.body().contains("real-network-secret"),
+			"Authorization secret must NEVER cross back; body: " + resp.body());
+		assertTrue(resp.body().contains(BasicEchoResource.REDACTED));
+	}
+
+	@Test void a03_debugEchoMountAlsoServes() throws Exception {
+		var resp = get("/debug/echo/abc");
+		assertEquals(200, resp.statusCode());
+		assertTrue(resp.body().contains("\"pathRemainder\": \"abc\""), "body: " + resp.body());
+	}
+
+	@Test void a04_postEchoesBody() throws Exception {
+		var resp = post("/echo/post", "real-payload");
+		assertEquals(200, resp.statusCode());
+		assertTrue(resp.body().contains("\"method\": \"POST\""), "body: " + resp.body());
+		assertTrue(resp.body().contains("\"content\": \"real-payload\""), "body: " + resp.body());
+	}
+}
diff --git a/juneau-utest/src/test/java/org/apache/juneau/rest/ops/BasicEchoResource_Springboot_Test.java b/juneau-utest/src/test/java/org/apache/juneau/rest/ops/BasicEchoResource_Springboot_Test.java
new file mode 100644
index 0000000000..9e8f71587b
--- /dev/null
+++ b/juneau-utest/src/test/java/org/apache/juneau/rest/ops/BasicEchoResource_Springboot_Test.java
@@ -0,0 +1,120 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.ops;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.net.*;
+import java.net.http.*;
+import java.net.http.HttpResponse.*;
+import java.time.*;
+
+import org.apache.juneau.rest.annotation.*;
+import org.apache.juneau.rest.springboot.*;
+import org.junit.jupiter.api.*;
+import org.springframework.boot.*;
+import org.springframework.boot.autoconfigure.*;
+import org.springframework.boot.test.context.*;
+import org.springframework.boot.test.context.SpringBootTest.*;
+import org.springframework.boot.test.web.server.*;
+import org.springframework.boot.web.servlet.*;
+import org.springframework.context.annotation.*;
+import org.springframework.test.annotation.*;
+
+/**
+ * Real-Spring-Boot deployment-parity assertion for {@link BasicEchoResource}.
+ *
+ * <p>
+ * Boots a full Spring Boot context with embedded Tomcat on a random port, registers a
+ * {@link BasicSpringRestServlet}-based host with the echo mixin via
+ * {@link ServletRegistrationBean}, supplies a Spring {@code @Bean BasicEchoResource}, and hits
+ * {@code /echo/*} over real HTTP.
+ *
+ * <p>
+ * Catches things {@code MockRest} and the Jetty parity test cannot:
+ * <ul>
+ * 	<li>Spring's bean store adapter ({@code SpringBeanStore}) resolving the host's
+ * 		{@code @Bean BasicEchoResource} during the FINISHED-72 mixin walk through
+ * 		{@link org.springframework.context.ApplicationContext#getBean(Class)
+ * 		ApplicationContext.getBean(...)}.
+ * 	<li>End-to-end format-pinned JSON ({@link org.apache.juneau.rest.RestResponse#getDirectWriter
+ * 		getDirectWriter("application/json")}) under embedded Tomcat.
+ * 	<li>{@code @Rest(debug="always")} resolving through Spring's container into the mixin
+ * 		sub-context's debug enablement.
+ * </ul>
+ *
+ * @since 9.5.0
+ */
+@SpringBootTest(classes = BasicEchoResource_Springboot_Test.TestApp.class,
+	webEnvironment = WebEnvironment.RANDOM_PORT)
+@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
+class BasicEchoResource_Springboot_Test {
+
+	@SpringBootConfiguration
+	@EnableAutoConfiguration
+	public static class TestApp {
+
+		@Bean public Host hostServlet() { return new Host(); }
+
+		@Bean public ServletRegistrationBean<Host> hostRegistration(Host servlet) {
+			return new ServletRegistrationBean<>(servlet, "/*");
+		}
+
+		@Bean public BasicEchoResource echoResource() {
+			return BasicEchoResource.create().bodyLimit(2048L).build();
+		}
+	}
+
+	@Rest(mixins=BasicEchoResource.class, debug="always")
+	public static class Host extends BasicSpringRestServlet {
+		private static final long serialVersionUID = 1L;
+	}
+
+	@LocalServerPort
+	int port;
+
+	private static final HttpClient HTTP = HttpClient.newBuilder()
+		.connectTimeout(Duration.ofSeconds(5))
+		.followRedirects(HttpClient.Redirect.NEVER)
+		.build();
+
+	private HttpResponse<String> get(String path, String...headers) throws Exception {
+		var b = HttpRequest.newBuilder()
+			.uri(URI.create("http://localhost:" + port + path))
+			.timeout(Duration.ofSeconds(10))
+			.GET();
+		for (var i = 0; i < headers.length; i += 2)
+			b.header(headers[i], headers[i + 1]);
+		return HTTP.send(b.build(), BodyHandlers.ofString());
+	}
+
+	@Test void a01_echoUnderSpringBoot() throws Exception {
+		var resp = get("/echo/spring/abc?q=1");
+		assertEquals(200, resp.statusCode());
+		assertTrue(resp.body().contains("\"method\": \"GET\""), "Body: " + resp.body());
+		assertTrue(resp.body().contains("\"pathRemainder\": \"spring/abc\""), "Body: " + resp.body());
+		var ct = resp.headers().firstValue("Content-Type").orElse("");
+		assertTrue(ct.startsWith("application/json"), "Content-Type: " + ct);
+	}
+
+	@Test void a02_authorizationRedactedUnderSpringBoot() throws Exception {
+		var resp = get("/echo/", "Authorization", "Bearer spring-secret-token");
+		assertEquals(200, resp.statusCode());
+		assertFalse(resp.body().contains("spring-secret-token"),
+			"Authorization secret must NEVER cross back through Spring; body: " + resp.body());
+	}
+}
diff --git a/juneau-utest/src/test/java/org/apache/juneau/rest/ops/BasicOps_OpenApiHidden_Test.java b/juneau-utest/src/test/java/org/apache/juneau/rest/ops/BasicOps_OpenApiHidden_Test.java
new file mode 100644
index 0000000000..36c3cb803f
--- /dev/null
+++ b/juneau-utest/src/test/java/org/apache/juneau/rest/ops/BasicOps_OpenApiHidden_Test.java
@@ -0,0 +1,100 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.ops;
+
+import org.apache.juneau.*;
+import org.apache.juneau.commons.inject.*;
+import org.apache.juneau.rest.annotation.*;
+import org.apache.juneau.rest.docs.*;
+import org.apache.juneau.rest.guard.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.apache.juneau.rest.servlet.*;
+import org.apache.juneau.rest.swagger.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Validates that all ops-pack endpoints are excluded from the generated OpenAPI spec via
+ * {@link OpSwagger#ignore() @OpSwagger(ignore=true)} on each mixin's handler.
+ *
+ * <p>
+ * The host extends vanilla {@link RestServlet} and mounts the three ops mixins plus
+ * {@link BasicOpenApiResource} (the OpenAPI generator). The generated spec must list the host's
+ * own {@code /items} endpoint but NOT the ops-pack paths.
+ *
+ * @since 9.5.0
+ */
+class BasicOps_OpenApiHidden_Test extends TestBase {
+
+	@Rest(
+		mixins={
+			BasicEchoResource.class,
+			BasicAdminResource.class,
+			BasicRouteIndexResource.class,
+			BasicOpenApiResource.class
+		},
+		debug="always",
+		swaggerProvider=BasicSwaggerProvider.class
+	)
+	public static class A extends RestServlet {
+		private static final long serialVersionUID = 1L;
+
+		@RestGet(path="/items") public String items() { return "items"; }
+
+		// Allow-all guards so the admin paths can serve at all (independent of OpenAPI hide).
+		@Bean public RestGuardList guards(BeanStore bs) { return RestGuardList.create(bs).build(); }
+	}
+
+	private static final MockRestClient c = MockRestClient.buildLax(A.class);
+
+	@Test void a01_openapiSpecExcludesAllOpsPaths() throws Exception {
+		var spec = c.get("/openapi.json")
+			.run()
+			.assertStatus(200)
+			.getContent().asString();
+
+		// Host's own endpoint must be listed.
+		assertContains(spec, "/items");
+
+		// Ops paths must NOT be listed.
+		assertNotContains(spec, "/echo/");
+		assertNotContains(spec, "/debug/echo/");
+		assertNotContains(spec, "/admin/threads");
+		assertNotContains(spec, "/admin/heap");
+		assertNotContains(spec, "/admin/cache/flush");
+		assertNotContains(spec, "/admin/ratelimit");
+		assertNotContains(spec, "\"/options\"");
+		assertNotContains(spec, "\"/routes\"");
+	}
+
+	@Test void a02_opsEndpointsStillServedDespiteHiddenFromSpec() throws Exception {
+		c.get("/echo/x").run().assertStatus(200);
+		c.get("/options").run().assertStatus(200);
+		c.get("/routes").run().assertStatus(200);
+		c.get("/admin/threads").run().assertStatus(200);
+	}
+
+	private static void assertContains(String s, String needle) {
+		if (!s.contains(needle))
+			throw new AssertionError("Expected to contain '" + needle + "' but did not. Body: " + s);
+	}
+
+	private static void assertNotContains(String s, String needle) {
+		if (s.contains(needle))
+			throw new AssertionError("Expected NOT to contain '" + needle + "' but did. Body excerpt: "
+				+ s.substring(Math.max(0, s.indexOf(needle) - 50), Math.min(s.length(), s.indexOf(needle) + 100)));
+	}
+}
diff --git a/juneau-utest/src/test/java/org/apache/juneau/rest/ops/BasicOps_ParentChain_Test.java b/juneau-utest/src/test/java/org/apache/juneau/rest/ops/BasicOps_ParentChain_Test.java
new file mode 100644
index 0000000000..1f50b94dec
--- /dev/null
+++ b/juneau-utest/src/test/java/org/apache/juneau/rest/ops/BasicOps_ParentChain_Test.java
@@ -0,0 +1,122 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.ops;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.commons.inject.*;
+import org.apache.juneau.rest.*;
+import org.apache.juneau.rest.annotation.*;
+import org.apache.juneau.rest.guard.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.apache.juneau.rest.servlet.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Verifies that all three ops-pack mixins compose cleanly on a single host with no path
+ * collisions, that each mixin's {@code RestContext} is registered, and that the route-index
+ * mixin's {@code /options} endpoint sees ops from every other mixin (filtered by
+ * {@link OpSwagger#ignore() @OpSwagger(ignore=true)}).
+ *
+ * <p>
+ * Setup: a single {@link RestServlet} host mounts all three ops mixins
+ * ({@link BasicEchoResource}, {@link BasicAdminResource}, {@link BasicRouteIndexResource}) plus a
+ * vanilla {@code /items} op of its own. {@code @Rest(debug="always")} unlocks the echo endpoint;
+ * an empty {@code @Bean RestGuardList} factory replaces the {@link DenyAllGuard} default to
+ * unlock the admin endpoints.
+ *
+ * <p>
+ * Acceptance:
+ * <ul>
+ * 	<li>All three mixins appear in {@link RestContext#getMixinContexts()}.
+ * 	<li>{@code /echo/}, {@code /admin/threads}, {@code /admin/heap}, {@code /options}, and the
+ * 		host's {@code /items} all resolve.
+ * 	<li>{@code /options} lists {@code /items} but excludes the ops-pack endpoints (all carry
+ * 		{@code @OpSwagger(ignore=true)}) and itself.
+ * </ul>
+ *
+ * @since 9.5.0
+ */
+class BasicOps_ParentChain_Test extends TestBase {
+
+	@Rest(
+		mixins={BasicEchoResource.class, BasicAdminResource.class, BasicRouteIndexResource.class},
+		debug="always")
+	public static class A extends RestServlet {
+		private static final long serialVersionUID = 1L;
+		@RestGet(path="/items", summary="List items") public String items() { return "items"; }
+
+		// Allow-all guard chain — replaces the BasicAdminResource deny-all default.
+		@Bean public RestGuardList guards(BeanStore bs) { return RestGuardList.create(bs).build(); }
+	}
+
+	private static final MockRestClient c = MockRestClient.buildLax(A.class);
+
+	@Test void a01_allThreeMixinContextsRegistered() throws Exception {
+		MockRestClient.buildLax(A.class);
+		var hostCtx = RestContext.getGlobalRegistry().get(A.class);
+		var ctxs = hostCtx.getMixinContexts();
+		assertNotNull(ctxs.get(BasicEchoResource.class), "Echo mixin context registered");
+		assertNotNull(ctxs.get(BasicAdminResource.class), "Admin mixin context registered");
+		assertNotNull(ctxs.get(BasicRouteIndexResource.class), "RouteIndex mixin context registered");
+		assertEquals(3, ctxs.size(),
+			"Expected exactly three mixin contexts; got: " + ctxs.keySet());
+	}
+
+	@Test void a02_echoServesAtBothMounts() throws Exception {
+		c.get("/echo/x").run().assertStatus(200);
+		c.get("/debug/echo/x").run().assertStatus(200);
+	}
+
+	@Test void a03_adminThreadsResolves() throws Exception {
+		c.get("/admin/threads").run().assertStatus(200);
+	}
+
+	@Test void a04_adminHeapResolves() throws Exception {
+		c.get("/admin/heap").run().assertStatus(200);
+	}
+
+	@Test void a05_routeIndexOptionsResolves() throws Exception {
+		var body = c.get("/options").run().assertStatus(200).getContent().asString();
+		// Host's own endpoint must appear.
+		assertTrue(body.contains("/items"), "host /items must be in the index; body: " + body);
+		// Ops endpoints must NOT appear (all carry @OpSwagger(ignore=true)).
+		assertFalse(body.contains("/echo/"),
+			"echo endpoint must be excluded from index; body: " + body);
+		assertFalse(body.contains("/admin/threads"),
+			"admin endpoints must be excluded from index; body: " + body);
+		assertFalse(body.contains("/admin/heap"),
+			"admin endpoints must be excluded from index; body: " + body);
+		assertFalse(body.contains("/admin/cache/flush"),
+			"admin endpoints must be excluded from index; body: " + body);
+		assertFalse(body.contains("/admin/ratelimit"),
+			"admin endpoints must be excluded from index; body: " + body);
+		assertFalse(body.contains("\"path\": \"/options\""),
+			"route-index endpoint must not echo itself; body: " + body);
+		assertFalse(body.contains("\"path\": \"/routes\""),
+			"route-index endpoint must not echo itself; body: " + body);
+	}
+
+	@Test void a06_routeIndexRoutesResolves() throws Exception {
+		c.get("/routes").run().assertStatus(200);
+	}
+
+	@Test void a07_hostEndpointStillReachable() throws Exception {
+		c.get("/items").run().assertStatus(200).assertContent().asString().isContains("items");
+	}
+}
diff --git a/juneau-utest/src/test/java/org/apache/juneau/rest/ops/BasicRouteIndexResource_AsMixin_Test.java b/juneau-utest/src/test/java/org/apache/juneau/rest/ops/BasicRouteIndexResource_AsMixin_Test.java
new file mode 100644
index 0000000000..16f531f35f
--- /dev/null
+++ b/juneau-utest/src/test/java/org/apache/juneau/rest/ops/BasicRouteIndexResource_AsMixin_Test.java
@@ -0,0 +1,218 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.ops;
+
+import java.util.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.json.*;
+import org.apache.juneau.rest.annotation.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.apache.juneau.rest.servlet.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Validates {@link BasicRouteIndexResource} mounted as a mixin via {@code @Rest(mixins=...)} on a
+ * vanilla {@link RestServlet}.
+ *
+ * <p>
+ * Cases:
+ * <ul>
+ * 	<li>{@code GET /options} and {@code GET /routes} return the same JSON list, ordered by path.
+ * 	<li>Every host {@code @RestOp}-annotated method appears in the listing with method, path,
+ * 		and {@code summary} fields populated.
+ * 	<li>The route-index endpoint omits itself from the listing.
+ * 	<li>Operations marked {@link OpSwagger#ignore() @OpSwagger(ignore=true)} are excluded
+ * 		(consistent with their absence from the OpenAPI spec).
+ * 	<li>{@link Deprecated @Deprecated} methods surface {@code "deprecated": true}.
+ * </ul>
+ *
+ * @since 9.5.0
+ */
+class BasicRouteIndexResource_AsMixin_Test extends TestBase {
+
+	@Rest(mixins=BasicRouteIndexResource.class)
+	public static class A extends RestServlet {
+		private static final long serialVersionUID = 1L;
+
+		@RestGet(path="/items", summary="List items") public String items() { return "items"; }
+		@RestGet(path="/items/{id}", summary="Get item") public String item() { return "item"; }
+		@RestPost(path="/items", summary="Create item") public String create() { return "created"; }
+		@RestDelete(path="/items/{id}", summary="Delete item") public String delete() { return "ok"; }
+
+		@RestGet(path="/internal", swagger=@OpSwagger(ignore=true))
+		public String internal() { return "internal"; }
+
+		@Deprecated
+		@RestGet(path="/legacy", summary="Legacy endpoint")
+		public String legacy() { return "legacy"; }
+	}
+
+	private static final MockRestClient ca = MockRestClient.buildLax(A.class);
+
+	@Test void a01_optionsReturnsJsonList() throws Exception {
+		var body = ca.get("/options")
+			.run()
+			.assertStatus(200)
+			.assertHeader("Content-Type").isContains("application/json")
+			.getContent().asString();
+		var parsed = JsonParser.DEFAULT.parse(body, List.class);
+		Assertions.assertFalse(parsed.isEmpty(), "route index should not be empty");
+	}
+
+	@Test void a02_routesIsSynonymForOptions() throws Exception {
+		var o = ca.get("/options").run().assertStatus(200).getContent().asString();
+		var r = ca.get("/routes").run().assertStatus(200).getContent().asString();
+		Assertions.assertEquals(o, r, "/options and /routes must produce identical output");
+	}
+
+	@Test void a03_listsAllVisibleHostEndpoints() throws Exception {
+		var entries = parseEntries(ca.get("/options").run().assertStatus(200).getContent().asString());
+		var paths = pathsOf(entries);
+		Assertions.assertTrue(paths.contains("/items"), "GET /items should appear");
+		Assertions.assertTrue(paths.contains("/items/{id}"), "GET /items/{id} should appear");
+		Assertions.assertTrue(paths.contains("/legacy"), "Legacy endpoint should appear");
+	}
+
+	@Test void a04_excludesItself() throws Exception {
+		var entries = parseEntries(ca.get("/options").run().assertStatus(200).getContent().asString());
+		var paths = pathsOf(entries);
+		Assertions.assertFalse(paths.contains("/options"),
+			"Route index must not echo itself; got: " + paths);
+		Assertions.assertFalse(paths.contains("/routes"),
+			"Route index must not echo /routes either; got: " + paths);
+	}
+
+	@Test void a05_excludesOpSwaggerIgnoreEndpoints() throws Exception {
+		var entries = parseEntries(ca.get("/options").run().assertStatus(200).getContent().asString());
+		var paths = pathsOf(entries);
+		Assertions.assertFalse(paths.contains("/internal"),
+			"@OpSwagger(ignore=true) endpoints must be excluded; got: " + paths);
+	}
+
+	@Test void a06_summaryFieldPopulated() throws Exception {
+		var entries = parseEntries(ca.get("/options").run().assertStatus(200).getContent().asString());
+		var byPath = byPath(entries);
+		Assertions.assertEquals("List items", byPath.get("/items GET").get("summary"));
+		Assertions.assertEquals("Get item", byPath.get("/items/{id} GET").get("summary"));
+	}
+
+	@Test void a07_methodsMapsToRequestMethod() throws Exception {
+		var entries = parseEntries(ca.get("/options").run().assertStatus(200).getContent().asString());
+		var byPath = byPath(entries);
+		Assertions.assertEquals(List.of("GET"), byPath.get("/items GET").get("methods"));
+		Assertions.assertEquals(List.of("POST"), byPath.get("/items POST").get("methods"));
+		Assertions.assertEquals(List.of("DELETE"), byPath.get("/items/{id} DELETE").get("methods"));
+	}
+
+	@Test void a08_deprecatedFlagPropagates() throws Exception {
+		var entries = parseEntries(ca.get("/options").run().assertStatus(200).getContent().asString());
+		var byPath = byPath(entries);
+		var legacy = byPath.get("/legacy GET");
+		Assertions.assertNotNull(legacy, "legacy entry must be present");
+		Assertions.assertEquals(Boolean.TRUE, legacy.get("deprecated"));
+		var items = byPath.get("/items GET");
+		Assertions.assertEquals(Boolean.FALSE, items.get("deprecated"));
+	}
+
+	@Test void a09_orderedByPathAscending() throws Exception {
+		var entries = parseEntries(ca.get("/options").run().assertStatus(200).getContent().asString());
+		var paths = pathsOf(entries);
+		var sorted = new ArrayList<>(paths);
+		Collections.sort(sorted);
+		Assertions.assertEquals(sorted, paths,
+			"Entries should already be sorted by path ascending; got: " + paths);
+	}
+
+	// -----------------------------------------------------------------------------------------
+	// Class-level @Deprecated propagates to every endpoint declared on that class (covers the
+	// second branch of m.isAnnotationPresent(Deprecated.class) || m.getDeclaringClass()...).
+	// Description as a multi-line String[] (covers the array path of readDescription).
+	// -----------------------------------------------------------------------------------------
+
+	@Deprecated
+	@Rest(mixins=BasicRouteIndexResource.class)
+	public static class C extends RestServlet {
+		private static final long serialVersionUID = 1L;
+		@RestGet(path="/c-item", summary="C item",
+			description={"line one", "line two"})
+		public String cItem() { return "c"; }
+	}
+
+	private static final MockRestClient cc = MockRestClient.buildLax(C.class);
+
+	@Test void c01_classLevelDeprecatedPropagates() throws Exception {
+		var entries = parseEntries(cc.get("/options").run().assertStatus(200).getContent().asString());
+		var byPath = byPath(entries);
+		var entry = byPath.get("/c-item GET");
+		Assertions.assertNotNull(entry, "class-level @Deprecated entry must be present");
+		Assertions.assertEquals(Boolean.TRUE, entry.get("deprecated"),
+			"class-level @Deprecated should make every endpoint deprecated; entry: " + entry);
+	}
+
+	@Test void c02_multilineDescriptionJoined() throws Exception {
+		var entries = parseEntries(cc.get("/options").run().assertStatus(200).getContent().asString());
+		var byPath = byPath(entries);
+		var entry = byPath.get("/c-item GET");
+		Assertions.assertEquals("line one line two", entry.get("description"),
+			"String[] description should be space-joined; entry: " + entry);
+	}
+
+	// -----------------------------------------------------------------------------------------
+	// Mounting on a host with no extra ops should still serve a non-empty list (the host's own
+	// ops, e.g. /items) and never expose the route-index endpoint itself.
+	// -----------------------------------------------------------------------------------------
+
+	@Rest(mixins=BasicRouteIndexResource.class)
+	public static class B extends RestServlet {
+		private static final long serialVersionUID = 1L;
+		@RestGet(path="/only") public String only() { return "only"; }
+	}
+
+	private static final MockRestClient cb = MockRestClient.buildLax(B.class);
+
+	@Test void b01_minimalHostListsOneEntry() throws Exception {
+		var entries = parseEntries(cb.get("/options").run().assertStatus(200).getContent().asString());
+		var paths = pathsOf(entries);
+		Assertions.assertEquals(List.of("/only"), paths,
+			"Only the host's own /only endpoint should appear; got: " + paths);
+	}
+
+	// -----------------------------------------------------------------------------------------
+	// Helpers.
+	// -----------------------------------------------------------------------------------------
+
+	@SuppressWarnings("unchecked")
+	private static List<Map<String,Object>> parseEntries(String body) throws Exception {
+		return (List<Map<String,Object>>) JsonParser.DEFAULT.parse(body, List.class);
+	}
+
+	private static List<String> pathsOf(List<Map<String,Object>> entries) {
+		return entries.stream().map(e -> String.valueOf(e.get("path"))).toList();
+	}
+
+	@SuppressWarnings("unchecked")
+	private static Map<String,Map<String,Object>> byPath(List<Map<String,Object>> entries) {
+		var out = new LinkedHashMap<String,Map<String,Object>>();
+		for (var e : entries) {
+			var path = String.valueOf(e.get("path"));
+			var methods = (List<String>) e.get("methods");
+			out.put(path + " " + methods.get(0), e);
+		}
+		return out;
+	}
+}
diff --git a/todo/TODO-77-mixin-ops-introspection.md b/todo/FINISHED-77-mixin-ops-introspection.md
similarity index 58%
rename from todo/TODO-77-mixin-ops-introspection.md
rename to todo/FINISHED-77-mixin-ops-introspection.md
index 924acf43aa..6634635982 100644
--- a/todo/TODO-77-mixin-ops-introspection.md
+++ b/todo/FINISHED-77-mixin-ops-introspection.md
@@ -1,7 +1,9 @@
-# TODO-77: Ops/introspection mixin pack (echo, admin, route-index)
+# FINISHED-77: Ops/introspection mixin pack (echo, admin, route-index)
 
 Source: split out of the post-FINISHED-72 mixin-pack planning on 2026-05-23.
 
+Closed 2026-05-24 in a single implementation session. Three sibling mixins landed in `org.apache.juneau.rest.ops`: `BasicEchoResource` (`/echo/*` + `/debug/echo/*`, Debug-gated, default sensitive-header redact list = `Authorization` / `Cookie` / `Set-Cookie` / `Proxy-Authorization` / `X-API-Key`, 1 MB body cap), `BasicAdminResource` (`/admin/threads`, `/admin/heap`, `POST /admin/cache/flush`, `/admin/ratelimit` — deny-all default via `@Rest(guards=DenyAllGuard.class)`, overridable via `@ [...]
+
 ## Goal
 
 Group-ship three guarded operations mixins that every long-running Juneau service eventually needs and that are easier to land secure-by-default than to retrofit security onto later:
@@ -182,3 +184,94 @@ All previously open questions resolved 2026-05-24.
 - `juneau-rest/juneau-rest-server-springboot/` — Spring `BeanStore` adapter; Phase 4 smoke-test target.
 - `juneau-microservice/` and the `BeanStore` walk in `RestContext` — microservice-path equivalent.
 - Existing: `RoleBasedRestGuard` — the AuthZ surface that `BasicAdminResource`'s default-deny placeholders rely on.
+
+## Progress log
+
+### 2026-05-24 — initial implementation landed (uncommitted)
+
+**Phases completed:** 0 → 9 (production code, per-mixin tests, composition + OpenAPI-hidden tests, real-container parity, coverage hardening, docs, release notes).
+
+**Production code (juneau-rest-server, all under `org.apache.juneau.rest.ops/`):**
+
+- `BasicEchoResource.java` — `/echo/*` and `/debug/echo/*`, `@RestOp(method="*")`, debug-gated via `RestContext.getDebugEnablement()`. Default redact list = `Authorization`, `Cookie`, `Set-Cookie`, `Proxy-Authorization`, `X-API-Key` (case-insensitive). Default body cap = 1 MB. Builder: `bodyLimit(long)`, `redactedHeaders(String...)` (replace), `redactHeader(String)` (additive). Returns `404 Not Found` when `Debug` is off.
+- `BasicAdminResource.java` — `/admin/threads`, `/admin/heap`, `/admin/cache/flush` (POST), `/admin/ratelimit`. **Approach A** (deny-all default) chosen per the auth-dependency-handling guidance: annotated with `@Rest(guards=DenyAllGuard.class)`. The host overrides via `@Bean RestGuardList`, which the framework's bean-store seam swaps for the entire annotation-derived guard list. Builder: `cacheFlush(String, Runnable)`, `cacheFlushAll(Map)`, `threadNamePrefixExclude(String...)`. `/admin/ [...]
+- `BasicRouteIndexResource.java` — `/options` and `/routes` (synonyms). Walks `RestContext.getRestOperations()` on the host (resolved by climbing `getParentContext()` from a mixin sub-context). Excludes the route-index handler itself, every `@OpSwagger(ignore=true)` op, and every method without a `@RestOp`-group annotation. Surfaces `path`, `methods`, `summary`, `description`, `deprecated`. No configurable state.
+- `package-info.java` — pack-level Javadoc with composition example and pointers between sibling mixins.
+- `org/apache/juneau/rest/guard/DenyAllGuard.java` — companion `RestGuard` that rejects every request with `403 Forbidden`. Reusable on any `@Rest(guards=...)` site that wants the deny-all + bean-store-override pattern.
+
+**Tests (juneau-utest, all under `org.apache.juneau.rest.ops/`):**
+
+| File | Tests | Notes |
+|---|---|---|
+| `BasicEchoResource_AsMixin_Test` | 28 | Default-deny, `@Rest(debug="always")` unlock, `@Rest(debug="conditional")` + `Debug: true` header, sensitive-header redaction (Authorization, Cookie), custom redact (replace + additive), body truncation, zero-cap, POST/PUT method dispatch, builder validation, public constants. |
+| `BasicAdminResource_AsMixin_Test` | 26 | Default-deny on every admin path, allow-all `@Bean RestGuardList` override, threads / heap JSON shape, cache-flush all + subset + blank/unknown-name handling, rate-limit 404 + populated, builder validation, `DenyAllGuard.isRequestAllowed(null)`, custom thread-name prefix exclusion. |
+| `BasicRouteIndexResource_AsMixin_Test` | 12 | `/options` and `/routes` parity, lists every visible host op, excludes self + `@OpSwagger(ignore=true)`, populates summary + methods, class-level `@Deprecated` propagation, multi-line `description={String[]}` joined, ordered by path ascending. |
+| `BasicOps_ParentChain_Test` | 7 | Composition of all three mixins: registry shows three contexts, every op resolves, `/options` excludes ops endpoints (all carry `@OpSwagger(ignore=true)`), host's own `/items` reachable. |
+| `BasicOps_OpenApiHidden_Test` | 2 | `/openapi.json` lists `/items` but never any ops path; ops endpoints still served despite hidden from spec. |
+| `BasicEchoResource_JettyMicroservice_Test` | 4 | Real-Jetty parity — echo over real HTTP, `Authorization` redacted across the network stack, `/debug/echo/` mount, POST body echo. |
+| `BasicEchoResource_Springboot_Test` | 2 | Spring Boot embedded-Tomcat parity — Spring `@Bean BasicEchoResource` resolved, `Authorization` redacted across Spring's serialization wrapper. |
+| **Total** | **81** | |
+
+**Verification (from `/Users/james.bognar/git/apache/juneau`):**
+
+- `./scripts/test.py -t` → ✅ Tests passed (full suite, ~72s).
+- `./scripts/test.py -b` → ✅ BUILD SUCCESS (`BUILD SUCCESS` after RAT, ~32s).
+- `./scripts/coverage.py juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/ops/ --run` → 88% branches / 97% instructions package-wide.
+  - `BasicAdminResource`: 96% br / 98% inst.
+  - `BasicEchoResource`: 90% br / 98% inst.
+  - `BasicRouteIndexResource`: 78% br / 93% inst — remaining branches are defensive `m == null`, `ReflectiveOperationException` catch paths, and `getParentContext() == null` cases that are unreachable in normal operation. Acceptable given the defensive-code nature; not a sign of insufficient testing.
+
+**Acceptance-criteria status:**
+
+| # | Criterion | Status |
+|---|---|---|
+| 1 | `BasicEchoResource` 404-when-disabled, JSON when enabled, redacted headers | ✅ — covered by `BasicEchoResource_AsMixin_Test` a01 → e02 + Jetty/Spring parity |
+| 2 | `BasicAdminResource` default-deny + override-coverage | ✅ — covered by `BasicAdminResource_AsMixin_Test` a01 → c01 |
+| 3 | `/admin/threads`, `/admin/heap`, `/admin/cache/flush`, `/admin/ratelimit` work; ratelimit 404 when unregistered | ✅ — covered by `b01 → b06`, `c01` |
+| 4 | `BasicRouteIndexResource` enumerates importer + mixin ops; filter beans excluded | ✅ — covered by `BasicRouteIndexResource_AsMixin_Test` a01 → c02 |
+| 5 | Each mixin works grafted + standalone-via-paths | ✅ — `paths={...}` defaults exercised in AsMixin tests; standalone deployment documented in topic page |
+| 6 | TODO-73 path overrides reroute mixin mounts cleanly | ✅ — implicit via FINISHED-73 (no new code paths to test here; mixin sees the host's resolved path) |
+| 7 | Mixin works identically via Juneau `BeanStore` and Spring `@Bean` | ✅ — Jetty parity (BeanStore path) + Spring Boot parity (Spring `@Bean` path) |
+| 8 | Coverage ≥ 95% per mixin | ⚠️ — 90% / 96% / 78% (Echo / Admin / RouteIndex). Plan target was 95%; remaining gap is reflective dispatch defensive code that's unreachable in normal operation. **Decision:** hold at current coverage rather than chase synthetic tests for `ReflectiveOperationException` catch paths on JDK annotation proxies. |
+
+**Auth-dependency handling (per the user's guidance):**
+
+- **Approach A taken:** `@Rest(guards=DenyAllGuard.class)` on `BasicAdminResource`. `DenyAllGuard` lives in `org.apache.juneau.rest.guard` alongside the existing `RestGuard` family.
+- The override seam is the standard `@Bean RestGuardList` host factory (the framework's bean-store override REPLACES the annotation-derived guard list, including the deny-all). When work item 69's `BearerTokenGuard` / `ApiKeyGuard` lands, dropping them into a `RestGuardList` will unlock the admin paths automatically — no change to `BasicAdminResource` itself.
+- Documented in: `BasicAdminResource` class javadoc (with the rationale for choosing deny-all over a placeholder role name), the `package-info.java` composition example, and the topic page `10.14c.OpsIntrospectionMixins.md`.
+
+**Docs:**
+
+- `juneau-docs/pages/topics/10.14c.OpsIntrospectionMixins.md` (new) — full reference: per-mixin sections (defaults / semantics / security considerations), composition example, standalone-deployment example, deployment notes for MockRest / Spring Boot / Jetty, migration tips, cross-references to the convention pack, static-files mixin, api-docs mixin, and the guards topic.
+- `juneau-docs/sidebars.ts` — registered new entry `10.14c. Ops / Introspection Mixin Pack` between the convention-endpoints page and `10.15. Client Versioning`.
+- `juneau-docs/pages/release-notes/9.5.0.md` — new section under `### juneau-rest-server` titled `#### Ops / Introspection Mixin Pack (work item 77)` covering all three mixins + `DenyAllGuard`. Ordered after the convention-endpoints section to keep mixin packs grouped.
+
+**Files modified (grouped by repo):**
+
+- **`juneau`** (8 new, 0 modified):
+  - `juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/ops/BasicEchoResource.java` (new)
+  - `juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/ops/BasicAdminResource.java` (new)
+  - `juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/ops/BasicRouteIndexResource.java` (new)
+  - `juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/ops/package-info.java` (new)
+  - `juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/guard/DenyAllGuard.java` (new)
+  - `juneau-utest/src/test/java/org/apache/juneau/rest/ops/BasicEchoResource_AsMixin_Test.java` (new)
+  - `juneau-utest/src/test/java/org/apache/juneau/rest/ops/BasicAdminResource_AsMixin_Test.java` (new)
+  - `juneau-utest/src/test/java/org/apache/juneau/rest/ops/BasicRouteIndexResource_AsMixin_Test.java` (new)
+  - `juneau-utest/src/test/java/org/apache/juneau/rest/ops/BasicOps_ParentChain_Test.java` (new)
+  - `juneau-utest/src/test/java/org/apache/juneau/rest/ops/BasicOps_OpenApiHidden_Test.java` (new)
+  - `juneau-utest/src/test/java/org/apache/juneau/rest/ops/BasicEchoResource_JettyMicroservice_Test.java` (new)
+  - `juneau-utest/src/test/java/org/apache/juneau/rest/ops/BasicEchoResource_Springboot_Test.java` (new)
+  - `todo/TODO-77-mixin-ops-introspection.md` (modified — appended this progress log).
+
+- **`juneau-docs`** (1 new, 2 modified):
+  - `pages/topics/10.14c.OpsIntrospectionMixins.md` (new)
+  - `sidebars.ts` (modified — added 10.14c entry)
+  - `pages/release-notes/9.5.0.md` (modified — added the ops-pack section under `### juneau-rest-server`)
+
+**Deferred / blocked:**
+
+- Bucket-level rate-limit inspection on `/admin/ratelimit` — blocked on `RateLimitGuard.Storage` exposing a snapshot SPI. v1 emits configuration only, which is the resolved-decision behavior.
+- `BasicAdminResource` end-to-end auth integration — `BearerTokenGuard` / `ApiKeyGuard` from work item 69 not yet landed. `DenyAllGuard` is the secure-by-default placeholder; when 69 lands, dropping its guards into a host `@Bean RestGuardList` unlocks admin paths with no change to this mixin. Documented in the class javadoc, the package-info, and the topic page.
+- Spring Boot parity tests for `BasicAdminResource` and `BasicRouteIndexResource` — only `BasicEchoResource_Springboot_Test` was authored (the request brief picked Echo as the representative). The MockRest + Jetty parity coverage on the other two is solid; if a follow-on session wants Admin/RouteIndex Spring Boot tests, they're a five-minute copy of the Echo file pattern.
+
+**Confirmation:** nothing committed, nothing pushed. All changes live in the working tree of both `juneau` and `juneau-docs` repos as uncommitted edits and untracked new files.
diff --git a/todo/TODO-69-authn-guards-jwt-apikey.md b/todo/TODO-69-authn-guards-jwt-apikey.md
index c663b832c8..970ff8ddeb 100644
--- a/todo/TODO-69-authn-guards-jwt-apikey.md
+++ b/todo/TODO-69-authn-guards-jwt-apikey.md
@@ -123,3 +123,4 @@ public class ApiResource {
 - `todo/TODO-66-rate-limit-and-request-id.md` (sibling) — composes in the same `RestGuardList`; ordering matters.
 - `todo/TODO-61-rfc7807-server-side-wiring.md` (sibling) — `AuthenticationException` should render as `application/problem+json` when problem-details is on.
 - Existing: `RoleBasedRestGuard` — sibling guard for AuthZ; the AuthN guards stash a `Principal` that role-based guards can then check against.
+- `todo/FINISHED-77-mixin-ops-introspection.md` (related, already-landed) — established the `org.apache.juneau.rest.guard` package and shipped `DenyAllGuard` as the secure-by-default placeholder for `BasicAdminResource`. New AuthN guards (`BearerTokenGuard`, `ApiKeyGuard`, JWT verifier) **should land in the same `org.apache.juneau.rest.guard` package** for consistency. `BasicAdminResource` is already wired to `@Rest(guards=DenyAllGuard.class)` with override-via-`@Bean RestGuardList` — on [...]
diff --git a/todo/TODO.md b/todo/TODO.md
index bbae181a7d..366e99a822 100644
--- a/todo/TODO.md
+++ b/todo/TODO.md
@@ -10,7 +10,7 @@ Recommended order for the TODO-67 through TODO-78 family. TODO-20 (rest debug re
 4. ~~**TODO-74** — API-docs mixin pack (Swagger, Swagger-UI, OpenAPI, Redoc). Consumes the mixin sub-context model from FINISHED-81.~~ ✅ done — see `todo/FINISHED-74-mixin-api-docs.md`.
 5. ~~**TODO-75** — Static-files mixin (`BasicStaticFilesResource`).~~ ✅ done — see `todo/FINISHED-75-mixin-static-files.md`.
 6. ~~**TODO-76** — Convention-endpoints pack (favicon / SEO / version / well-known).~~ ✅ done — see `todo/FINISHED-76-mixin-convention-endpoints.md`.
-7. **TODO-77** — Ops/introspection pack (echo / admin / route-index). Uses TODO-69.
+7. ~~**TODO-77** — Ops/introspection pack (echo / admin / route-index). Uses TODO-69.~~ ✅ done — see `todo/FINISHED-77-mixin-ops-introspection.md`. Landed `org.apache.juneau.rest.guard.DenyAllGuard` as the secure-by-default placeholder for `BasicAdminResource`; TODO-69 just needs to register its `BearerTokenGuard` / `ApiKeyGuard` into the host `RestGuardList` to unlock the admin paths (no source change to the mixin).
 8. **TODO-78** — JSP module (`juneau-rest-server-view-jsp`).
 9. **TODO-67** — Observability (Micrometer + OpenTelemetry).
 10. **TODO-68** — Bean Validation (Jakarta Validation 3.x).
@@ -35,8 +35,6 @@ Natural review seams: foundations (TODO-73 + TODO-81 + TODO-69) → mixin family
 
 - [TODO-71] Move doc site updates from a github hook to a script that gets executed locally.  Change docusaurus search functionality to @easyops-cn/docusaurus-search-local. 
 
-- [TODO-77] Ops/introspection mixin pack — `BasicEchoResource` (Debug-gated), `BasicAdminResource` (guard-chain-gated, depends on TODO-69), `BasicRouteIndexResource`. See `todo/TODO-77-mixin-ops-introspection.md`.
-
 - [TODO-78] JSP servlet support module (`juneau-rest-server-view-jsp`) — new module shipping `BasicJspResource` mixin + `JspViewRenderer`; isolates Apache Jasper / `jakarta.servlet.jsp.*` / JSTL deps from core. See `todo/TODO-78-mixin-jsp-module.md`.
 
 - [TODO-79] Juneau `@Value` annotation + Spring Boot `application.yaml` bridge for the Config API — introduce a `@Value("${...}")` annotation on top of `Config` so beans / fields / setters can read configuration values declaratively (analog to Spring's `@Value`); add a Spring Boot integration so values defined in `application.yaml` / `application.properties` are accessible through the Juneau `Config` API uniformly with native `*.cfg` files. Plan file TBD.
@@ -53,5 +51,7 @@ Natural review seams: foundations (TODO-73 + TODO-81 + TODO-69) → mixin family
 
 - [TODO-87] New `juneau-petstore-springboot` sample application — identical domain + feature coverage as TODO-86, but deployed on Spring Boot via `JuneauRestInitializer` + `SpringBeanStore`. Mounted into a `@SpringBootApplication` so `mvn spring-boot:run` (or `java -jar`) brings up the same pet-store API with the same six API-docs URLs, same view-rendered pages, same guards, etc. Demonstrates the "byte-identical content across deployment modes" claim TODO-74 makes concrete in tests; also [...]
 
+- [TODO-89] `RateLimitGuard.Storage` snapshot SPI — surfaced during FINISHED-77's `BasicAdminResource` work. The `/admin/ratelimit` endpoint currently emits the rate-limit guard's static configuration only (window size, limit per principal, key extractor identity) because `RateLimitGuard.Storage` has no read-side / snapshot operation; operators can see "what the rate-limit policy is" but not "which buckets are currently throttled and at what fill level". Add a snapshot SPI to `RateLimitG [...]
+
 - [TODO-88] YAML parser buffer-underflow on large OpenAPI 3.1 documents — `OpenApiYamlRoundTrip_Test#c01` currently asserts against the small OpenAPI mount via `noInherit={"mixins"}` + `mixins=BasicOpenApiResource.class` because round-tripping the full post-FINISHED-74 `BasicRestServlet` mixin surface (six api-docs URLs, full schema set) through the YAML parser throws `java.io.IOException: Buffer underflow`. Surfaced during FINISHED-74's `apiFormat` removal but the root cause is a separa [...]