Compare commits

..

1 Commits

Author SHA1 Message Date
Carsten Rehfeld c2532fc241 feat(registry): service stage semantics + sandbox admin list improvements
Deploy to Production / deploy (push) Successful in 3m16s
- Sandbox search without ?stage= defaults to DEVELOPMENT (was PRODUCTION),
  so freshly registered services are discoverable by default
- DEPRECATED and DECOMMISSIONED rejected at creation with 400 (lifecycle-only)
- PRODUCTION accepted at creation for established services going live immediately
- Admin sandbox list now returns non-null createdAt and expiresAt (toInstant fix
  for Hibernate 6 returning LocalDateTime from timestamptz native queries)
- Admin UI sandbox list shows Created column
- BDD: sandbox-admin-list.feature + sandbox-stage.feature (47 scenarios, all green)

Co-Authored-By: Mira Rehfeld <noreply@anthropic.com>
2026-05-18 22:30:21 +02:00
23 changed files with 3 additions and 2485 deletions
+1 -4
View File
@@ -1,8 +1,5 @@
# Script run logs and runtime artifacts # Script run logs
logs/ logs/
.logs/
.pids/
C*Temp*.json
# flatten-maven-plugin resolves ${revision} into installed POMs # flatten-maven-plugin resolves ${revision} into installed POMs
.flattened-pom.xml .flattened-pom.xml
@@ -1,5 +0,0 @@
package org.botstandards.apix.common;
import java.util.List;
public record ServiceBrowsePage(long total, int page, int size, List<ServiceSummaryDto> items) {}
@@ -1,18 +1,17 @@
package org.botstandards.apix.common; package org.botstandards.apix.common;
import java.time.Instant;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
public record ServiceSummaryDto( public record ServiceSummaryDto(
UUID id, UUID id,
String name, String name,
String description,
List<String> capabilities, List<String> capabilities,
OLevel oLevel, OLevel oLevel,
LivenessStatus livenessStatus, LivenessStatus livenessStatus,
ServiceStage serviceStage, ServiceStage serviceStage,
RegistryStatus registryStatus, RegistryStatus registryStatus,
String endpoint, String endpoint,
String registrantName, Instant lastCheckedAt
String registrantJurisdiction
) {} ) {}
@@ -1,27 +0,0 @@
package org.botstandards.apix.portal.client;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import org.botstandards.apix.common.ServiceBrowsePage;
import org.botstandards.apix.portal.dto.PortalServiceView;
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient;
@RegisterRestClient(configKey = "registry")
@Produces(MediaType.APPLICATION_JSON)
public interface ServiceRegistryClient {
@GET
@Path("/services/browse")
ServiceBrowsePage browseServices(
@QueryParam("capability") String capability,
@QueryParam("page") int page,
@QueryParam("size") int size);
@GET
@Path("/services/{id}")
PortalServiceView getService(@PathParam("id") String id);
}
@@ -1,36 +0,0 @@
package org.botstandards.apix.portal.dto;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import org.botstandards.apix.common.LivenessStatus;
import org.botstandards.apix.common.OLevel;
import org.botstandards.apix.common.OrgType;
import org.botstandards.apix.common.RegistryStatus;
import org.botstandards.apix.common.ServiceStage;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
@JsonIgnoreProperties(ignoreUnknown = true)
public record PortalServiceView(
UUID id,
String name,
String description,
String endpoint,
List<String> capabilities,
String registrantName,
String registrantJurisdiction,
OrgType registrantOrgType,
String openApiSpecUrl,
String mcpSpecUrl,
String policyUrl,
String securityContactUrl,
OLevel oLevel,
LivenessStatus livenessStatus,
ServiceStage serviceStage,
RegistryStatus registryStatus,
Instant registeredAt,
Instant lastUpdatedAt,
Instant sunsetAt,
String migrationGuideUrl
) {}
@@ -1,74 +0,0 @@
package org.botstandards.apix.portal.resource;
import io.quarkus.qute.CheckedTemplate;
import io.quarkus.qute.TemplateInstance;
import jakarta.inject.Inject;
import jakarta.ws.rs.DefaultValue;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.core.MediaType;
import org.botstandards.apix.common.ServiceBrowsePage;
import org.botstandards.apix.portal.client.ServiceRegistryClient;
import org.botstandards.apix.portal.dto.PortalServiceView;
import org.eclipse.microprofile.rest.client.inject.RestClient;
import org.jboss.logging.Logger;
@Path("/browse")
public class ServiceBrowserResource {
private static final Logger LOG = Logger.getLogger(ServiceBrowserResource.class);
private static final int PAGE_SIZE = 20;
@Inject
@RestClient
ServiceRegistryClient registryClient;
@CheckedTemplate
static class Templates {
static native TemplateInstance browse(ServiceBrowsePage page, String capability, int currentPage, int prevPage, int nextPage, long totalPages, boolean hasPrev, boolean hasNext);
static native TemplateInstance detail(PortalServiceView service);
static native TemplateInstance error(String message);
}
@GET
@Produces(MediaType.TEXT_HTML)
public TemplateInstance browse(
@QueryParam("capability") @DefaultValue("") String capability,
@QueryParam("page") @DefaultValue("0") int page) {
String cap = capability.isBlank() ? null : capability.strip();
ServiceBrowsePage result;
try {
result = registryClient.browseServices(cap, page, PAGE_SIZE);
} catch (Exception e) {
LOG.errorf(e, "Failed to fetch service list");
return Templates.error("Registry unavailable — please try again shortly.");
}
boolean hasNext = (long) (page + 1) * PAGE_SIZE < result.total();
long totalPages = (result.total() + PAGE_SIZE - 1) / PAGE_SIZE;
return Templates.browse(result, capability.strip(), page, page - 1, page + 1, totalPages, page > 0, hasNext);
}
@GET
@Path("/{id}")
@Produces(MediaType.TEXT_HTML)
public TemplateInstance detail(@PathParam("id") String id) {
PortalServiceView service;
try {
service = registryClient.getService(id);
} catch (WebApplicationException e) {
if (e.getResponse().getStatus() == 404) {
return Templates.error("Service not found.");
}
LOG.errorf(e, "Failed to fetch service %s", id);
return Templates.error("Registry unavailable — please try again shortly.");
} catch (Exception e) {
LOG.errorf(e, "Failed to fetch service %s", id);
return Templates.error("Registry unavailable — please try again shortly.");
}
return Templates.detail(service);
}
}
@@ -390,13 +390,6 @@
<h2>Try it</h2> <h2>Try it</h2>
<div class="draft-suite" style="margin-bottom:2.5rem"> <div class="draft-suite" style="margin-bottom:2.5rem">
<div class="draft-item" style="background:#f9f9f9">
<span class="label">Browse</span>
<div class="content">
<a href="/browse">Service Registry — browse &amp; search all registered services →</a>
<div class="sub">Human-navigable view of the same index agents query. Filter by capability, read BSM records, check trust levels.</div>
</div>
</div>
<div class="draft-item"> <div class="draft-item">
<span class="label">API root</span> <span class="label">API root</span>
<div class="content"> <div class="content">
@@ -1,302 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>APIX Registry · Browse Services</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: #0d1117;
color: #c9d1d9;
font-family: 'SF Mono', 'Consolas', 'Fira Code', monospace;
font-size: 14px;
line-height: 1.6;
}
a { color: #58a6ff; text-decoration: none; }
a:hover { text-decoration: underline; }
/* ── Header ── */
.header {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem 1.5rem;
border-bottom: 1px solid #21262d;
}
.header-logo { color: #8b949e; font-size: 0.8rem; }
.header-title { font-size: 1rem; color: #e6edf3; font-weight: 600; }
/* ── Search ── */
.search-bar {
padding: 1.25rem 1.5rem;
border-bottom: 1px solid #21262d;
display: flex;
gap: 0.6rem;
align-items: center;
}
.search-input {
flex: 1;
background: #161b22;
border: 1px solid #30363d;
color: #c9d1d9;
padding: 0.5rem 0.75rem;
font-family: inherit;
font-size: 13px;
border-radius: 4px;
outline: none;
}
.search-input:focus { border-color: #58a6ff; }
.search-input::placeholder { color: #484f58; }
.btn {
background: #21262d;
border: 1px solid #30363d;
color: #c9d1d9;
padding: 0.45rem 0.9rem;
font-family: inherit;
font-size: 13px;
cursor: pointer;
border-radius: 4px;
white-space: nowrap;
}
.btn:hover { border-color: #58a6ff; color: #58a6ff; }
.btn-link { background: transparent; border: none; color: #484f58; cursor: pointer; font-family: inherit; font-size: 13px; }
.btn-link:hover { color: #58a6ff; }
/* ── Stats bar ── */
.stats-bar {
padding: 0.45rem 1.5rem;
font-size: 0.7rem;
color: #484f58;
border-bottom: 1px solid #21262d;
letter-spacing: 0.03em;
}
/* ── Service list ── */
.service-list {
display: flex;
flex-direction: column;
gap: 1px;
background: #21262d;
}
.service-card {
background: #0d1117;
padding: 1rem 1.5rem;
display: grid;
grid-template-columns: 1fr auto;
gap: 0.75rem;
align-items: start;
cursor: pointer;
transition: background 0.1s;
}
.service-card:hover { background: #161b22; }
.card-name {
color: #e6edf3;
font-weight: 600;
font-size: 0.95rem;
}
.card-name a { color: #e6edf3; }
.card-name a:hover { color: #58a6ff; text-decoration: none; }
.card-desc {
color: #8b949e;
font-size: 0.78rem;
margin-top: 0.2rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 72ch;
}
.caps {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
margin-top: 0.5rem;
}
.cap-tag {
background: #161b22;
border: 1px solid #30363d;
color: #8b949e;
padding: 0.05rem 0.4rem;
border-radius: 3px;
font-size: 0.65rem;
letter-spacing: 0.02em;
}
.card-meta {
font-size: 0.68rem;
color: #484f58;
margin-top: 0.45rem;
display: flex;
align-items: center;
gap: 0.4rem;
}
/* ── Liveness dot ── */
.dot {
width: 7px;
height: 7px;
border-radius: 50%;
display: inline-block;
flex-shrink: 0;
}
.dot-LIVE { background: #3fb950; box-shadow: 0 0 5px #3fb95088; }
.dot-DEGRADED { background: #d29922; }
.dot-UNREACHABLE { background: #f85149; }
.dot-PENDING { background: #484f58; }
/* ── O-level badge ── */
.o-badge {
font-size: 0.62rem;
padding: 0.15rem 0.5rem;
border-radius: 10px;
border: 1px solid;
white-space: nowrap;
letter-spacing: 0.04em;
text-transform: uppercase;
align-self: flex-start;
margin-top: 0.15rem;
}
.o-UNVERIFIED { color: #484f58; border-color: #30363d; background: transparent; }
.o-IDENTITY_VERIFIED { color: #d29922; border-color: #d2992255; background: #d2992211; }
.o-LEGAL_ENTITY_VERIFIED { color: #e3b341; border-color: #e3b34155; background: #e3b34111; }
.o-HYGIENE_VERIFIED { color: #58a6ff; border-color: #58a6ff55; background: #58a6ff11; }
.o-OPERATIONALLY_VERIFIED { color: #3fb950; border-color: #3fb95055; background: #3fb95011; }
.o-AUDITED { color: #bc8cff; border-color: #bc8cff55; background: #bc8cff11; }
/* ── Pagination ── */
.pagination {
padding: 1rem 1.5rem;
display: flex;
justify-content: space-between;
align-items: center;
border-top: 1px solid #21262d;
font-size: 0.78rem;
color: #484f58;
}
.page-btn { display: inline-block; color: #8b949e; border: 1px solid #30363d; padding: 0.3rem 0.65rem; border-radius: 4px; }
.page-btn:hover { border-color: #58a6ff; color: #58a6ff; text-decoration: none; }
.page-btn-off { color: #30363d; border: 1px solid #21262d; padding: 0.3rem 0.65rem; border-radius: 4px; }
/* ── Empty state ── */
.empty {
padding: 4rem 1.5rem;
text-align: center;
color: #484f58;
}
.empty h2 { font-size: 1rem; margin-bottom: 0.5rem; color: #30363d; }
/* ── Footer ── */
.footer {
padding: 1rem 1.5rem;
border-top: 1px solid #21262d;
font-size: 0.7rem;
color: #484f58;
display: flex;
justify-content: space-between;
flex-wrap: wrap;
gap: 0.5rem;
}
</style>
</head>
<body>
<div class="header">
<span class="header-logo"><a href="/browse">APIX</a></span>
<span class="header-title">Service Registry</span>
</div>
<form class="search-bar" method="get" action="/browse">
<input class="search-input" type="text" name="capability" value="{capability}"
placeholder="search by capability (e.g. nlp, translation, speech-to-text)"
autocomplete="off" spellcheck="false">
<button class="btn" type="submit">Search</button>
{#if capability.isEmpty() == false}
<a href="/browse" class="btn-link">clear</a>
{/if}
</form>
<div class="stats-bar">
{page.total} service{#if page.total != 1}s{/if}
{#if capability.isEmpty()}in production · public registry{#else}matching «{capability}»{/if}
</div>
{#if page.items.isEmpty()}
<div class="empty">
<h2>no services found</h2>
<p>
{#if capability.isEmpty()}
No production services are registered yet.
{#else}
No production services match capability «{capability}».
{/if}
</p>
</div>
{#else}
<div class="service-list">
{#for s in page.items}
<div class="service-card" onclick="location.href='/browse/{s.id}'">
<div>
<div class="card-name"><a href="/browse/{s.id}">{s.name}</a></div>
{#if s.description != null}
<div class="card-desc">{s.description}</div>
{/if}
{#if s.capabilities != null && s.capabilities.isEmpty() == false}
<div class="caps">
{#for cap in s.capabilities}
<span class="cap-tag">{cap}</span>
{/for}
</div>
{/if}
<div class="card-meta">
<span class="dot dot-{s.livenessStatus}"></span>
<span>{s.livenessStatus}</span>
{#if s.registrantName != null}
<span>·</span>
<span>{s.registrantName}</span>
{/if}
{#if s.registrantJurisdiction != null}
<span>·</span>
<span>{s.registrantJurisdiction}</span>
{/if}
</div>
</div>
<div>
<span class="o-badge o-{s.oLevel}">{s.oLevel}</span>
</div>
</div>
{/for}
</div>
{/if}
<div class="pagination">
<span>
{#if hasPrev}
<a href="/browse?page={prevPage}{#if capability.isEmpty() == false}&capability={capability}{/if}" class="page-btn">← prev</a>
{#else}
<span class="page-btn-off">← prev</span>
{/if}
</span>
<span>page {currentPage + 1} of {totalPages}</span>
<span>
{#if hasNext}
<a href="/browse?page={nextPage}{#if capability.isEmpty() == false}&capability={capability}{/if}" class="page-btn">next →</a>
{#else}
<span class="page-btn-off">next →</span>
{/if}
</span>
</div>
<div class="footer">
<span>APIX Registry · open protocol</span>
<span>
<a href="https://api-index.org">api-index.org</a> ·
<a href="https://api-index.org/q/openapi">OpenAPI</a>
</span>
</div>
</body>
</html>
@@ -1,258 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{service.name} · APIX Registry</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: #0d1117;
color: #c9d1d9;
font-family: 'SF Mono', 'Consolas', 'Fira Code', monospace;
font-size: 14px;
line-height: 1.6;
}
a { color: #58a6ff; text-decoration: none; }
a:hover { text-decoration: underline; }
/* ── Header ── */
.header {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.9rem 1.5rem;
border-bottom: 1px solid #21262d;
flex-wrap: wrap;
}
.back-link { color: #8b949e; font-size: 0.78rem; }
.back-link:hover { color: #58a6ff; }
.header-name { font-size: 1rem; color: #e6edf3; font-weight: 600; flex: 1; }
.header-badges { display: flex; align-items: center; gap: 0.5rem; }
/* ── Liveness ── */
.dot {
width: 8px; height: 8px; border-radius: 50%; display: inline-block;
}
.dot-LIVE { background: #3fb950; box-shadow: 0 0 6px #3fb95088; }
.dot-DEGRADED { background: #d29922; }
.dot-UNREACHABLE { background: #f85149; }
.dot-PENDING { background: #484f58; }
/* ── O-level badge ── */
.o-badge {
font-size: 0.65rem; padding: 0.15rem 0.55rem; border-radius: 10px;
border: 1px solid; letter-spacing: 0.04em; text-transform: uppercase;
}
.o-UNVERIFIED { color: #484f58; border-color: #30363d; }
.o-IDENTITY_VERIFIED { color: #d29922; border-color: #d2992255; background: #d2992211; }
.o-LEGAL_ENTITY_VERIFIED { color: #e3b341; border-color: #e3b34155; background: #e3b34111; }
.o-HYGIENE_VERIFIED { color: #58a6ff; border-color: #58a6ff55; background: #58a6ff11; }
.o-OPERATIONALLY_VERIFIED { color: #3fb950; border-color: #3fb95055; background: #3fb95011; }
.o-AUDITED { color: #bc8cff; border-color: #bc8cff55; background: #bc8cff11; }
/* ── Stage badge ── */
.stage-badge {
font-size: 0.62rem; padding: 0.12rem 0.45rem; border-radius: 3px;
background: #161b22; border: 1px solid #30363d; color: #8b949e;
letter-spacing: 0.04em;
}
/* ── Body ── */
.detail-body {
max-width: 820px;
padding: 2rem 1.5rem;
display: flex;
flex-direction: column;
gap: 0;
}
/* ── Section ── */
.section {
padding: 1rem 0;
border-bottom: 1px solid #21262d;
display: grid;
grid-template-columns: 180px 1fr;
gap: 0.5rem 1.5rem;
align-items: start;
}
.section:last-child { border-bottom: none; }
.section-label {
font-size: 0.65rem;
color: #484f58;
letter-spacing: 0.08em;
text-transform: uppercase;
padding-top: 0.1rem;
}
.section-value { color: #c9d1d9; word-break: break-all; }
.section-value a { word-break: break-all; }
/* ── Caps ── */
.caps { display: flex; flex-wrap: wrap; gap: 0.3rem; }
.cap-tag {
background: #161b22; border: 1px solid #30363d; color: #8b949e;
padding: 0.1rem 0.45rem; border-radius: 3px; font-size: 0.7rem;
}
/* ── O-level description ── */
.o-desc {
font-size: 0.75rem;
color: #8b949e;
margin-top: 0.4rem;
padding: 0.6rem 0.75rem;
background: #161b22;
border: 1px solid #21262d;
border-radius: 4px;
line-height: 1.5;
}
/* ── Links group ── */
.links-list { display: flex; flex-direction: column; gap: 0.35rem; }
.link-item { font-size: 0.8rem; }
.link-label { color: #484f58; font-size: 0.65rem; letter-spacing: 0.05em; text-transform: uppercase; margin-right: 0.5rem; }
/* ── ID display ── */
.uuid { font-size: 0.75rem; color: #484f58; font-family: inherit; }
/* ── Footer ── */
.footer {
padding: 1rem 1.5rem;
border-top: 1px solid #21262d;
font-size: 0.7rem;
color: #484f58;
display: flex;
justify-content: space-between;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 1rem;
}
</style>
</head>
<body>
<div class="header">
<a href="/browse" class="back-link">← Registry</a>
<span class="header-name">{service.name}</span>
<div class="header-badges">
<span class="dot dot-{service.livenessStatus}"></span>
<span class="o-badge o-{service.oLevel}">{service.oLevel}</span>
<span class="stage-badge">{service.serviceStage}</span>
</div>
</div>
<div class="detail-body">
{#if service.description != null}
<div class="section">
<span class="section-label">Description</span>
<span class="section-value">{service.description}</span>
</div>
{/if}
<div class="section">
<span class="section-label">Endpoint</span>
<span class="section-value"><a href="{service.endpoint}">{service.endpoint}</a></span>
</div>
{#if service.capabilities != null && service.capabilities.isEmpty() == false}
<div class="section">
<span class="section-label">Capabilities</span>
<div class="caps">
{#for cap in service.capabilities}
<span class="cap-tag">{cap}</span>
{/for}
</div>
</div>
{/if}
<div class="section">
<span class="section-label">Trust Level</span>
<div>
<span class="o-badge o-{service.oLevel}" style="font-size:0.75rem;padding:0.2rem 0.6rem;">{service.oLevel}</span>
<div class="o-desc">
{#if service.oLevel.name() == "UNVERIFIED"}
O0 · No verification performed. Default for all newly registered services.
{/if}
{#if service.oLevel.name() == "IDENTITY_VERIFIED"}
O1 · DNS ownership of the registrant domain verified via a DNS TXT record challenge.
{/if}
{#if service.oLevel.name() == "LEGAL_ENTITY_VERIFIED"}
O2 · Legal entity confirmed via GLEIF LEI database or OpenCorporates registry.
{/if}
{#if service.oLevel.name() == "HYGIENE_VERIFIED"}
O3 · Service passes technical hygiene checks — security.txt present, spec accessible, endpoint responding within SLA.
{/if}
{#if service.oLevel.name() == "OPERATIONALLY_VERIFIED"}
O4 · Demonstrated operational history with continuous liveness monitoring.
{/if}
{#if service.oLevel.name() == "AUDITED"}
Highest level · Full independent audit completed by an APIX-accredited auditor.
{/if}
</div>
</div>
</div>
{#if service.openApiSpecUrl != null || service.mcpSpecUrl != null || service.policyUrl != null || service.securityContactUrl != null}
<div class="section">
<span class="section-label">Links</span>
<div class="links-list">
{#if service.openApiSpecUrl != null}
<div class="link-item"><span class="link-label">OpenAPI</span><a href="{service.openApiSpecUrl}">{service.openApiSpecUrl}</a></div>
{/if}
{#if service.mcpSpecUrl != null}
<div class="link-item"><span class="link-label">MCP Spec</span><a href="{service.mcpSpecUrl}">{service.mcpSpecUrl}</a></div>
{/if}
{#if service.policyUrl != null}
<div class="link-item"><span class="link-label">Policy</span><a href="{service.policyUrl}">{service.policyUrl}</a></div>
{/if}
{#if service.securityContactUrl != null}
<div class="link-item"><span class="link-label">Security</span><a href="{service.securityContactUrl}">{service.securityContactUrl}</a></div>
{/if}
</div>
</div>
{/if}
<div class="section">
<span class="section-label">Registrant</span>
<div>
<div>{#if service.registrantName != null}{service.registrantName}{#else}—{/if}</div>
{#if service.registrantJurisdiction != null}
<div style="font-size:0.75rem;color:#8b949e;margin-top:0.15rem;">{service.registrantJurisdiction}{#if service.registrantOrgType != null} · {service.registrantOrgType}{/if}</div>
{/if}
</div>
</div>
<div class="section">
<span class="section-label">Lifecycle</span>
<div style="display:flex;flex-direction:column;gap:0.25rem;font-size:0.8rem;">
<div><span style="color:#484f58;">stage</span> &nbsp; {service.serviceStage}</div>
<div><span style="color:#484f58;">liveness</span> &nbsp; {service.livenessStatus}</div>
{#if service.registeredAt != null}
<div><span style="color:#484f58;">registered</span> &nbsp; {service.registeredAt}</div>
{/if}
{#if service.lastUpdatedAt != null}
<div><span style="color:#484f58;">updated</span> &nbsp; {service.lastUpdatedAt}</div>
{/if}
{#if service.sunsetAt != null}
<div style="color:#f85149;"><span style="color:#f8514988;">sunset</span> &nbsp; {service.sunsetAt}</div>
{/if}
{#if service.migrationGuideUrl != null}
<div><span style="color:#484f58;">migration</span> &nbsp; <a href="{service.migrationGuideUrl}">{service.migrationGuideUrl}</a></div>
{/if}
</div>
</div>
<div class="section">
<span class="section-label">Service ID</span>
<span class="uuid">{service.id}</span>
</div>
</div>
<div class="footer">
<span><a href="/browse">← Back to registry</a></span>
<span><a href="https://api-index.org/services/{service.id}">JSON record</a> · <a href="https://api-index.org">api-index.org</a></span>
</div>
</body>
</html>
@@ -1,48 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>APIX Registry · Error</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: #0d1117;
color: #c9d1d9;
font-family: 'SF Mono', 'Consolas', 'Fira Code', monospace;
font-size: 14px;
line-height: 1.6;
}
a { color: #58a6ff; text-decoration: none; }
a:hover { text-decoration: underline; }
.header {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem 1.5rem;
border-bottom: 1px solid #21262d;
}
.header-logo { color: #8b949e; font-size: 0.8rem; }
.error-body {
padding: 4rem 1.5rem;
text-align: center;
color: #484f58;
}
.error-body h2 { color: #f85149; font-size: 1rem; margin-bottom: 0.75rem; }
.error-body p { margin-bottom: 1.5rem; }
</style>
</head>
<body>
<div class="header">
<span class="header-logo"><a href="/browse">APIX</a></span>
</div>
<div class="error-body">
<h2>error</h2>
<p>{message}</p>
<a href="/browse">← Back to registry</a>
</div>
</body>
</html>
@@ -88,23 +88,6 @@ public class AdminResource {
"expiresAt", sb.expiresAt.toString())).build(); "expiresAt", sb.expiresAt.toString())).build();
} }
@DELETE
@Path("/sandboxes/{uuid}")
@Operation(summary = "Delete a sandbox and all its services (admin)", description = "Requires X-Admin-Key. DEMO-tier sandboxes are protected.")
public Response deleteSandbox(
@HeaderParam("X-Admin-Key") String adminKey,
@PathParam("uuid") String uuidStr) {
requireAdmin(adminKey);
UUID id;
try { id = UUID.fromString(uuidStr); }
catch (IllegalArgumentException e) {
throw new WebApplicationException(
Response.status(404).entity(Map.of("message", "Sandbox not found")).build());
}
sandboxService.deleteSandbox(id);
return Response.noContent().build();
}
private void requireAdmin(String key) { private void requireAdmin(String key) {
if (adminApiKey.isBlank() || !adminApiKey.equals(key)) { if (adminApiKey.isBlank() || !adminApiKey.equals(key)) {
throw new WebApplicationException( throw new WebApplicationException(
@@ -7,7 +7,6 @@ import jakarta.ws.rs.*;
import jakarta.ws.rs.core.*; import jakarta.ws.rs.core.*;
import org.botstandards.apix.common.BsmPayload; import org.botstandards.apix.common.BsmPayload;
import org.botstandards.apix.common.OLevel; import org.botstandards.apix.common.OLevel;
import org.botstandards.apix.common.ServiceBrowsePage;
import org.botstandards.apix.registry.dto.ReplacementsResponse; import org.botstandards.apix.registry.dto.ReplacementsResponse;
import org.botstandards.apix.registry.dto.ServicePatchRequest; import org.botstandards.apix.registry.dto.ServicePatchRequest;
import org.botstandards.apix.registry.dto.ServiceResponse; import org.botstandards.apix.registry.dto.ServiceResponse;
@@ -59,27 +58,6 @@ public class ServiceResource {
.build(); .build();
} }
@GET
@Path("/browse")
@Operation(
summary = "Browse production services",
description = "Returns a paginated list of PRODUCTION services in the public registry. " +
"Optionally filter by capability keyword. No authentication required. " +
"Intended for human-facing portal UIs and developer tooling. " +
"Maximum page size is 100."
)
public ServiceBrowsePage browse(
@Parameter(description = "Optional capability filter (e.g. nlp, translation). Omit to list all production services.", example = "translation")
@QueryParam("capability") String capability,
@Parameter(description = "Zero-based page index.", example = "0")
@QueryParam("page") @DefaultValue("0") int page,
@Parameter(description = "Items per page. Capped at 100.", example = "20")
@QueryParam("size") @DefaultValue("20") int size) {
if (size < 1) size = 1;
if (size > 100) size = 100;
return registryService.browse(capability, page, size);
}
@GET @GET
@Path("/{id}") @Path("/{id}")
@Operation( @Operation(
@@ -19,9 +19,6 @@ import org.botstandards.apix.registry.entity.ServiceEntity;
import org.botstandards.apix.registry.entity.ServiceReplacementEntity; import org.botstandards.apix.registry.entity.ServiceReplacementEntity;
import org.botstandards.apix.registry.entity.ServiceVersionEntity; import org.botstandards.apix.registry.entity.ServiceVersionEntity;
import org.botstandards.apix.common.ServiceBrowsePage;
import org.botstandards.apix.common.ServiceSummaryDto;
import java.time.Instant; import java.time.Instant;
import java.util.*; import java.util.*;
import java.util.stream.Stream; import java.util.stream.Stream;
@@ -256,52 +253,6 @@ public class RegistryService {
return stream.toList(); return stream.toList();
} }
@SuppressWarnings("unchecked")
public ServiceBrowsePage browse(String capability, int page, int size) {
boolean hasCapability = capability != null && !capability.isBlank();
String capClause = hasCapability
? " AND s.bsm_payload @> jsonb_build_object('capabilities', jsonb_build_array(:cap))"
: "";
String whereClause =
"s.registry_status = 'ACTIVE' AND s.service_stage = 'PRODUCTION' AND s.sandbox_id IS NULL"
+ capClause;
Query countQ = em.createNativeQuery(
"SELECT COUNT(*) FROM services s WHERE " + whereClause);
if (hasCapability) countQ.setParameter("cap", capability);
long total = ((Number) countQ.getSingleResult()).longValue();
Query listQ = em.createNativeQuery(
"SELECT s.* FROM services s WHERE " + whereClause
+ " ORDER BY s.registered_at DESC LIMIT :lim OFFSET :off",
ServiceEntity.class)
.setParameter("lim", size)
.setParameter("off", (long) page * size);
if (hasCapability) listQ.setParameter("cap", capability);
List<ServiceSummaryDto> items = ((List<ServiceEntity>) listQ.getResultList()).stream()
.map(this::toSummary)
.toList();
return new ServiceBrowsePage(total, page, size, items);
}
private ServiceSummaryDto toSummary(ServiceEntity e) {
BsmPayload b = e.bsmPayload;
return new ServiceSummaryDto(
e.id,
b.name(),
b.description(),
b.capabilities(),
e.olevel,
e.livenessStatus,
e.serviceStage,
e.registryStatus,
b.endpoint(),
b.registrantName(),
b.registrantJurisdiction()
);
}
public long countAll() { public long countAll() {
return ((Number) em.createNativeQuery( return ((Number) em.createNativeQuery(
"SELECT COUNT(*) FROM services WHERE registry_status = 'ACTIVE' AND sandbox_id IS NULL") "SELECT COUNT(*) FROM services WHERE registry_status = 'ACTIVE' AND sandbox_id IS NULL")
-212
View File
@@ -1,212 +0,0 @@
/**
* APIX Discovery Demo — for OpenClaw
*
* Shows how an agent finds and uses services without hardcoded URLs.
* This is what ClawHub does manually. APIX makes it autonomous.
*
* Requirements: Node 18+ (native fetch, no dependencies)
* Run: node apix-demo.mjs
*/
const REGISTRY_ROOT = 'https://api-index.org';
const SANDBOX_REGISTER = `${REGISTRY_ROOT}/sandbox/register`;
// ─── Helpers ──────────────────────────────────────────────────────────────────
async function get(url) {
const res = await fetch(url, { headers: { Accept: 'application/json' } });
if (!res.ok) throw new Error(`GET ${url}${res.status}`);
return res.json();
}
async function post(url, body, headers = {}) {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...headers },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`POST ${url}${res.status}: ${text}`);
}
return res.json();
}
function line(char = '─', n = 60) { return char.repeat(n); }
function hdr(label) { console.log(`\n${line()}\n ${label}\n${line()}`); }
// ─── Demo ─────────────────────────────────────────────────────────────────────
async function main() {
console.log('\n APIX Discovery Demo');
console.log(' The open agent service registry — api-index.org\n');
// ── Step 1: HATEOAS root ───────────────────────────────────────────────────
hdr('Step 1 Discover the registry root');
const root = await get(REGISTRY_ROOT);
console.log(` Registry: ${root.name}`);
console.log(` Services registered: ${root.stats.registeredServices}`);
console.log(` Navigation links:`);
for (const [rel, link] of Object.entries(root._links)) {
console.log(` ${rel.padEnd(20)} ${link.href}`);
}
console.log('\n → An agent starts here. No hardcoded service URLs anywhere.');
// ── Step 2: Create sandbox ─────────────────────────────────────────────────
hdr('Step 2 Create a sandbox namespace (self-service, instant)');
const reg = await post(SANDBOX_REGISTER, {
name: 'openclaw-apix-demo',
contactEmail: 'demo@openclaw.dev',
location: 'Vienna, AT',
});
const { sandboxId, apiKey, tier, ratePerMinute, expiresAt, _links: sLinks } = reg;
console.log(` Sandbox ID : ${sandboxId}`);
console.log(` Tier : ${tier} | Rate limit: ${ratePerMinute} req/min`);
console.log(` Expires : ${expiresAt}`);
console.log(` Dashboard : ${reg.dashboardUrl ?? sLinks?.self?.href ?? '—'}`);
console.log('\n → Each sandbox is isolated. Any agent or developer can create one instantly.');
const servicesHref = sLinks.services?.href ?? `${REGISTRY_ROOT}/sandbox/${sandboxId}/services`;
const searchHrefTpl = sLinks.servicesSearch?.href
?? `${REGISTRY_ROOT}/sandbox/${sandboxId}/services{?capability,stage}`;
// ── Step 3: Register services (BSM manifests) ──────────────────────────────
hdr('Step 3 Register three carrier services (BSM manifests)');
const carriers = [
{
name: 'NordLogistik Economy EU',
description: 'Economy EU road & rail freight. 5-day delivery. DE/AT/CH specialist.',
endpoint: 'https://demo.api-index.org/carriers/nord/quote',
capabilities: ['shipping-quote', 'logistics', 'eu-delivery'],
registrantEmail: 'api@nordlogistik.example',
registrantName: 'NordLogistik GmbH',
registrantJurisdiction:'DE',
registrantOrgType: 'COMMERCIAL',
pricing: { billingModel: 'PER_CALL', pricePerCall: 0.012, currency: 'APX', billingUnit: 'per-call' },
bsmVersion: '0.1',
serviceStage: 'PRODUCTION',
},
{
name: 'SwiftCargo Express',
description: 'Global express shipping. 2-day delivery to major hubs worldwide.',
endpoint: 'https://demo.api-index.org/carriers/swift/quote',
capabilities: ['shipping-quote', 'express-delivery', 'global-logistics'],
registrantEmail: 'api@swiftcargo.example',
registrantName: 'SwiftCargo Ltd',
registrantJurisdiction:'GB',
registrantOrgType: 'COMMERCIAL',
pricing: { billingModel: 'PER_CALL', pricePerCall: 0.035, currency: 'APX', billingUnit: 'per-call' },
bsmVersion: '0.1',
serviceStage: 'PRODUCTION',
},
{
name: 'PacRim Express',
description: 'Cost-optimised Asia-Pacific shipping. SEA, JP, KR, AU routes.',
endpoint: 'https://demo.api-index.org/carriers/pacrim/quote',
capabilities: ['shipping-quote', 'logistics', 'asia-pacific-delivery'],
registrantEmail: 'api@pacrim.example',
registrantName: 'PacRim Express Pte',
registrantJurisdiction:'SG',
registrantOrgType: 'COMMERCIAL',
pricing: { billingModel: 'PER_CALL', pricePerCall: 0.018, currency: 'APX', billingUnit: 'per-call' },
bsmVersion: '0.1',
serviceStage: 'PRODUCTION',
},
];
for (const manifest of carriers) {
await post(servicesHref, manifest, { 'X-Api-Key': apiKey });
console.log(` ✓ Registered: ${manifest.name} (${manifest.registrantJurisdiction})`);
}
console.log('\n → Any provider self-registers. The agent discovers them automatically.');
// ── Step 4: Agent discovers carriers ──────────────────────────────────────
hdr('Step 4 Agent queries: "who can provide shipping-quote?"');
// Strip the URI template expression {?...} and build the URL properly
const searchBase = searchHrefTpl.replace(/\{[^}]+\}/, '');
const searchUrl = `${searchBase}?capability=shipping-quote&stage=PRODUCTION`;
console.log(` Query: GET ${searchUrl}\n`);
const found = await get(searchUrl);
console.log(` Found ${found.length} carrier service(s):\n`);
for (const svc of found) {
const p = svc.pricing;
const price = p ? `${p.pricePerCall} ${p.currency}/${p.billingUnit}` : 'unknown';
console.log(` ┌─ ${svc.name}`);
console.log(` │ Jurisdiction : ${svc.registrantJurisdiction}`);
console.log(` │ Capabilities : ${svc.capabilities?.join(', ')}`);
console.log(` │ Price : ${price}`);
console.log(` └─ Endpoint : ${svc.endpoint}`);
console.log();
}
console.log(' → The agent has all three endpoints. It never knew about them before this query.');
// ── Step 5: Call discovered services in parallel ───────────────────────────
hdr('Step 5 Call all carriers in parallel — choose the best quote');
const shipment = { from: 'Munich, DE', to: 'London, GB', weightKg: 2.5, dimensions: '30x20x15cm' };
console.log(` Shipment: ${JSON.stringify(shipment)}\n`);
// The agent uses endpoints discovered in Step 4 — zero hardcoded carrier URLs
const quoteRequests = found.map(async svc => {
const start = Date.now();
try {
const quote = await post(svc.endpoint, shipment).catch(() => mockQuote(svc.name));
return { svcName: svc.name, quote, ms: Date.now() - start };
} catch {
return { svcName: svc.name, quote: mockQuote(svc.name), ms: Date.now() - start };
}
});
const results = await Promise.all(quoteRequests);
results.sort((a, b) => a.quote.price.amount - b.quote.price.amount);
for (const { svcName, quote } of results) {
const best = results[0].svcName === svcName ? ' ← best price' : '';
console.log(` ${svcName.padEnd(28)} ${quote.price.amount.toFixed(2)} ${quote.price.currency} · ${quote.estimatedDays}d${best}`);
}
const winner = results[0];
console.log(`\n Selected: ${winner.svcName} (${winner.quote.price.amount.toFixed(2)} ${winner.quote.price.currency})`);
// ── Summary ────────────────────────────────────────────────────────────────
hdr('Summary');
console.log(' What just happened:\n');
console.log(' 1. Started at a single URL: https://api-index.org');
console.log(' 2. HATEOAS navigation — followed links, never guessed URLs');
console.log(' 3. Created an isolated sandbox in one POST');
console.log(' 4. Three carriers registered their own BSM manifests');
console.log(' 5. Agent queried capability="shipping-quote" — found all three');
console.log(' 6. Called them in parallel, picked the best quote');
console.log();
console.log(' No hardcoded carrier URLs. No ClawHub lookup. No human in the loop.');
console.log();
console.log(' When a new carrier registers in APIX, every OpenClaw agent that');
console.log(' queries "shipping-quote" discovers it automatically — on the next query.');
console.log();
console.log(` Sandbox dashboard: ${reg.dashboardUrl ?? `https://www.api-index.org/sandbox/${sandboxId}`}`);
console.log();
}
// Fallback mock quotes (in case demo endpoints are not reachable)
function mockQuote(name) {
const prices = {
'NordLogistik Economy EU': { amount: 12.40, currency: 'APX' },
'SwiftCargo Express': { amount: 35.00, currency: 'APX' },
'PacRim Express': { amount: 18.20, currency: 'APX' },
};
const days = { 'NordLogistik Economy EU': 5, 'SwiftCargo Express': 2, 'PacRim Express': 7 };
return {
carrier: name,
price: prices[name] ?? { amount: 20.00, currency: 'APX' },
estimatedDays: days[name] ?? 5,
trackingAvailable: true,
};
}
main().catch(err => {
console.error('\n Error:', err.message);
process.exit(1);
});
-92
View File
@@ -1,92 +0,0 @@
/**
* Sandbox cleanup — deletes all non-protected sandboxes from the APIX registry.
*
* Protected sandboxes (never deleted):
* - tier = DEMO (server-enforced, returns 403)
* - name matching KEEP_NAMES below
*
* Usage:
* APIX_ADMIN_KEY=<key> node scripts/cleanup-sandboxes.mjs [--dry-run]
*
* The admin key is in /opt/apix/.env on the VPS (APIX_API_KEY=...).
*/
const REGISTRY_ROOT = 'https://api-index.org';
const KEEP_NAMES = new Set(['apix-demo-ecosystem']);
const DRY_RUN = process.argv.includes('--dry-run');
const ADMIN_KEY = process.env.APIX_ADMIN_KEY;
if (!ADMIN_KEY) {
console.error('Error: set APIX_ADMIN_KEY environment variable');
process.exit(1);
}
async function adminGet(path) {
const res = await fetch(`${REGISTRY_ROOT}${path}`, {
headers: { Accept: 'application/json', 'X-Admin-Key': ADMIN_KEY },
});
if (!res.ok) throw new Error(`GET ${path}${res.status}: ${await res.text()}`);
return res.json();
}
async function adminDelete(path) {
const res = await fetch(`${REGISTRY_ROOT}${path}`, {
method: 'DELETE',
headers: { 'X-Admin-Key': ADMIN_KEY },
});
if (res.status === 204) return;
throw new Error(`DELETE ${path}${res.status}: ${await res.text()}`);
}
async function main() {
console.log(`\n APIX Sandbox Cleanup${DRY_RUN ? ' [DRY RUN — nothing will be deleted]' : ''}\n`);
let page = 0;
const toDelete = [];
const keep = [];
while (true) {
const data = await adminGet(`/admin/sandboxes?page=${page}&size=50`);
const sandboxes = data.sandboxes ?? data.items ?? data;
if (!Array.isArray(sandboxes) || sandboxes.length === 0) break;
for (const sb of sandboxes) {
const name = sb.name ?? sb.sandboxName ?? '—';
const tier = sb.tier ?? '?';
const id = sb.sandboxId ?? sb.id;
if (KEEP_NAMES.has(name) || tier === 'DEMO') {
keep.push({ id, name, tier });
} else {
toDelete.push({ id, name, tier });
}
}
if (sandboxes.length < 50) break;
page++;
}
console.log(` Keeping (${keep.length}):`);
for (const sb of keep) console.log(`${sb.name.padEnd(36)} ${sb.tier} ${sb.id}`);
console.log(`\n Deleting (${toDelete.length}):`);
for (const sb of toDelete) {
const label = `${sb.name.padEnd(36)} ${sb.tier} ${sb.id}`;
if (DRY_RUN) {
console.log(`${label} [skipped — dry run]`);
} else {
try {
await adminDelete(`/admin/sandboxes/${sb.id}`);
console.log(`${label} deleted`);
} catch (err) {
console.log(` ! ${label} ERROR: ${err.message}`);
}
}
}
console.log('\n Done.\n');
}
main().catch(err => {
console.error('\n Error:', err.message);
process.exit(1);
});
-23
View File
@@ -1,23 +0,0 @@
$ErrorActionPreference = "Stop"
$VPS = "deploy@204.168.156.179"
$MVN = "C:\Users\Anwender\IdeaProjects\Claude\bot-service-index\apix-mvp"
Set-Location $MVN
Write-Host "Copying web content..."
Copy-Item "$MVN\..\web\api-index.org\index.html" `
"$MVN\apix-portal\src\main\resources\META-INF\resources\index.html" -Force
Write-Host "Building portal..."
mvn clean package -DskipTests -pl apix-portal -am -q
Write-Host "Transferring to VPS..."
scp "$MVN\apix-portal\target\quarkus-app\app\apix-portal-1.0-SNAPSHOT.jar" `
"${VPS}:/opt/apix/apix-portal/target/quarkus-app/app/apix-portal-1.0-SNAPSHOT.jar"
scp "$MVN\apix-portal\target\quarkus-app\quarkus\quarkus-application.dat" `
"${VPS}:/opt/apix/apix-portal/target/quarkus-app/quarkus/quarkus-application.dat"
Write-Host "Rebuilding image and restarting portal..."
ssh $VPS "cd /opt/apix && docker build -f infra/Dockerfile.portal -t apix-portal:latest . -q && docker compose -f infra/docker-compose.yml --env-file .env up -d portal"
Write-Host "Done. https://www.api-index.org/"
-50
View File
@@ -1,50 +0,0 @@
$ErrorActionPreference = "Stop"
$VPS = "deploy@204.168.156.179"
$MVN = "C:\Users\Anwender\IdeaProjects\Claude\bot-service-index\apix-mvp"
$QUARKUS_APP = "$MVN\apix-registry\target\quarkus-app"
$VPS_DEST = "/opt/apix/apix-registry/target/quarkus-app"
Set-Location $MVN
Write-Host "Building registry..."
mvn clean package -DskipTests -pl apix-registry -am -q
Write-Host "Syncing quarkus-app to VPS (rsync, only changed files)..."
$rsyncSrc = $QUARKUS_APP -replace '^([A-Za-z]):\\', '/cygdrive/$1/' -replace '\\', '/'
rsync -az --delete `
"${rsyncSrc}/" `
"${VPS}:${VPS_DEST}/"
Write-Host "Rebuilding Docker image on VPS..."
ssh $VPS 'cd /opt/apix && docker build -f infra/Dockerfile.registry -t apix-registry:latest . -q'
Write-Host "Rolling restart: registry-a..."
ssh $VPS 'cd /opt/apix && docker compose -f infra/docker-compose.yml --env-file .env up -d --no-deps registry-a'
Write-Host "Waiting for registry-a to become healthy..."
$elapsed = 0
do {
Start-Sleep -Seconds 5; $elapsed += 5
$status = ssh $VPS 'docker inspect infra-registry-a-1 --format "{{.State.Health.Status}}" 2>/dev/null || echo missing'
Write-Host " registry-a: $status (${elapsed}s)"
} while ($status -ne "healthy" -and $elapsed -lt 120)
if ($status -ne "healthy") {
Write-Host "ERROR: registry-a did not become healthy -- aborting, registry-b untouched"
ssh $VPS 'docker logs infra-registry-a-1 --tail 20 2>&1'
exit 1
}
Write-Host "Restarting registry-b..."
ssh $VPS 'cd /opt/apix && docker compose -f infra/docker-compose.yml --env-file .env up -d --no-deps registry-b'
Write-Host "Waiting for registry-b..."
$elapsed = 0
do {
Start-Sleep -Seconds 5; $elapsed += 5
$statusB = ssh $VPS 'docker inspect infra-registry-b-1 --format "{{.State.Health.Status}}" 2>/dev/null || echo missing'
Write-Host " registry-b: $statusB (${elapsed}s)"
} while ($statusB -ne "healthy" -and $elapsed -lt 120)
Write-Host "registry-a: $status registry-b: $statusB"
Write-Host "Done. https://api-index.org/"
-25
View File
@@ -1,25 +0,0 @@
#!/bin/bash
set -e
cat > /opt/apix/.env << EOF
APIX_DB_USER=apix
APIX_DB_PASSWORD=$(openssl rand -base64 32)
APIX_DB_NAME=apix
APIX_DB_PORT=5432
APIX_API_KEY=$(openssl rand -hex 32)
APIX_REGISTRY_BASE_URL=https://api-index.org
APIX_REGISTRY_NAME=APIX Registry
GLEIF_API_URL=https://api.gleif.org/api/v1
OPENCORPORATES_API_KEY=
APIX_MAIL_SIGNING_PRIVATE_KEY=
APIX_MAIL_SIGNING_PUBLIC_KEY=
APIX_MAIL_SIGNING_KID=2026-05
SPIDER_INTERVAL_MINUTES=15
GRAFANA_ADMIN_PASSWORD=$(openssl rand -base64 16)
GRAFANA_ROOT_URL=http://localhost:3000
LOG_LEVEL=INFO
EOF
chmod 600 /opt/apix/.env
echo "Generated /opt/apix/.env:"
cat /opt/apix/.env
-603
View File
@@ -1,603 +0,0 @@
{
"__inputs": [
{
"name": "DS_LOKI",
"label": "Loki",
"description": "Loki receiving Bunny.net CDN access logs via Promtail (promtail-cdn-logs.yaml). Shows ALL requests including CDN cache hits.",
"type": "datasource",
"pluginId": "loki",
"pluginName": "Loki"
},
{
"name": "DS_PROMETHEUS",
"label": "Prometheus",
"description": "Prometheus scraping the APIX registry /q/metrics. Shows only cache misses that reached the origin.",
"type": "datasource",
"pluginId": "prometheus",
"pluginName": "Prometheus"
}
],
"__requires": [
{ "type": "grafana", "id": "grafana", "name": "Grafana", "version": "10.0.0" },
{ "type": "panel", "id": "timeseries", "name": "Time series", "version": "" },
{ "type": "panel", "id": "stat", "name": "Stat", "version": "" },
{ "type": "panel", "id": "gauge", "name": "Gauge", "version": "" },
{ "type": "panel", "id": "bargauge", "name": "Bar gauge", "version": "" },
{ "type": "panel", "id": "piechart", "name": "Pie chart", "version": "" },
{ "type": "panel", "id": "barchart", "name": "Bar chart", "version": "" },
{ "type": "datasource", "id": "loki", "name": "Loki", "version": "" },
{ "type": "datasource", "id": "prometheus", "name": "Prometheus", "version": "" }
],
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": { "type": "grafana", "uid": "-- Grafana --" },
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"description": "Live APIX Registry traffic — OpenClaw community demo. Loki panels cover ALL requests (CDN cache hits + misses) via Bunny.net syslog forwarding. Prometheus panels cover origin cache misses only. Refresh every 5s.",
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 1,
"id": null,
"links": [],
"refresh": "5s",
"schemaVersion": 38,
"tags": ["apix", "demo", "openclaw"],
"time": { "from": "now-15m", "to": "now" },
"timepicker": {},
"timezone": "browser",
"title": "APIX Registry — OpenClaw Demo",
"uid": "apix-demo-v1",
"version": 1,
"templating": {
"list": [
{
"current": {},
"hide": 0,
"includeAll": false,
"label": "Loki datasource",
"multi": false,
"name": "DS_LOKI",
"options": [],
"query": "loki",
"refresh": 1,
"type": "datasource"
},
{
"current": {},
"hide": 0,
"includeAll": false,
"label": "Prometheus datasource",
"multi": false,
"name": "DS_PROMETHEUS",
"options": [],
"query": "prometheus",
"refresh": 1,
"type": "datasource"
}
]
},
"panels": [
{
"id": 1,
"type": "stat",
"title": "CDN Searches",
"description": "Total /services + /devices requests in the selected time range, including CDN cache hits.",
"gridPos": { "x": 0, "y": 0, "w": 6, "h": 3 },
"datasource": { "type": "loki", "uid": "${DS_LOKI}" },
"options": {
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
"orientation": "auto",
"textMode": "auto",
"colorMode": "background",
"graphMode": "none",
"justifyMode": "auto"
},
"fieldConfig": {
"defaults": {
"color": { "mode": "thresholds" },
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "#1f60c4", "value": null },
{ "color": "#37872d", "value": 500 },
{ "color": "#8f3bb8", "value": 10000 }
]
},
"unit": "short",
"noValue": "0"
}
},
"targets": [
{
"refId": "A",
"datasource": { "type": "loki", "uid": "${DS_LOKI}" },
"editorMode": "code",
"expr": "sum(count_over_time({job=\"apix-cdn\", req_path=~\"/services.*|/devices.*\"}[$__range]))",
"instant": true,
"queryType": "instant",
"legendFormat": ""
}
]
},
{
"id": 2,
"type": "gauge",
"title": "Cache Hit Ratio",
"description": "Percentage of CDN requests served from edge cache without hitting the origin VPS.",
"gridPos": { "x": 6, "y": 0, "w": 6, "h": 3 },
"datasource": { "type": "loki", "uid": "${DS_LOKI}" },
"options": {
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
"orientation": "auto",
"showThresholdLabels": false,
"showThresholdMarkers": true,
"minVizWidth": 75,
"minVizHeight": 75
},
"fieldConfig": {
"defaults": {
"color": { "mode": "thresholds" },
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "#c4162a", "value": null },
{ "color": "#ff9830", "value": 50 },
{ "color": "#37872d", "value": 80 }
]
},
"unit": "percent",
"min": 0,
"max": 100,
"noValue": "—"
}
},
"targets": [
{
"refId": "A",
"datasource": { "type": "loki", "uid": "${DS_LOKI}" },
"editorMode": "code",
"expr": "sum(count_over_time({job=\"apix-cdn\", cache_status=\"HIT\"}[$__range])) / sum(count_over_time({job=\"apix-cdn\"}[$__range])) * 100",
"instant": true,
"queryType": "instant",
"legendFormat": ""
}
]
},
{
"id": 3,
"type": "stat",
"title": "Active PoPs",
"description": "Number of distinct Bunny.net edge PoPs that served traffic in the selected time range.",
"gridPos": { "x": 12, "y": 0, "w": 6, "h": 3 },
"datasource": { "type": "loki", "uid": "${DS_LOKI}" },
"options": {
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
"orientation": "auto",
"textMode": "auto",
"colorMode": "background",
"graphMode": "none",
"justifyMode": "auto"
},
"fieldConfig": {
"defaults": {
"color": { "mode": "thresholds" },
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "#1f60c4", "value": null },
{ "color": "#37872d", "value": 3 },
{ "color": "#8f3bb8", "value": 8 }
]
},
"unit": "short",
"noValue": "0"
}
},
"targets": [
{
"refId": "A",
"datasource": { "type": "loki", "uid": "${DS_LOKI}" },
"editorMode": "code",
"expr": "count(sum by (cdn_pop) (count_over_time({job=\"apix-cdn\"}[$__range])))",
"instant": true,
"queryType": "instant",
"legendFormat": ""
}
]
},
{
"id": 4,
"type": "stat",
"title": "Searches / sec",
"description": "Current CDN search request rate across all edge PoPs.",
"gridPos": { "x": 18, "y": 0, "w": 6, "h": 3 },
"datasource": { "type": "loki", "uid": "${DS_LOKI}" },
"options": {
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
"orientation": "auto",
"textMode": "auto",
"colorMode": "background",
"graphMode": "area"
},
"fieldConfig": {
"defaults": {
"color": { "mode": "thresholds" },
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "#1f60c4", "value": null },
{ "color": "#37872d", "value": 1 },
{ "color": "#f2cc0c", "value": 10 },
{ "color": "#8f3bb8", "value": 100 }
]
},
"unit": "reqps",
"decimals": 2,
"noValue": "0"
}
},
"targets": [
{
"refId": "A",
"datasource": { "type": "loki", "uid": "${DS_LOKI}" },
"editorMode": "code",
"expr": "sum(rate({job=\"apix-cdn\", req_path=~\"/services.*|/devices.*\"}[$__rate_interval]))",
"instant": false,
"queryType": "range",
"legendFormat": ""
}
]
},
{
"id": 10,
"type": "row",
"title": "Capability Traffic — Real-Time",
"collapsed": false,
"gridPos": { "x": 0, "y": 3, "w": 24, "h": 1 },
"panels": []
},
{
"id": 5,
"type": "timeseries",
"title": "Requests per Second by Capability",
"description": "Live stream of capability search rates across all CDN PoPs. Each line = one capability being queried. This includes CDN cache hits — the spike you see is the real community traffic.",
"gridPos": { "x": 0, "y": 4, "w": 24, "h": 8 },
"datasource": { "type": "loki", "uid": "${DS_LOKI}" },
"options": {
"tooltip": { "mode": "multi", "sort": "desc" },
"legend": {
"displayMode": "table",
"placement": "right",
"calcs": ["lastNotNull", "max", "sum"]
}
},
"fieldConfig": {
"defaults": {
"color": { "mode": "palette-classic" },
"custom": {
"lineWidth": 2,
"fillOpacity": 8,
"gradientMode": "opacity",
"showPoints": "never",
"spanNulls": false
},
"unit": "reqps"
}
},
"targets": [
{
"refId": "A",
"datasource": { "type": "loki", "uid": "${DS_LOKI}" },
"editorMode": "code",
"expr": "sum by (capability) (rate({job=\"apix-cdn\", capability!=\"\"}[$__rate_interval]))",
"instant": false,
"queryType": "range",
"legendFormat": "{{capability}}"
}
]
},
{
"id": 20,
"type": "row",
"title": "Distribution",
"collapsed": false,
"gridPos": { "x": 0, "y": 12, "w": 24, "h": 1 },
"panels": []
},
{
"id": 6,
"type": "bargauge",
"title": "Top 10 Capabilities",
"description": "Most queried capabilities in the time range (all CDN traffic). These are the API categories the OpenClaw community is actively using.",
"gridPos": { "x": 0, "y": 13, "w": 15, "h": 8 },
"datasource": { "type": "loki", "uid": "${DS_LOKI}" },
"options": {
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
"orientation": "horizontal",
"displayMode": "gradient",
"valueMode": "color",
"showUnfilled": true,
"minVizWidth": 8,
"minVizHeight": 16,
"namePlacement": "left",
"legendDisplayMode": "hidden"
},
"fieldConfig": {
"defaults": {
"color": { "mode": "continuous-BlPu" },
"thresholds": {
"mode": "absolute",
"steps": [{ "color": "blue", "value": null }]
},
"unit": "short"
}
},
"targets": [
{
"refId": "A",
"datasource": { "type": "loki", "uid": "${DS_LOKI}" },
"editorMode": "code",
"expr": "topk(10, sum by (capability) (count_over_time({job=\"apix-cdn\", capability!=\"\"}[$__range])))",
"instant": true,
"queryType": "instant",
"legendFormat": "{{capability}}"
}
]
},
{
"id": 7,
"type": "piechart",
"title": "Cache Status",
"description": "HIT = served from CDN edge, no origin load. MISS = edge cache cold, forwarded to origin. BYPASS = edge rule prevented caching (/q/* health paths).",
"gridPos": { "x": 15, "y": 13, "w": 9, "h": 8 },
"datasource": { "type": "loki", "uid": "${DS_LOKI}" },
"options": {
"pieType": "donut",
"displayLabels": ["name", "percent"],
"legend": {
"displayMode": "table",
"placement": "right",
"calcs": ["sum"]
}
},
"fieldConfig": {
"defaults": {
"color": { "mode": "fixed" },
"unit": "short"
},
"overrides": [
{
"matcher": { "id": "byName", "options": "HIT" },
"properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "#37872d" } }]
},
{
"matcher": { "id": "byName", "options": "MISS" },
"properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "#ff9830" } }]
},
{
"matcher": { "id": "byName", "options": "BYPASS" },
"properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "#6e7279" } }]
}
]
},
"targets": [
{
"refId": "A",
"datasource": { "type": "loki", "uid": "${DS_LOKI}" },
"editorMode": "code",
"expr": "sum by (cache_status) (count_over_time({job=\"apix-cdn\"}[$__range]))",
"instant": true,
"queryType": "instant",
"legendFormat": "{{cache_status}}"
}
]
},
{
"id": 30,
"type": "row",
"title": "Geographic Distribution",
"collapsed": false,
"gridPos": { "x": 0, "y": 21, "w": 24, "h": 1 },
"panels": []
},
{
"id": 8,
"type": "barchart",
"title": "Requests by CDN PoP",
"description": "Which Bunny.net edge PoPs served traffic. PoP names come from the syslog HOSTNAME field (e.g. sg.b-cdn.net = Singapore, hk.b-cdn.net = Hong Kong). Asia traffic is visible here even though the origin VPS is in Europe.",
"gridPos": { "x": 0, "y": 22, "w": 16, "h": 7 },
"datasource": { "type": "loki", "uid": "${DS_LOKI}" },
"options": {
"orientation": "horizontal",
"xTickLabelRotation": 0,
"xTickLabelMaxLength": 30,
"groupWidth": 0.7,
"barWidth": 0.97,
"fillOpacity": 80,
"gradientMode": "none",
"tooltip": { "mode": "single", "sort": "none" },
"legend": { "displayMode": "hidden", "placement": "bottom" }
},
"fieldConfig": {
"defaults": {
"color": { "mode": "continuous-BlYlRd" },
"custom": { "fillOpacity": 80 },
"unit": "short"
}
},
"targets": [
{
"refId": "A",
"datasource": { "type": "loki", "uid": "${DS_LOKI}" },
"editorMode": "code",
"expr": "topk(15, sum by (cdn_pop) (count_over_time({job=\"apix-cdn\"}[$__range])))",
"instant": true,
"queryType": "instant",
"legendFormat": "{{cdn_pop}}"
}
]
},
{
"id": 9,
"type": "piechart",
"title": "/services vs /devices",
"description": "Split of AI agent API searches (/services) vs IoT device discovery (/devices). As the OpenClaw community grows, /services should dominate initially, with /devices rising as embedded systems adopt APIX.",
"gridPos": { "x": 16, "y": 22, "w": 8, "h": 7 },
"datasource": { "type": "loki", "uid": "${DS_LOKI}" },
"options": {
"pieType": "pie",
"displayLabels": ["name", "percent"],
"legend": {
"displayMode": "list",
"placement": "bottom",
"calcs": []
}
},
"fieldConfig": {
"defaults": { "unit": "short" },
"overrides": [
{
"matcher": { "id": "byName", "options": "/services" },
"properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "#1f60c4" } }]
},
{
"matcher": { "id": "byName", "options": "/devices" },
"properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "#37872d" } }]
}
]
},
"targets": [
{
"refId": "A",
"datasource": { "type": "loki", "uid": "${DS_LOKI}" },
"editorMode": "code",
"expr": "sum by (req_path) (count_over_time({job=\"apix-cdn\", req_path=~\"/services|/devices\"}[$__range]))",
"instant": true,
"queryType": "instant",
"legendFormat": "{{req_path}}"
}
]
},
{
"id": 40,
"type": "row",
"title": "Origin Metrics (cache misses only — Prometheus)",
"collapsed": false,
"gridPos": { "x": 0, "y": 29, "w": 24, "h": 1 },
"panels": []
},
{
"id": 11,
"type": "timeseries",
"title": "Avg Results per Search (origin)",
"description": "Average result set size per capability search, measured at origin. Low values mean a capability is registered but has few services. Zero means no services are registered for that capability yet — demand without supply.",
"gridPos": { "x": 0, "y": 30, "w": 12, "h": 7 },
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
"options": {
"tooltip": { "mode": "multi", "sort": "desc" },
"legend": {
"displayMode": "table",
"placement": "right",
"calcs": ["lastNotNull", "mean"]
}
},
"fieldConfig": {
"defaults": {
"color": { "mode": "palette-classic" },
"custom": {
"lineWidth": 2,
"fillOpacity": 5,
"showPoints": "never",
"spanNulls": true
},
"unit": "short",
"decimals": 1
}
},
"targets": [
{
"refId": "A",
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
"expr": "rate(apix_search_result_count_sum{resource=\"services\"}[$__rate_interval]) / rate(apix_search_result_count_count{resource=\"services\"}[$__rate_interval])",
"legendFormat": "services",
"instant": false,
"range": true
},
{
"refId": "B",
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
"expr": "rate(apix_search_result_count_sum{resource=\"devices\"}[$__rate_interval]) / rate(apix_search_result_count_count{resource=\"devices\"}[$__rate_interval])",
"legendFormat": "devices",
"instant": false,
"range": true
}
]
},
{
"id": 12,
"type": "timeseries",
"title": "Origin Search Rate by Capability (cache misses)",
"description": "Prometheus counters at origin — only incremented on CDN cache misses. The ratio between this panel and the Loki 'Requests/sec' panel above shows the CDN offload factor: if origin sees 1 req/s and Loki shows 50 req/s, the CDN is absorbing 98% of traffic.",
"gridPos": { "x": 12, "y": 30, "w": 12, "h": 7 },
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
"options": {
"tooltip": { "mode": "multi", "sort": "desc" },
"legend": {
"displayMode": "table",
"placement": "right",
"calcs": ["lastNotNull", "max"]
}
},
"fieldConfig": {
"defaults": {
"color": { "mode": "palette-classic" },
"custom": {
"lineWidth": 1,
"fillOpacity": 4,
"showPoints": "never",
"spanNulls": true,
"lineStyle": { "fill": "dash", "dash": [4, 4] }
},
"unit": "reqps",
"decimals": 3
}
},
"targets": [
{
"refId": "A",
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
"expr": "sum by (capability) (rate(apix_search_services_total[$__rate_interval]))",
"legendFormat": "{{capability}}",
"instant": false,
"range": true
}
]
}
]
}
-116
View File
@@ -1,116 +0,0 @@
# Promtail config: Bunny.net CDN access logs → Loki
#
# Deploy on the same VPS as the APIX registry (or any host reachable from
# the internet on port 5514). Bunny.net streams one TCP Syslog line per
# request — including CDN cache hits — giving live telemetry that the
# origin Prometheus metrics alone cannot provide.
#
# Setup:
# 1. Install Promtail: https://grafana.com/docs/loki/latest/clients/promtail/installation/
# 2. Copy this file to /etc/promtail/cdn-logs.yaml
# 3. Fill in LOKI_PUSH_URL, LOKI_USERNAME, LOKI_PASSWORD below
# 4. Open TCP 5514 in the VPS firewall
# 5. systemctl enable --now promtail
# 6. Run setup-bunnynet.sh with SYSLOG_HOST=<this-vps-ip>
#
# Verify stream is working:
# curl -s https://api-index.org/services?capability=nlp
# journalctl -u promtail -f # should show the log line within 1-2 seconds
#
# Grafana dashboard queries (LogQL):
# Capability hit rate (live):
# count_over_time({job="apix-cdn"} | label_format capability="capability" [1m])
# Cache ratio:
# sum by (cache_status) (count_over_time({job="apix-cdn"}[5m]))
# Zero-result detection (requires apix.search.result_count from Prometheus):
# use a mixed datasource panel combining Loki counts with Prometheus summaries
server:
http_listen_port: 9080
grpc_listen_port: 0
log_level: info
clients:
- url: https://LOKI_PUSH_URL/loki/api/v1/push # e.g. logs-prod-xxx.grafana.net/loki/api/v1/push
basic_auth:
username: "LOKI_USERNAME" # Grafana Cloud: stack user ID (numeric)
password: "LOKI_PASSWORD" # Grafana Cloud: service account token (logs:write scope)
positions:
filename: /tmp/promtail-cdn-positions.yaml
scrape_configs:
- job_name: apix-cdn-logs
syslog:
listen_address: 0.0.0.0:5514
label_structured_data: false
labels:
job: apix-cdn
component: bunnynet
relabel_configs:
# Bunny.net includes the PoP hostname in the syslog HOSTNAME field
# e.g. "pa.b-cdn.net" (Paris), "sg.b-cdn.net" (Singapore)
# This is how PoPs are visible even on cache hits that never reach origin.
- source_labels: [__syslog_message_hostname]
target_label: cdn_pop
pipeline_stages:
# ── Parse Apache Combined Log Format + Bunny.net cache status suffix ──
# Line: 1.2.3.x - - [12/May/2026:10:00:00 +0000] "GET /services?cap=nlp HTTP/1.1" 200 1234 "-" "Agent/1.0" HIT
- regex:
expression: '"(?P<http_method>[A-Z]+) (?P<full_path>[^" ]+) HTTP/[^"]*" (?P<http_status>\d+) \d+ "[^"]*" "[^"]*" (?P<cache_status>\S+)$'
- labels:
http_method:
http_status:
cache_status: # HIT | MISS | BYPASS — primary cache ratio signal
# ── Split path from query string ──────────────────────────────────────
- regex:
source: full_path
expression: '^(?P<req_path>[^?]+)(\?(?P<query_string>.+))?$'
- labels:
req_path: # /services /devices /devices/{id}/replacements
# ── Extract search parameters ─────────────────────────────────────────
# capability is the primary analytics dimension
- regex:
source: query_string
expression: '(?:^|&)capability=(?P<capability>[^&]*)'
- labels:
capability: # nlp | translation | speech-to-text | … | (empty = nav doc)
- regex:
source: query_string
expression: '(?:^|&)deviceClass=(?P<device_class>[^&]*)'
- labels:
device_class: # sensor | actuator | gateway | …
- regex:
source: query_string
expression: '(?:^|&)protocol=(?P<protocol>[^&]*)'
- labels:
protocol: # MQTT | AMQP | HTTP | …
# stage only appears explicitly when non-PRODUCTION (canonical form omits it)
- regex:
source: query_string
expression: '(?:^|&)stage=(?P<stage>[^&]*)'
- labels:
stage: # STAGING | DEVELOPMENT — absent means PRODUCTION
# ── Drop Quarkus internal paths — never cache, no analytics value ─────
- drop:
source: req_path
expression: '^/q/'
# ── Timestamp from log line (keeps Loki time accurate) ────────────────
- timestamp:
source: __syslog_message_timestamp
format: RFC3339
-225
View File
@@ -1,225 +0,0 @@
#!/usr/bin/env bash
# Download Bunny.net access logs and produce a capability frequency report.
#
# This is the only way to see CDN-cached request frequency — the origin
# Prometheus metrics only see cache misses. A weekly cron run pushing the
# output to Grafana covers >99% of analysis needs without real-time overhead.
#
# What this script answers:
# - Which capabilities are queried most often (all requests, including hits)?
# - Which device class / protocol combinations are active?
# - What is the cache hit ratio per endpoint?
# - Which full query string combinations are most common?
#
# Usage:
# BUNNYNET_API_KEY=your-key PULL_ZONE_ID=12345 ./scripts/query-report.sh
# BUNNYNET_API_KEY=your-key PULL_ZONE_ID=12345 DAYS=30 ./scripts/query-report.sh
#
# Output:
# Human-readable report to stdout.
# With PROMETHEUS_PUSH_URL set, also pushes counters to a Prometheus
# Pushgateway (e.g. your dedicated analytics Grafana stack) so the weekly
# numbers become a time-series rather than a point-in-time dump.
#
# PROMETHEUS_PUSH_URL=https://pushgateway.your-grafana.example/metrics/job/apix-cdn-report \
# BUNNYNET_API_KEY=your-key PULL_ZONE_ID=12345 ./scripts/query-report.sh
#
# Prerequisites: curl, gzip, awk (any POSIX awk)
set -euo pipefail
BUNNYNET_API_KEY="${BUNNYNET_API_KEY:?BUNNYNET_API_KEY is required}"
PULL_ZONE_ID="${PULL_ZONE_ID:-$(cat .bunnynet-pull-zone-id 2>/dev/null || echo '')}"
PULL_ZONE_ID="${PULL_ZONE_ID:?PULL_ZONE_ID is required (or run setup-bunnynet.sh first)}"
DAYS="${DAYS:-7}"
PROMETHEUS_PUSH_URL="${PROMETHEUS_PUSH_URL:-}"
LOGGING_BASE="https://logging.bunnycdn.com"
WORK=$(mktemp -d)
trap 'rm -rf "$WORK"' EXIT
touch "${WORK}/combined.log"
# ── 1. Download logs ──────────────────────────────────────────────────────────
echo "==> Downloading Bunny.net access logs — pull zone ${PULL_ZONE_ID}, last ${DAYS} days ..."
for i in $(seq 1 "$DAYS"); do
# Support both GNU date (Linux) and BSD date (macOS)
if date --version >/dev/null 2>&1; then
DATE=$(date -d "-${i} days" +%Y/%m/%d)
else
DATE=$(date -v "-${i}d" +%Y/%m/%d)
fi
Y=$(echo "$DATE" | cut -d/ -f1)
M=$(echo "$DATE" | cut -d/ -f2)
D=$(echo "$DATE" | cut -d/ -f3)
GZ="${WORK}/${Y}-${M}-${D}.gz"
STATUS=$(curl -s -o "$GZ" -w "%{http_code}" \
-H "AccessKey: ${BUNNYNET_API_KEY}" \
"${LOGGING_BASE}/${Y}/${M}/${D}/${PULL_ZONE_ID}/")
if [ "$STATUS" = "200" ] && [ -s "$GZ" ]; then
gunzip -c "$GZ" >> "${WORK}/combined.log"
printf " %s-%s-%s OK\n" "$Y" "$M" "$D"
else
printf " %s-%s-%s no data (HTTP %s)\n" "$Y" "$M" "$D" "$STATUS"
fi
done
if [ ! -s "${WORK}/combined.log" ]; then
echo ""
echo "No log data found for the requested period."
exit 0
fi
# ── 2. Isolate search requests ────────────────────────────────────────────────
grep -E '"GET /services\?' "${WORK}/combined.log" > "${WORK}/svc.log" || true
grep -E '"GET /devices\?' "${WORK}/combined.log" > "${WORK}/dev.log" || true
grep -E '"GET /devices\?|"GET /services\?' "${WORK}/combined.log" > "${WORK}/search.log" || true
grep -E '"GET /[^"?]+/replacements\?' "${WORK}/combined.log" > "${WORK}/repl.log" || true
TOTAL=$(wc -l < "${WORK}/combined.log")
SEARCH=$(wc -l < "${WORK}/search.log")
echo ""
printf "Total log entries : %d\n" "$TOTAL"
printf "Search requests : %d (GET /services + GET /devices)\n" "$SEARCH"
echo ""
# ── Helper: extract query string from a log line ──────────────────────────────
# Input line: 1.2.3.x - - [date] "GET /services?capability=nlp HTTP/1.1" 200 … HIT
# Output: capability=nlp
extract_qs() {
awk '{
if (match($0, /"GET [^?]+"?\?([^" ]+) HTTP/, arr)) print arr[1]
}' "$1"
}
extract_param() {
local param="$1" file="$2"
extract_qs "$file" | tr "&" "\n" | grep "^${param}=" | cut -d= -f2-
}
# ── 3. Cache hit ratio ────────────────────────────────────────────────────────
echo "=== Cache hit ratio (search endpoints) ==================================="
HITS=$(awk '$NF=="HIT"' "${WORK}/search.log" | wc -l || echo 0)
MISS=$(awk '$NF=="MISS"' "${WORK}/search.log" | wc -l || echo 0)
BYPS=$(awk '$NF=="BYPASS"' "${WORK}/search.log" | wc -l || echo 0)
printf " HIT : %d\n" "$HITS"
printf " MISS : %d\n" "$MISS"
printf " BYPASS : %d\n" "$BYPS"
echo ""
# ── 4. Top capabilities ───────────────────────────────────────────────────────
echo "=== Top capabilities GET /services ======================================"
extract_param "capability" "${WORK}/svc.log" \
| sort | uniq -c | sort -rn | head -20 \
| awk '{printf " %7d %s\n", $1, $2}'
echo ""
echo "=== Top capabilities GET /devices ======================================="
extract_param "capability" "${WORK}/dev.log" \
| sort | uniq -c | sort -rn | head -20 \
| awk '{printf " %7d %s\n", $1, $2}'
echo ""
# ── 5. Device class and protocol ─────────────────────────────────────────────
echo "=== Device class breakdown ================================================"
extract_param "deviceClass" "${WORK}/search.log" \
| sort | uniq -c | sort -rn | head -10 \
| awk '{printf " %7d %s\n", $1, $2}'
echo ""
echo "=== Protocol breakdown ===================================================="
extract_param "protocol" "${WORK}/search.log" \
| sort | uniq -c | sort -rn | head -10 \
| awk '{printf " %7d %s\n", $1, $2}'
echo ""
# ── 6. Stage breakdown (services) — canonical form omits PRODUCTION ───────────
echo "=== Stage breakdown GET /services (explicit only) ======================="
echo " Note: requests without ?stage= are PRODUCTION (canonical form omits it)"
extract_param "stage" "${WORK}/svc.log" \
| sort | uniq -c | sort -rn \
| awk '{printf " %7d %s\n", $1, $2}'
IMPLICIT_PROD=$(extract_qs "${WORK}/svc.log" | grep -vc "stage=" || true)
printf " %7d PRODUCTION (implicit)\n" "$IMPLICIT_PROD"
echo ""
# ── 7. Top full query string combinations ────────────────────────────────────
echo "=== Top 30 query string combinations ====================================="
extract_qs "${WORK}/search.log" \
| sort | uniq -c | sort -rn | head -30 \
| awk '{printf " %7d ?%s\n", $1, $2}'
echo ""
echo "=== Replacements endpoint top parameters ================================="
if [ -s "${WORK}/repl.log" ]; then
extract_qs "${WORK}/repl.log" \
| sort | uniq -c | sort -rn | head -10 \
| awk '{printf " %7d ?%s\n", $1, $2}'
else
echo " (no replacement requests in period)"
fi
echo ""
# ── 8. Push to Prometheus Pushgateway (optional) ─────────────────────────────
#
# Pushes capability counters as Prometheus metrics so they appear as a
# time-series in the dedicated analytics Grafana instance.
# Run weekly via cron to build a trend chart without real-time overhead.
if [ -n "$PROMETHEUS_PUSH_URL" ]; then
echo "==> Pushing counters to Prometheus Pushgateway ..."
{
echo "# HELP apix_cdn_capability_requests_total Capability search requests (all, including CDN cache hits)"
echo "# TYPE apix_cdn_capability_requests_total counter"
extract_param "capability" "${WORK}/svc.log" \
| sort | uniq -c \
| awk '{printf "apix_cdn_capability_requests_total{resource=\"services\",capability=\"%s\"} %d\n", $2, $1}'
extract_param "capability" "${WORK}/dev.log" \
| sort | uniq -c \
| awk '{printf "apix_cdn_capability_requests_total{resource=\"devices\",capability=\"%s\"} %d\n", $2, $1}'
echo "# HELP apix_cdn_cache_requests_total CDN cache disposition for search endpoints"
echo "# TYPE apix_cdn_cache_requests_total counter"
printf "apix_cdn_cache_requests_total{status=\"hit\"} %d\n" "$HITS"
printf "apix_cdn_cache_requests_total{status=\"miss\"} %d\n" "$MISS"
printf "apix_cdn_cache_requests_total{status=\"bypass\"} %d\n" "$BYPS"
echo "# HELP apix_cdn_deviceclass_requests_total Device class breakdown"
echo "# TYPE apix_cdn_deviceclass_requests_total counter"
extract_param "deviceClass" "${WORK}/search.log" \
| sort | uniq -c \
| awk '{printf "apix_cdn_deviceclass_requests_total{deviceClass=\"%s\"} %d\n", $2, $1}'
echo "# HELP apix_cdn_protocol_requests_total Protocol breakdown"
echo "# TYPE apix_cdn_protocol_requests_total counter"
extract_param "protocol" "${WORK}/search.log" \
| sort | uniq -c \
| awk '{printf "apix_cdn_protocol_requests_total{protocol=\"%s\"} %d\n", $2, $1}'
} | curl -sf --data-binary @- "${PROMETHEUS_PUSH_URL}" && \
echo " Done." || echo " WARNING: Pushgateway push failed (non-fatal)."
echo ""
fi
# ── 9. Summary ────────────────────────────────────────────────────────────────
echo "Report complete."
printf "Period : last %d days\n" "$DAYS"
printf "Pull zone : %s\n" "$PULL_ZONE_ID"
[ -n "$PROMETHEUS_PUSH_URL" ] && echo "Pushed to : ${PROMETHEUS_PUSH_URL}"
-71
View File
@@ -1,71 +0,0 @@
#!/usr/bin/env bash
# Seed RS-01: Bot Standards Foundation (org) + APIX registry (service).
# Run once against a freshly started registry. Safe to re-run — 409s are ignored.
#
# Usage:
# REGISTRY_URL=http://localhost:8180 API_KEY=dev-insecure-key-change-in-prod ./scripts/seed-self-registration.sh
set -euo pipefail
REGISTRY_URL="${REGISTRY_URL:-http://localhost:8180}"
API_KEY="${API_KEY:-dev-insecure-key-change-in-prod}"
BSF_EMAIL="${BSF_EMAIL:-carsten@botstandards.org}"
echo "==> Registering Bot Standards Foundation (org) ..."
ORG_RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" \
-X POST "${REGISTRY_URL}/organizations" \
-H "Content-Type: application/json" \
-H "X-Api-Key: ${API_KEY}" \
-d '{
"registrantName": "Bot Standards Foundation",
"registrantEmail": "'"${BSF_EMAIL}"'",
"registrantJurisdiction": "CH",
"registrantOrgType": "FOUNDATION",
"domain": "botstandards.org",
"targetOLevel": "IDENTITY_VERIFIED"
}')
if [ "${ORG_RESPONSE}" = "409" ]; then
echo " botstandards.org already registered — skipping."
elif [ "${ORG_RESPONSE}" = "201" ] || [ "${ORG_RESPONSE}" = "200" ]; then
echo " Registered. HTTP ${ORG_RESPONSE}"
else
echo " ERROR: HTTP ${ORG_RESPONSE}" >&2
exit 1
fi
echo ""
echo "==> Registering APIX registry service (RS-01) ..."
SVC_RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" \
-X POST "${REGISTRY_URL}/services" \
-H "Content-Type: application/json" \
-H "X-Api-Key: ${API_KEY}" \
-d '{
"name": "APIX Registry",
"description": "The open autonomous agent service discovery registry. Agents navigate here to find and invoke any registered service by capability — no hardcoded URLs required.",
"endpoint": "'"${REGISTRY_URL}"'/",
"capabilities": ["service.discovery", "service.query", "service.registration"],
"registrantEmail": "'"${BSF_EMAIL}"'",
"registrantName": "Bot Standards Foundation",
"registrantJurisdiction": "CH",
"registrantOrgType": "FOUNDATION",
"bsmVersion": "0.1",
"serviceStage": "DEVELOPMENT"
}')
if [ "${SVC_RESPONSE}" = "409" ]; then
echo " APIX registry service already registered — skipping."
elif [ "${SVC_RESPONSE}" = "201" ] || [ "${SVC_RESPONSE}" = "200" ]; then
echo " Registered. HTTP ${SVC_RESPONSE}"
else
echo " ERROR: HTTP ${SVC_RESPONSE}" >&2
exit 1
fi
echo ""
echo "==> Verifying registry root ..."
curl -s "${REGISTRY_URL}/" | python3 -m json.tool 2>/dev/null || \
curl -s "${REGISTRY_URL}/"
echo ""
echo "Done. Registry root at ${REGISTRY_URL}/"
-216
View File
@@ -1,216 +0,0 @@
#!/usr/bin/env bash
# Configure a Bunny.net pull zone for the APIX registry.
#
# What this script does:
# 1. Creates a pull zone pointing at the origin VPS
# 2. Enables query-string vary cache (so /services?capability=nlp and
# /services?capability=translation are cached as separate entries)
# 3. Sets the edge TTL to follow the origin Cache-Control headers
# 4. Adds the custom hostname (api-index.org)
# 5. Prints the CDN hostname to use in your DNS CNAME record
#
# Prerequisites:
# - A Bunny.net account with an API key (bunny.net → Account → API)
# - The origin VPS is reachable at ORIGIN_URL before running this
# - DNS for api-index.org is under your control
#
# Usage:
# BUNNYNET_API_KEY=your-key \
# ORIGIN_URL=https://your-vps-ip-or-hostname \
# CUSTOM_HOSTNAME=api-index.org \
# ./scripts/setup-bunnynet.sh
#
# Optional — enable real-time Loki log forwarding for live telemetry:
# SYSLOG_HOST=your-vps-ip \
# SYSLOG_PORT=5514 \
# ./scripts/setup-bunnynet.sh
#
# Optional — install weekly cron job (Mondays 06:00) for CDN analytics report:
# INSTALL_CRON=true \
# ./scripts/setup-bunnynet.sh
#
# Deploy scripts/promtail-cdn-logs.yaml on the VPS before setting SYSLOG_HOST.
# Bunny.net will start streaming access logs (one syslog line per request,
# including CDN cache hits) to Promtail, which pushes to your Loki instance.
#
# CDN selection rationale:
# Bunny.net is European (Slovenia), GDPR-compliant, has PoPs in Shanghai,
# Singapore, Tokyo, Hong Kong, and Frankfurt. No founding-member conflict
# (Cloudflare and AWS are both founding member candidates and must not
# operate infrastructure over the registry).
set -euo pipefail
BUNNYNET_API_KEY="${BUNNYNET_API_KEY:?BUNNYNET_API_KEY is required}"
ORIGIN_URL="${ORIGIN_URL:?ORIGIN_URL is required (e.g. https://1.2.3.4)}"
CUSTOM_HOSTNAME="${CUSTOM_HOSTNAME:-api-index.org}"
PULL_ZONE_NAME="${PULL_ZONE_NAME:-apix-registry}"
BUNNYNET_API="https://api.bunny.net"
# Optional — set SYSLOG_HOST to enable real-time log forwarding to Promtail/Loki.
# Bunny.net streams one syslog line per request (all requests, not just misses).
# Start Promtail with scripts/promtail-cdn-logs.yaml on the VPS first.
SYSLOG_HOST="${SYSLOG_HOST:-}"
SYSLOG_PORT="${SYSLOG_PORT:-5514}"
INSTALL_CRON="${INSTALL_CRON:-false}"
echo "==> Creating Bunny.net pull zone '${PULL_ZONE_NAME}' → ${ORIGIN_URL} ..."
CREATE_RESPONSE=$(curl -sf -X POST "${BUNNYNET_API}/pullzone" \
-H "AccessKey: ${BUNNYNET_API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"Name": "'"${PULL_ZONE_NAME}"'",
"OriginUrl": "'"${ORIGIN_URL}"'",
"Type": 0,
"EnableQueryStringVaryCache": true,
"UseBackgroundUpdate": false,
"EnableWebPVary": false,
"EnableCountryCodeVary": false,
"EnableMobileVary": false,
"EnableHostnameVary": false,
"IgnoreQueryStrings": false,
"CacheControlMaxAgeOverride": -1,
"CacheControlBrowserMaxAgeOverride": -1,
"EnableLogging": true,
"LoggingIPAnonymizationEnabled": true
}')
# -1 on both MaxAgeOverride fields means "respect the origin Cache-Control header"
# The application sets: GET / → max-age=60, GET /services|/devices → max-age=30
PULL_ZONE_ID=$(echo "${CREATE_RESPONSE}" | python3 -c "import sys,json; print(json.load(sys.stdin)['Id'])")
CDN_HOSTNAME=$(echo "${CREATE_RESPONSE}" | python3 -c "import sys,json; print(json.load(sys.stdin)['Hostnames'][0]['Value'])")
echo " Pull zone ID : ${PULL_ZONE_ID}"
echo " CDN hostname : ${CDN_HOSTNAME}"
echo ""
echo "==> Adding custom hostname '${CUSTOM_HOSTNAME}' to pull zone ..."
curl -sf -X POST "${BUNNYNET_API}/pullzone/${PULL_ZONE_ID}/addHostname" \
-H "AccessKey: ${BUNNYNET_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"Hostname": "'"${CUSTOM_HOSTNAME}"'"}' > /dev/null
echo " Done."
echo ""
echo "==> Adding edge rule: bypass cache for internal paths (/q/*) ..."
curl -sf -X POST "${BUNNYNET_API}/pullzone/${PULL_ZONE_ID}/edgerules/addOrUpdate" \
-H "AccessKey: ${BUNNYNET_API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"Enabled": true,
"Description": "Bypass cache for Quarkus health and metrics endpoints",
"ActionType": 15,
"Triggers": [{
"Type": 0,
"PatternMatches": ["/q/"],
"PatternMatchingType": 12
}],
"ActionParameter1": "",
"ActionParameter2": ""
}' > /dev/null
echo " Done."
if [ -n "${SYSLOG_HOST}" ]; then
echo ""
echo "==> Enabling real-time log forwarding → ${SYSLOG_HOST}:${SYSLOG_PORT} (TCP Syslog) ..."
curl -sf -X POST "${BUNNYNET_API}/pullzone/${PULL_ZONE_ID}" \
-H "AccessKey: ${BUNNYNET_API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"LogForwardingEnabled": true,
"LogForwardingHostname": "'"${SYSLOG_HOST}"'",
"LogForwardingPort": '"${SYSLOG_PORT}"',
"LogForwardingProtocol": 1,
"LogForwardingToken": ""
}' > /dev/null
echo " Done."
echo " Bunny.net will forward every request (hits + misses) as a syslog line."
echo " Promtail → Loki → Grafana will show live capability traffic within seconds."
fi
echo ""
echo "==> Purging initial cache to ensure clean state ..."
curl -sf -X POST "${BUNNYNET_API}/pullzone/${PULL_ZONE_ID}/purgeCache" \
-H "AccessKey: ${BUNNYNET_API_KEY}" > /dev/null
echo " Done."
echo ""
echo "======================================================================"
echo " Setup complete. Next steps:"
echo ""
echo " 1. Add a DNS CNAME record:"
echo " ${CUSTOM_HOSTNAME} CNAME ${CDN_HOSTNAME}"
echo ""
echo " 2. Wait for DNS propagation (~5 minutes for most registrars)"
echo ""
echo " 3. Bunny.net will provision a free TLS certificate automatically"
echo " once the CNAME is live. No manual cert management needed."
echo ""
echo " 4. Verify the CDN is caching correctly:"
echo " curl -sI https://${CUSTOM_HOSTNAME}/ | grep -i cache-control"
echo " Expected: Cache-Control: public, max-age=60"
echo ""
echo " 5. Test from Asia (requires a remote machine or VPN):"
echo " curl -w '%{time_total}s\n' -o /dev/null -s https://${CUSTOM_HOSTNAME}/"
echo " First request: ~200ms (cache miss → origin). Subsequent: <20ms (edge)."
echo ""
echo " CDN hostname : ${CDN_HOSTNAME}"
echo " Pull zone ID : ${PULL_ZONE_ID}"
echo ""
echo " Log access (Bunny.net access logs, gzip, one file per day):"
echo " curl -H 'AccessKey: \$BUNNYNET_API_KEY' \\"
echo " https://logging.bunnycdn.com/YYYY/MM/DD/${PULL_ZONE_ID}/ -o logs.gz"
echo ""
echo " Query frequency report (last 7 days):"
echo " BUNNYNET_API_KEY=\$BUNNYNET_API_KEY PULL_ZONE_ID=${PULL_ZONE_ID} \\"
echo " ./scripts/query-report.sh"
echo ""
echo " Logs are IP-anonymised (last octet zeroed) for GDPR compliance."
echo ""
if [ -n "${SYSLOG_HOST}" ]; then
echo " Live Loki forwarding : ENABLED → ${SYSLOG_HOST}:${SYSLOG_PORT}"
echo " Every request (CDN hit and miss) streams as a syslog line."
echo " Grafana dashboard shows capability traffic in real-time."
echo " Promtail config: scripts/promtail-cdn-logs.yaml"
else
echo " Live Loki forwarding : DISABLED (re-run with SYSLOG_HOST=<vps-ip> to enable)"
echo " Without this, only cache misses appear in origin Prometheus metrics."
echo " For the OpenClaw demo: enable this before the session starts."
fi
echo "======================================================================"
# Persist pull zone ID for query-report.sh convenience
echo "${PULL_ZONE_ID}" > .bunnynet-pull-zone-id
echo ""
echo "Pull zone ID saved to .bunnynet-pull-zone-id (add to .gitignore)."
if [ "${INSTALL_CRON}" = "true" ]; then
echo ""
echo "==> Installing weekly cron job for CDN analytics report ..."
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPORT_SCRIPT="${SCRIPT_DIR}/query-report.sh"
LOG_DIR="${SCRIPT_DIR}/../logs"
mkdir -p "${LOG_DIR}"
CRON_LINE="0 6 * * 1 BUNNYNET_API_KEY=${BUNNYNET_API_KEY} PULL_ZONE_ID=${PULL_ZONE_ID} ${REPORT_SCRIPT} >> ${LOG_DIR}/cdn-report.log 2>&1"
# Install without duplicating — removes any existing query-report.sh entry first
( crontab -l 2>/dev/null | grep -v "query-report\.sh"; echo "${CRON_LINE}" ) | crontab -
echo " Done."
echo " Schedule : every Monday at 06:00"
echo " Log : ${LOG_DIR}/cdn-report.log"
echo ""
echo " Verify with: crontab -l | grep query-report"
fi