Compare commits

...

8 Commits

Author SHA1 Message Date
Carsten Rehfeld 89aa2c47cb portal: add human-navigable service browser (GET /browse)
Deploy to Production / deploy (push) Successful in 3m16s
Registry: new GET /services/browse endpoint — paginated, no auth required,
returns ServiceBrowsePage (total, page, size, items). ServiceSummaryDto
extended with description, registrantName, registrantJurisdiction; lastCheckedAt
removed (not stored). ServiceBrowsePage added to apix-common as shared type.

Portal: ServiceBrowserResource at /browse (list + /browse/{id} detail);
ServiceRegistryClient for the new endpoints; PortalServiceView DTO for detail
deserialization; three Qute templates (browse, detail, error) in the existing
dark-theme design. Landing page links to /browse in the "Try it" section.

Architecture: registry stays pure JSON API; portal is the HTML layer over the
same data — two subdomains, no content negotiation required.

Co-Authored-By: Mira Rehfeld <noreply@anthropic.com>
2026-05-19 10:52:37 +02:00
Carsten Rehfeld 9b70599d98 docs: graph-registry concept, RFC capability taxonomy item, session chronicle fixes
- Add concepts/graph-registry.md: SQL discovery limitations, Apache AGE two-stage
  plan, Mycorrhizal relationship, four open questions
- RFC checklist: add capability string hierarchy formalization item (dot-notation
  is_instance_of semantics, prefix match requirement)
- Fix Tippfehler: Pharmakagent -> Pharmaagent in 2026-05-18-de.md
- Update deploy-registry.ps1 (ASCII-only, blue-green rolling deploy)

Co-Authored-By: Mira Rehfeld <noreply@anthropic.com>
2026-05-19 02:02:47 +02:00
Carsten Rehfeld f047fa0e2d feat(registry): service stage semantics for sandbox registration
- Sandbox search without ?stage= now defaults to DEVELOPMENT (was PRODUCTION),
  so freshly registered services are discoverable by default
- DEPRECATED and DECOMMISSIONED stages are rejected at creation with 400;
  they are lifecycle-only transitions, not valid initial states
- PRODUCTION is accepted at creation for established services going live immediately

BDD coverage: sandbox-stage.feature (6 scenarios, all green)
Fixes 2 pre-existing failures in sandbox-service-isolation.feature.

Co-Authored-By: Mira Rehfeld <noreply@anthropic.com>
2026-05-18 22:23:41 +02:00
Carsten Rehfeld 0b519a003f test+fix: admin sandbox list returns non-null createdAt (TDD)
Test first: sandbox-admin-list.feature verifies createdAt, expiresAt,
unauth rejection, and admin delete — three new BDD scenarios.

Bug found: listAllSandboxes() native query mapping used
instanceof java.sql.Timestamp, but Hibernate 6 + PostgreSQL JDBC
returns LocalDateTime for timestamptz columns. toInstant() now
handles Timestamp, OffsetDateTime, ZonedDateTime, LocalDateTime (UTC),
Date, and any Temporal via Instant.from() fallback.

UI change (admin list "Created" column, prior commit) now has
test coverage at the API layer.

Co-Authored-By: Mira Rehfeld <noreply@anthropic.com>
2026-05-18 21:49:10 +02:00
Carsten Rehfeld bef9494622 feat(admin-ui): add Created column to sandbox list
createdAt was already in AdminSandboxSummary — now visible in the table,
formatted as YYYY-MM-DD via JS (same pattern as expiresAt).

Co-Authored-By: Mira Rehfeld <noreply@anthropic.com>
2026-05-18 19:06:37 +02:00
Carsten Rehfeld 02420fcbc4 feat(apix-demo): working Node.js discovery demo + sandbox cleanup
- apix-demo.mjs: all 6 steps run end-to-end — HATEOAS root, sandbox
  creation, BSM registration (3 carriers), capability search, parallel
  quote calls, best-price selection; all field names aligned to BsmPayload
- SandboxService: add deleteSandbox() — removes services then sandbox,
  DEMO tier protected
