fix(sandbox): two sandbox bugs blocking Peter Steinberger beta test

Bug 1 — sandbox-scoped discovery returns empty:
GET /services?capability=nlp with a sandbox API key returns [] because
Bunny.net CDN caches the public (PRODUCTION-scoped) empty response and
serves it regardless of the X-Api-Key header. Fix: ServiceResource.search()
now returns Cache-Control: private, no-store when a sandbox key is active,
opting the response out of CDN caching.

Bug 2 — _links.sandbox href uses sandbox name instead of UUID:
IndexResource.index() used sandbox.name as the URL path segment instead of
sandbox.id. Name-based URLs return 404 since migration-020 made UUID the
sole resource identifier. Fix: sandbox.name → sandbox.id.

Also: SandboxResource GET /{uuid} simplified to SandboxIndexResponse
(metadata + links only); heavy usage stats + agent visits are on
/telemetry. SandboxService feedback INSERT uses CAST(:x AS jsonb) for
portability across PostgreSQL JDBC driver versions.

Co-Authored-By: Mira Rehfeld <noreply@anthropic.com>
This commit is contained in:
Carsten Rehfeld
2026-05-16 13:20:37 +02:00
parent 134c8b3c6b
commit e385d05ce1
4 changed files with 18 additions and 10 deletions
@@ -47,7 +47,7 @@ public class IndexResource {
if (apiKey != null && !apiKey.isBlank()) {
SandboxEntity sandbox = sandboxService.findByKey(apiKey);
if (sandbox != null) {
sandboxLink = new LinkRef(baseUrl + "/sandbox/" + sandbox.name);
sandboxLink = new LinkRef(baseUrl + "/sandbox/" + sandbox.id);
}
}
@@ -7,7 +7,6 @@ import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import org.botstandards.apix.common.BsmPayload;
import org.botstandards.apix.common.SandboxDashboardResponse;
import org.botstandards.apix.registry.dto.*;
import org.botstandards.apix.registry.entity.SandboxEntity;
import org.botstandards.apix.registry.service.SandboxService;
@@ -69,16 +68,18 @@ public class SandboxResource {
@GET
@Path("/{uuid}")
@Operation(
summary = "Sandbox root — HATEOAS navigation and dashboard data",
description = "Returns sandbox metadata, navigation links, usage stats, and agent visit data. " +
summary = "Sandbox root — HATEOAS navigation",
description = "Returns sandbox metadata and navigation links. " +
"No authentication required. The UUID is the permanent resource identifier."
)
public SandboxDashboardResponse index(@PathParam("uuid") String uuidStr) {
public SandboxIndexResponse index(@PathParam("uuid") String uuidStr) {
UUID id = parseUuid(uuidStr);
SandboxDashboardResponse dashboard = sandboxService.getDashboard(id);
var sb = sandboxService.requireById(id);
sandboxService.recordUsage(id.toString(), SandboxService.EVENT_SANDBOX_VIEWED);
meters.counter("apix.sandbox.views", "sandbox", id.toString()).increment();
return dashboard;
String base = baseUrl + "/sandbox/" + id;
return new SandboxIndexResponse(sb.id.toString(), sb.name, sb.tier, sb.ratePerMinute,
sb.expiresAt, sandboxService.sandboxLinks(base, id.toString()));
}
@POST
@@ -89,6 +89,8 @@ public class ServiceResource {
summary = "Search services by capability and optional extension properties",
description = "Returns services matching the given capability keyword. " +
"Omitting ?stage= defaults to PRODUCTION only — this is the standard agent query. " +
"When a valid sandbox API key is provided via X-Api-Key, results are scoped to that sandbox " +
"and stage defaults to DEVELOPMENT. " +
"Use ?stage=DEVELOPMENT or ?stage=BETA to discover pre-production services. " +
"Capability values are lowercase kebab-case strings defined by the registrant (e.g. nlp, translation, speech-to-text). " +
"The search matches exact capability tokens, not substrings. " +
@@ -96,7 +98,7 @@ public class ServiceResource {
"Multiple ?property= parameters are ANDed together. " +
"Example: ?capability=translation&property=region:eu&property=dataResidency:DE"
)
public List<ServiceResponse> search(
public Response search(
@Parameter(description = "Capability keyword to search for (e.g. nlp, translation, speech-to-text). Required.", example = "nlp")
@QueryParam("capability") String capability,
@Parameter(description = "Lifecycle stage filter. Defaults to PRODUCTION if omitted. Valid values: DEVELOPMENT, BETA, PRODUCTION, DEPRECATED.", example = "PRODUCTION")
@@ -123,7 +125,12 @@ public class ServiceResource {
"capability", tv(capability))
.record(results.size());
return results;
Response.ResponseBuilder rb = Response.ok(results);
// Sandbox responses are scoped to a specific API key — must not be cached by the CDN.
if (sandboxId != null) {
rb.header("Cache-Control", "private, no-store");
}
return rb.build();
}
@PATCH
@@ -377,7 +377,7 @@ public class SandboxService {
em.createNativeQuery(
"INSERT INTO sandbox_feedback " +
"(sandbox_id, scores, agent_identifier, comment, model_identifier, model_provider) " +
"VALUES (:sid, :scores::jsonb, :agent, :comment, :modelId, :modelProvider)")
"VALUES (:sid, CAST(:scores AS jsonb), :agent, :comment, :modelId, :modelProvider)")
.setParameter("sid", sandboxId)
.setParameter("scores", scoresJson)
.setParameter("agent", req.agentIdentifier())