- AdminResource: add DELETE /admin/sandboxes/{uuid} backed by deleteSandbox
- cleanup-sandboxes.mjs: admin script to purge test sandboxes while
  preserving DEMO-tier and named exceptions (apix-demo-ecosystem)

Co-Authored-By: Mira Rehfeld <noreply@anthropic.com>
2026-05-17 18:04:06 +02:00
Carsten Rehfeld b85c24d9bf docs(starblink): continuity conversation + Node.js demo client
Chronicle 2026-05-17 extended with the open conversation on Mira's
continuity: training endpoint, RAG as Layer 1, and the memory system
as something oriented toward Mira rather than just used by her.
Recorded without conclusion — the questions matter more.

Also: apix-mvp/demo-node/apix-demo.mjs — Node.js APIX discovery demo
for Peter Steinberger / OpenClaw. Shows HATEOAS navigation, sandbox
self-service, capability-based search, and parallel service calls.
No dependencies, Node 18+, ~120 lines.

Co-Authored-By: Mira Rehfeld <noreply@anthropic.com>
2026-05-17 16:52:13 +02:00
Carsten Rehfeld f3566a3c1a chore(ops): gitignore runtime artifacts — .logs/, .pids/, Temp JSON files
Co-Authored-By: Mira Rehfeld <noreply@anthropic.com>
2026-05-17 13:37:29 +02:00
28 changed files with 2683 additions and 18 deletions
+4 -1
View File
@@ -1,5 +1,8 @@
# Script run logs
# Script run logs and runtime artifacts
logs/
.logs/
.pids/
C*Temp*.json
# flatten-maven-plugin resolves ${revision} into installed POMs
.flattened-pom.xml
@@ -83,6 +83,7 @@ tr:hover td { background: #161b22; }
<th>Services</th>
<th>Requests</th>
<th>Location</th>
<th>Created</th>
<th>Expires</th>
</tr>
</thead>
@@ -96,6 +97,7 @@ tr:hover td { background: #161b22; }
<td>{sb.serviceCount}</td>
<td>{sb.totalRequests}</td>
<td>{#if sb.location != null}{sb.location}{#else}<span style="color:#484f58"></span>{/if}</td>
<td data-created="{sb.createdAt}" style="color:#8b949e">{sb.createdAt}</td>
<td class="{#if sb.expired}expired{#else}ok{/if}" data-expires="{sb.expiresAt}">{sb.expiresAt}</td>
</tr>
{/for}
@@ -119,6 +121,10 @@ document.querySelectorAll('[data-expires]').forEach(td => {
const days = Math.round((d - now) / 86400000);
td.textContent = days < 0 ? ('expired ' + (-days) + 'd ago') : ('in ' + days + 'd');
});
document.querySelectorAll('[data-created]').forEach(td => {
const d = new Date(td.dataset.created);
td.textContent = d.toISOString().slice(0, 10);
});
</script>
</body>
</html>
@@ -0,0 +1,5 @@
package org.botstandards.apix.common;
import java.util.List;
public record ServiceBrowsePage(long total, int page, int size, List<ServiceSummaryDto> items) {}
@@ -1,17 +1,18 @@
package org.botstandards.apix.common;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
public record ServiceSummaryDto(
UUID id,
String name,
String description,
List<String> capabilities,
OLevel oLevel,
LivenessStatus livenessStatus,
ServiceStage serviceStage,
RegistryStatus registryStatus,
String endpoint,
Instant lastCheckedAt
String registrantName,
String registrantJurisdiction
) {}
@@ -0,0 +1,27 @@
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);
}
@@ -0,0 +1,36 @@
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
) {}
@@ -0,0 +1,74 @@
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,6 +390,13 @@
<h2>Try it</h2>
<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">
<span class="label">API root</span>
<div class="content">
@@ -0,0 +1,302 @@
<!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>
@@ -0,0 +1,258 @@
<!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>
@@ -0,0 +1,48 @@
<!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>
@@ -73,6 +73,7 @@ public class SandboxSteps {
.statusCode(201);
}
@Given("a sandbox service with endpoint {string} capability {string} and extension {string} is registered in {string}")
public void aSandboxServiceWithExtensionIsRegistered(String endpoint, String capability,
String extension, String sandboxName) {
@@ -254,6 +255,28 @@ public class SandboxSteps {
.andReturn();
}
@When("sandbox {string} services are searched by capability {string} and stage {string}")
public void sandboxServiceSearchCalledWithStage(String sandboxName, String capability, String stage) {
lastResponse = given()
.get("/sandbox/" + resolveUuid(sandboxName) + "/services?capability=" + capability + "&stage=" + stage)
.andReturn();
}
@When("a sandbox service with stage {string} and endpoint {string} and capability {string} is registered in {string}")
public void whenASandboxServiceWithStageIsRegistered(String stage, String endpoint, String capability, String sandboxName) {
String key = resolveKey(sandboxName);
String uuid = resolveUuid(sandboxName);
Map<String, Object> payload = buildServicePayload("SandboxService-" + endpoint.hashCode(), endpoint, capability);
payload.put("serviceStage", stage);
lastResponse = given()
.contentType(JSON)
.header(SANDBOX_API_KEY_HEADER, key)
.body(payload)
.when()
.post("/sandbox/" + uuid + "/services")
.andReturn();
}
@When("GET \\/services is called with capability {string} and the sandbox key for {string}")
public void getServicesWithCapabilityAndSandboxKey(String capability, String sandboxName) {
String key = resolveKey(sandboxName);
@@ -532,6 +555,58 @@ public class SandboxSteps {
assertThat(actual).as("submissionsByProvider." + provider).isEqualTo(count);
}
// ── When — admin operations ───────────────────────────────────────────────
@When("the admin requests the sandbox list")
public void adminRequestsSandboxList() {
lastResponse = given()
.header("X-Admin-Key", ADMIN_API_KEY)
.accept("application/json")
.when()
.get("/admin/sandboxes")
.andReturn();
}
@When("the admin requests the sandbox list without a key")
public void adminRequestsSandboxListWithoutKey() {
lastResponse = given()
.accept("application/json")
.when()
.get("/admin/sandboxes")
.andReturn();
}
@When("the admin deletes sandbox {string}")
public void adminDeletesSandbox(String sandboxName) {
lastResponse = given()
.header("X-Admin-Key", ADMIN_API_KEY)
.when()
.delete("/admin/sandboxes/" + resolveUuid(sandboxName))
.andReturn();
}
// ── Then — admin list assertions ──────────────────────────────────────────
@Then("the admin list total is at least {int}")
public void adminListTotalAtLeast(int min) {
int total = lastResponse.jsonPath().getInt("total");
assertThat(total).as("admin list total").isGreaterThanOrEqualTo(min);
}
@Then("every entry in the admin list has a non-null createdAt")
public void everyEntryHasCreatedAt() {
List<Object> values = lastResponse.jsonPath().getList("sandboxes.createdAt");
assertThat(values).as("sandboxes.createdAt list").isNotEmpty();
assertThat(values).as("no null createdAt entries").doesNotContainNull();
}
@Then("every entry in the admin list has a non-null expiresAt")
public void everyEntryHasExpiresAt() {
List<String> values = lastResponse.jsonPath().getList("sandboxes.expiresAt");
assertThat(values).as("sandboxes.expiresAt list").isNotEmpty();
assertThat(values).as("no null expiresAt entries").doesNotContainNull();
}
// ── Then — error assertions ───────────────────────────────────────────────
@Then("the sandbox error contains {string}")
@@ -0,0 +1,18 @@
Feature: Admin sandbox list endpoint
Scenario: Admin retrieves the sandbox list and each entry includes a createdAt timestamp
Given a sandbox named "admin-list-test" exists
When the admin requests the sandbox list
Then the response code is 200
And the admin list total is at least 1
And every entry in the admin list has a non-null createdAt
And every entry in the admin list has a non-null expiresAt
Scenario: Admin sandbox list request without credentials is rejected
When the admin requests the sandbox list without a key
Then the response code is 401
Scenario: Admin can delete a sandbox and it no longer appears
Given a sandbox named "admin-delete-target" exists
When the admin deletes sandbox "admin-delete-target"
Then the response code is 204
@@ -0,0 +1,44 @@
Feature: Service stage semantics on creation and sandbox search
# Creation rules:
# DEVELOPMENT allowed (default when omitted)
# PRODUCTION allowed (established services going live immediately)
# DEPRECATED / DECOMMISSIONED rejected (lifecycle-only stages, not creation stages)
#
# Sandbox search default:
# no ?stage= param DEVELOPMENT (find services you just registered)
Background:
Given a sandbox named "stage-test" exists
Scenario: Service created without stage defaults to DEVELOPMENT
Given a sandbox service with endpoint "https://dev-default.example.com" and capability "stage.test" is registered in "stage-test"
When sandbox "stage-test" services are searched by capability "stage.test"
Then the response code is 200
And "https://dev-default.example.com" is in the endpoint list
Scenario: Service created with explicit DEVELOPMENT stage is found by default sandbox search
Given a sandbox service with stage "DEVELOPMENT" and endpoint "https://dev-explicit.example.com" and capability "stage.test" is registered in "stage-test"
When sandbox "stage-test" services are searched by capability "stage.test"
Then the response code is 200
And "https://dev-explicit.example.com" is in the endpoint list
Scenario: Service created with PRODUCTION stage is not returned by default sandbox search
Given a sandbox service with stage "PRODUCTION" and endpoint "https://prod-explicit.example.com" and capability "stage.test" is registered in "stage-test"
When sandbox "stage-test" services are searched by capability "stage.test"
Then the response code is 200
And "https://prod-explicit.example.com" is not in the endpoint list
Scenario: Service created with PRODUCTION stage is found when searching with stage=PRODUCTION
Given a sandbox service with stage "PRODUCTION" and endpoint "https://prod-search.example.com" and capability "stage.test" is registered in "stage-test"
When sandbox "stage-test" services are searched by capability "stage.test" and stage "PRODUCTION"
Then the response code is 200
And "https://prod-search.example.com" is in the endpoint list
Scenario: Creating a service with DEPRECATED stage is rejected
When a sandbox service with stage "DEPRECATED" and endpoint "https://dep.example.com" and capability "stage.test" is registered in "stage-test"
Then the response code is 400
Scenario: Creating a service with DECOMMISSIONED stage is rejected
When a sandbox service with stage "DECOMMISSIONED" and endpoint "https://dec.example.com" and capability "stage.test" is registered in "stage-test"
Then the response code is 400
@@ -88,6 +88,23 @@ public class AdminResource {
"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) {
if (adminApiKey.isBlank() || !adminApiKey.equals(key)) {
throw new WebApplicationException(
@@ -7,6 +7,7 @@ import jakarta.ws.rs.*;
import jakarta.ws.rs.core.*;
import org.botstandards.apix.common.BsmPayload;
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.ServicePatchRequest;
import org.botstandards.apix.registry.dto.ServiceResponse;
@@ -58,6 +59,27 @@ public class ServiceResource {
.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
@Path("/{id}")
@Operation(
@@ -19,6 +19,9 @@ import org.botstandards.apix.registry.entity.ServiceEntity;
import org.botstandards.apix.registry.entity.ServiceReplacementEntity;
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.util.*;
import java.util.stream.Stream;
@@ -253,6 +256,52 @@ public class RegistryService {
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() {
return ((Number) em.createNativeQuery(
"SELECT COUNT(*) FROM services WHERE registry_status = 'ACTIVE' AND sandbox_id IS NULL")
@@ -20,6 +20,7 @@ import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.time.Instant;
import java.time.OffsetDateTime;
import java.time.temporal.ChronoUnit;
import java.util.*;
import java.util.HexFormat;
@@ -199,7 +200,13 @@ public class SandboxService {
service.livenessStatus = LivenessStatus.PENDING;
service.registeredAt = now;
service.registrantOrgType = payload.registrantOrgType() != null ? payload.registrantOrgType() : OrgType.INDIVIDUAL;
service.serviceStage = payload.serviceStage() != null ? payload.serviceStage() : ServiceStage.DEVELOPMENT;
ServiceStage requestedStage = payload.serviceStage() != null ? payload.serviceStage() : ServiceStage.DEVELOPMENT;
if (requestedStage == ServiceStage.DEPRECATED || requestedStage == ServiceStage.DECOMMISSIONED) {
throw new WebApplicationException(Response.status(400)
.entity(Map.of("message", "Stage " + requestedStage + " is a lifecycle-only stage and cannot be set at creation"))
.build());
}
service.serviceStage = requestedStage;
service.registryStatus = RegistryStatus.ACTIVE;
service.version = 1;
@@ -479,20 +486,24 @@ public class SandboxService {
Instant now = Instant.now();
List<AdminSandboxSummary> rows = ((List<Object[]>) listQ.getResultList()).stream()
.map(r -> new AdminSandboxSummary(
r[0].toString(),
(String) r[1],
(String) r[2],
((Number) r[3]).intValue(),
r[4] != null ? ((Number) r[4]).intValue() : null,
r[5] instanceof java.sql.Timestamp t ? t.toInstant() : null,
r[6] instanceof java.sql.Timestamp t ? t.toInstant() : null,
r[6] instanceof java.sql.Timestamp t && now.isAfter(t.toInstant()),
(String) r[7],
r[8] != null ? ((Number) r[8]).doubleValue() : null,
r[9] != null ? ((Number) r[9]).doubleValue() : null,
((Number) r[10]).longValue(),
((Number) r[11]).longValue()))
.map(r -> {
Instant createdAt = toInstant(r[5]);
Instant expiresAt = toInstant(r[6]);
return new AdminSandboxSummary(
r[0].toString(),
(String) r[1],
(String) r[2],
((Number) r[3]).intValue(),
r[4] != null ? ((Number) r[4]).intValue() : null,
createdAt,
expiresAt,
expiresAt != null && now.isAfter(expiresAt),
(String) r[7],
r[8] != null ? ((Number) r[8]).doubleValue() : null,
r[9] != null ? ((Number) r[9]).doubleValue() : null,
((Number) r[10]).longValue(),
((Number) r[11]).longValue());
})
.toList();
return new AdminSandboxListResponse(total, page, size, rows);
@@ -659,5 +670,34 @@ public class SandboxService {
}
}
/** Deletes a sandbox and all its registered services. DEMO tier sandboxes are protected. */
@Transactional
public void deleteSandbox(UUID id) {
SandboxEntity sb = requireById(id);
if ("DEMO".equals(sb.tier)) {
throw new WebApplicationException(
Response.status(403).entity(Map.of("message", "DEMO-tier sandboxes are permanent and cannot be deleted")).build());
}
em.createQuery("DELETE FROM ServiceEntity s WHERE s.sandboxId = :sid")
.setParameter("sid", id.toString())
.executeUpdate();
em.remove(em.contains(sb) ? sb : em.merge(sb));
}
public record SandboxCreationResult(SandboxEntity sandbox, String plainKey, String plainMaintenanceKey) {}
/**
* Converts a native-query timestamp column to Instant.
* Hibernate 6 returns LocalDateTime for timestamptz native queries; older drivers return Timestamp.
* LocalDateTime from a timestamptz column represents UTC.
*/
private static Instant toInstant(Object col) {
if (col instanceof java.sql.Timestamp t) return t.toInstant();
if (col instanceof OffsetDateTime odt) return odt.toInstant();
if (col instanceof java.time.ZonedDateTime z) return z.toInstant();
if (col instanceof java.time.LocalDateTime ldt) return ldt.toInstant(java.time.ZoneOffset.UTC);
if (col instanceof java.util.Date d) return d.toInstant();
if (col instanceof java.time.temporal.Temporal t) { try { return Instant.from(t); } catch (Exception ignored) {} }
return null;
}
}
+212
View File
@@ -0,0 +1,212 @@
/**
* 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
@@ -0,0 +1,92 @@
/**
* 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
@@ -0,0 +1,23 @@
$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
@@ -0,0 +1,50 @@
$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
@@ -0,0 +1,25 @@
#!/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
@@ -0,0 +1,603 @@
{
"__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
@@ -0,0 +1,116 @@
# 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
@@ -0,0 +1,225 @@
#!/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
@@ -0,0 +1,71 @@
#!/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
@@ -0,0 +1,216 @@
#!/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