Compare commits

...

28 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
Carsten Rehfeld 1fd481a2d9 chore(admin): gitignore node_modules and Playwright output dirs
Deploy to Production / deploy (push) Successful in 3m17s
Co-Authored-By: Mira Rehfeld <noreply@anthropic.com>
2026-05-17 02:14:18 +02:00
Carsten Rehfeld 7ccc384ebc test(admin): Playwright chart tests — 11 scenarios, fully mocked
Covers axes, live label format, path update on delta, 1 s poll cadence,
rate decay after CHART_TICK, burst overflow clamp, and header elements.
All network I/O mocked via page.route(); no live Quarkus server required.

Co-Authored-By: Mira Rehfeld <noreply@anthropic.com>
2026-05-17 02:14:02 +02:00
Carsten Rehfeld 4d980d7fc3 fix(admin): single update path per poll — no competing D3 transitions
Deploy to Production / deploy (push) Successful in 3m15s
Removed separate setInterval for buf flush. Flush + redraw now happen
together inside poll() — one atomic step, one D3 transition(950ms).
Two independent 1 s timers were firing out of phase and interrupting
each other's transitions, causing flicker and apparent 2 s cadence.

Co-Authored-By: Mira Rehfeld <noreply@anthropic.com>
2026-05-17 01:49:37 +02:00
Carsten Rehfeld f895bcf0cf feat(admin): 1 s chart buckets + smooth D3 transition
Deploy to Production / deploy (push) Successful in 3m14s
- Bucket size: 1 s (was 5 s) → chart scrolls every second, feels live
- Window: 72 s visible, x-axis labels -72s / -36s / now
- D3 transition(900ms, easeLinear) on path redraw → continuous motion
- Sliding-window rate calculation unchanged (still 5 s window for rpm)

Co-Authored-By: Mira Rehfeld <noreply@anthropic.com>
2026-05-17 01:45:08 +02:00
Carsten Rehfeld a4a5a8e3f2 fix(admin): sliding-window rate — no sawtooth drop at bucket boundaries
Deploy to Production / deploy (push) Successful in 3m20s
Replace fixed-window accDelta (resets to 0 every 5 s) with a sliding
window: recentPolls[] holds {t, delta} entries; liveRpm() sums all
entries within the last 5 s and extrapolates to rpm. Old entries age
out naturally so the rate decays smoothly with no hard reset jump.
Chart and label now update continuously without the 0-drop artifact.

Co-Authored-By: Mira Rehfeld <noreply@anthropic.com>
2026-05-17 01:37:55 +02:00
Carsten Rehfeld fa473d2b61 feat(admin): chart redraws every 1 s — live bucket at right edge
Deploy to Production / deploy (push) Successful in 3m12s
Separate chart refresh from bucket flush:
- 5 s flush commits completed bucket into buf, resets accumulator
- Every poll (1 s): redraw path as buf[1..] + current live bucket
  so the rightmost bar visually builds up in real time
- Label and chart now both update at 1 s cadence

Co-Authored-By: Mira Rehfeld <noreply@anthropic.com>
2026-05-17 01:26:08 +02:00
Carsten Rehfeld 435e1bda42 fix(admin): smooth live rate label — window-accumulated rate, not instantaneous
Deploy to Production / deploy (push) Successful in 3m16s
With 1 s polls and batch traffic, delta * 60 flickers between 0 and 300.
Fix: live label shows accDelta / elapsed_in_window * 60000 — the rate
builds up continuously within each 5 s bucket. First 200 ms after flush
carries the previous window rate to avoid a zero jump.

Co-Authored-By: Mira Rehfeld <noreply@anthropic.com>
2026-05-17 01:20:15 +02:00
Carsten Rehfeld de597f1d8b fix(admin): clip rate chart at 100% — SVG clipPath + data cap
Deploy to Production / deploy (push) Successful in 3m13s
Two-layer fix:
- SVG clipPath on plot area: path physically cannot escape chart bounds
- Chart flush caps rpm at rateLimit: timing drift (two batches in one
  5 s window) no longer causes visual overshoot
Live label still shows true req/min and % including values > 100%.

Co-Authored-By: Mira Rehfeld <noreply@anthropic.com>
2026-05-17 01:12:10 +02:00
Carsten Rehfeld 4908d5d6e0 feat(admin): adaptive poll rate — 1 s active, 5 s idle after 15 s no traffic
Deploy to Production / deploy (push) Successful in 3m16s
Admin sandbox drill-down polls at 1 s when traffic is flowing (live label
updates feel instant), drops back to 5 s after 15 s of inactivity.
Chart bucket remains fixed at 5 s so x-axis labels stay correct; deltas
accumulate between buckets independently of poll rate.

Tier-dependent rate reserved for future sandbox-owner dashboard.

Co-Authored-By: Mira Rehfeld <noreply@anthropic.com>
2026-05-17 00:52:42 +02:00
Carsten Rehfeld 0ee76ac535 fix(admin): serialize poll response as JSON string via ObjectMapper
Deploy to Production / deploy (push) Successful in 3m20s
Response.ok(Map.of(...)).build() with class-level @Produces(TEXT_HTML)
caused Quarkus to call Map.toString() instead of the Jackson writer,
producing {key=value} output that r.json() can't parse. Explicitly
serialize with ObjectMapper and set Content-Type to application/json.

Co-Authored-By: Mira Rehfeld <noreply@anthropic.com>
2026-05-17 00:35:06 +02:00
Carsten Rehfeld 6bc4059922 fix(admin): move rate chart out of Qute template into admin.js
Deploy to Production / deploy (push) Successful in 3m26s
Qute processes all {...} in .html files including <script> blocks.
JS function bodies like function(d) { return y(d); } were silently
eaten, causing a syntax error that killed the entire chart script.

Moving the rate chart IIFE into admin.js (static, Qute-never-touches)
fixes the root cause. Axes (Y: 0/50/100%, X: -6m/-3m/now) and
live % label now render correctly.

Co-Authored-By: Mira Rehfeld <noreply@anthropic.com>
2026-05-17 00:29:16 +02:00
Carsten Rehfeld 00e4731bde feat(admin): add time/% axes to live rate chart
Deploy to Production / deploy (push) Successful in 3m20s
Y-axis shows 0/50/100% of rate limit with grid lines.
X-axis shows -6m / -3m / now time labels.
Live label also shows current utilisation percentage.
Chart height increased to 96px to accommodate axes.
Cache-busted admin.js to v4.

Co-Authored-By: Mira Rehfeld <noreply@anthropic.com>
2026-05-17 00:21:22 +02:00
Carsten Rehfeld 0d4f7934f7 fix(admin): bust admin.js browser cache with ?v=3, disable immutable static cache
Deploy to Production / deploy (push) Successful in 3m16s
Quarkus defaults to Cache-Control: immutable, max-age=86400 for static files.
The immutable flag means browsers ignore hard-refresh and serve stale JS for 24h.
Fix: version query string on admin.js URLs, max-age=300 (5min), clear immutable-paths.

Co-Authored-By: Mira Rehfeld <noreply@anthropic.com>
2026-05-16 23:53:21 +02:00
Carsten Rehfeld c7bfa73d2f fix(admin): blinks + live rate chart on sandbox drill-down
Deploy to Production / deploy (push) Successful in 3m14s
Blink bug: agent visits were gated behind registrarLat != null — sandboxes
without a declared location showed no blinks at all. Now always enters the
sandbox branch when __D.sandboxId is present; registrar star still conditional.

Rate chart: rolling D3 area chart (72 buckets x 5s = 6min window) polls
GET /sandbox/{uuid}/poll every 5s, computes delta→rpm, renders with
CatmullRom curve. Red dashed limit line. Live value shown in stat box and
chart header. Poll endpoint added to DashboardResource (JSON, no HTML).

Co-Authored-By: Mira Rehfeld <noreply@anthropic.com>
2026-05-16 22:29:37 +02:00
Carsten Rehfeld 51e210ab68 fix(admin): pre-compute pagination in Java, remove arithmetic from Qute template
Deploy to Production / deploy (push) Successful in 3m12s
Qute cannot evaluate + and * as arithmetic operators at runtime even with
requireTypeSafeExpressions=false — it tries to look them up as map keys.
Pre-compute hasPrev/hasNext/displayPage/prevPage/nextPage in the resource.

Co-Authored-By: Mira Rehfeld <noreply@anthropic.com>
2026-05-16 21:55:18 +02:00
Carsten Rehfeld 60dfcd58f6 fix(admin): use getRequestUri().getPath() in auth filter to avoid //login double-slash
Deploy to Production / deploy (push) Successful in 3m13s
Co-Authored-By: Mira Rehfeld <noreply@anthropic.com>
2026-05-16 21:42:18 +02:00
Carsten Rehfeld ad19c8f20f fix(admin): relax Qute type-safety for arithmetic in list.html, fix JS template literals
Deploy to Production / deploy (push) Successful in 3m14s
requireTypeSafeExpressions=false allows {page+1} and (page+1)*size
in pagination conditions. JS template literals with ${days} replaced
with string concatenation to avoid Qute consuming the {days} token.

Co-Authored-By: Mira Rehfeld <noreply@anthropic.com>
2026-05-16 21:30:29 +02:00
Carsten Rehfeld 82a379e536 feat(admin): apix-admin UI at admin.api-index.org
Deploy to Production / deploy (push) Failing after 47s
Internal maintainer dashboard: global stats, sandbox list with
tier-filter and pagination, sandbox drill-down with D3 world map,
tier-promotion form. API-key auth via HttpOnly cookie. Port 8084,
rolling a/b deploy alongside the existing service pairs.

- apix-common: AdminSandboxSummary, AdminSandboxListResponse, AdminStatsResponse DTOs
- apix-registry: AdminResource (4 endpoints), SandboxService.listAllSandboxes + getGlobalStats
- apix-registry: SandboxResource GET /{uuid}/dashboard endpoint (was missing — fixes portal 500)
- apix-portal: RegistryClient path corrected to /dashboard
- apix-admin: new Quarkus module — auth filter, registry REST client, 3 resources, 6 Qute templates, admin.js
- infra: Dockerfile.admin, docker-compose admin-a/b services, Caddyfile admin.api-index.org block
- scripts: deploy-bluegreen.sh extended for admin-a/b

Co-Authored-By: Mira Rehfeld <noreply@anthropic.com>
2026-05-16 21:18:48 +02:00
Carsten Rehfeld e385d05ce1 fix(sandbox): two sandbox bugs blocking Peter Steinberger beta test
Bug 1 — sandbox-scoped discovery returns empty:
GET /services?capability=nlp with a sandbox API key returns [] because
Bunny.net CDN caches the public (PRODUCTION-scoped) empty response and
serves it regardless of the X-Api-Key header. Fix: ServiceResource.search()
now returns Cache-Control: private, no-store when a sandbox key is active,
opting the response out of CDN caching.

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

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

Co-Authored-By: Mira Rehfeld <noreply@anthropic.com>
2026-05-16 13:20:37 +02:00
Carsten Rehfeld 134c8b3c6b fix(registry): all 5 CucumberIT suites green — device nav + iotReady filter
Three root causes fixed:

1. DeviceCucumberIT — duplicate step class removed from bdd.device; moved
   to dedicated devicebdd package with its own TestSetup and glue scanning,
   preventing duplicate-step conflicts with IoT suites.

2. DeviceNavigationSteps — ?-in-Cucumber-expression treated as optional-char
   quantifier. Changed GET /devices?capability=, ?deviceClass=, ?protocol=
   patterns to anchored regex (^…$) with (.+) capture so literal ? matches.

3. IotProfileCucumberIT iotReady=true — CanonicalQueryFilter was silently
   redirecting ?iotReady=true to the canonical URL (which omitted iotReady).
   RestAssured followed the 302 and the server never saw the parameter.
   Fix: add iotReady to QueryNormalisationService.canonicalForReplacements
   with normaliseIotReady ("true" → "true", else absent).
   ServiceResource now reads iotReady via UriInfo (bypasses JAX-RS @QueryParam
   which is downstream of the filter rewrite). RegistryService uses primitive
   boolean + INNER JOIN iot_profiles when iotReady=true.

Co-Authored-By: Mira Rehfeld <noreply@anthropic.com>
2026-05-15 20:11:18 +02:00
70 changed files with 4876 additions and 128 deletions
+6
View File
@@ -35,3 +35,9 @@ REGISTRY_BASE_URL=http://localhost:8180
# ── Logging ───────────────────────────────────────────────────────────────────
# DEBUG in dev; INFO in prod
LOG_LEVEL=DEBUG
# ── Observability ─────────────────────────────────────────────────────────────
# Grafana admin password. Change in any non-local environment.
GRAFANA_ADMIN_PASSWORD=admin
# Set to your public domain in production, e.g. https://grafana.api-index.org
GRAFANA_ROOT_URL=http://localhost:3000
+8 -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
@@ -7,6 +10,9 @@ logs/
# Local environment — never commit real credentials
.env
# Bunny.net pull zone ID written by setup-bunnynet.sh
.bunnynet-pull-zone-id
# Maven build output
target/
**/target/
@@ -22,3 +28,4 @@ sanctions-cache/
# OS
.DS_Store
Thumbs.db
/.allure/
+3
View File
@@ -0,0 +1,3 @@
node_modules/
playwright-report/
test-results/
+76
View File
@@ -0,0 +1,76 @@
{
"name": "apix-admin-tests",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "apix-admin-tests",
"devDependencies": {
"@playwright/test": "^1.44.0"
}
},
"node_modules/@playwright/test": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz",
"integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.60.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/playwright": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.60.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
}
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"name": "apix-admin-tests",
"private": true,
"scripts": {
"test": "playwright test",
"test:headed": "playwright test --headed",
"test:report": "playwright show-report"
},
"devDependencies": {
"@playwright/test": "^1.44.0"
}
}
+14
View File
@@ -0,0 +1,14 @@
// @ts-check
const { defineConfig } = require('@playwright/test');
module.exports = defineConfig({
testDir: './tests',
timeout: 30_000,
retries: 0,
reporter: [['line'], ['html', { open: 'never' }]],
use: {
baseURL: process.env.APIX_ADMIN_URL || 'http://localhost:8084',
// Cookie set by the auth filter; value must match APIX_API_KEY on the registry
storageState: undefined,
},
});
+70
View File
@@ -0,0 +1,70 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.botstandards</groupId>
<artifactId>apix-parent</artifactId>
<version>${revision}</version>
</parent>
<artifactId>apix-admin</artifactId>
<name>APIX :: Admin</name>
<description>Internal maintainer UI at admin.api-index.org. API-key auth, no username/password. Port 8084.</description>
<dependencies>
<dependency>
<groupId>org.botstandards</groupId>
<artifactId>apix-common</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-rest</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-rest-qute</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-rest-client</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-rest-client-jackson</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-smallrye-health</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-junit5</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>${quarkus.platform.group-id}</groupId>
<artifactId>quarkus-maven-plugin</artifactId>
<extensions>true</extensions>
<executions>
<execution>
<goals>
<goal>build</goal>
<goal>generate-code</goal>
<goal>generate-code-tests</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,46 @@
package org.botstandards.apix.admin.client;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType;
import org.botstandards.apix.common.*;
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient;
import java.util.Map;
@RegisterRestClient(configKey = "registry")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public interface AdminRegistryClient {
@GET
@Path("/admin/sandboxes")
AdminSandboxListResponse listSandboxes(
@HeaderParam("X-Admin-Key") String adminKey,
@QueryParam("page") int page,
@QueryParam("size") int size,
@QueryParam("tier") String tier);
@GET
@Path("/admin/stats")
AdminStatsResponse stats(@HeaderParam("X-Admin-Key") String adminKey);
@GET
@Path("/admin/sandboxes/{uuid}")
SandboxDashboardResponse sandboxDetail(
@HeaderParam("X-Admin-Key") String adminKey,
@PathParam("uuid") String uuid);
@PATCH
@Path("/admin/sandboxes/{uuid}/tier")
Map<String, Object> promoteTier(
@HeaderParam("X-Admin-Key") String adminKey,
@PathParam("uuid") String uuid,
Map<String, String> body);
@PATCH
@Path("/sandbox/admin/{uuid}/tier")
Map<String, Object> promoteTierLegacy(
@HeaderParam("X-Admin-Key") String adminKey,
@PathParam("uuid") String uuid,
Map<String, String> body);
}
@@ -0,0 +1,41 @@
package org.botstandards.apix.admin.filter;
import jakarta.ws.rs.container.ContainerRequestContext;
import jakarta.ws.rs.container.ContainerRequestFilter;
import jakarta.ws.rs.core.Cookie;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.UriInfo;
import jakarta.ws.rs.ext.Provider;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import java.io.IOException;
import java.net.URI;
@Provider
public class AdminAuthFilter implements ContainerRequestFilter {
@ConfigProperty(name = "apix.admin.key")
String adminKey;
@ConfigProperty(name = "apix.admin.cookie-name")
String cookieName;
private static final java.util.Set<String> PUBLIC_PATHS = java.util.Set.of(
"/login", "/q/health", "/q/health/live", "/q/health/ready"
);
@Override
public void filter(ContainerRequestContext ctx) throws IOException {
UriInfo uri = ctx.getUriInfo();
String path = uri.getRequestUri().getPath();
for (String pub : PUBLIC_PATHS) {
if (path.startsWith(pub)) return;
}
Cookie session = ctx.getCookies().get(cookieName);
if (session != null && adminKey.equals(session.getValue())) return;
ctx.abortWith(Response.seeOther(URI.create("/login")).build());
}
}
@@ -0,0 +1,82 @@
package org.botstandards.apix.admin.resource;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.quarkus.qute.CheckedTemplate;
import io.quarkus.qute.TemplateInstance;
import jakarta.inject.Inject;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.*;
import java.util.Map;
import org.botstandards.apix.common.AdminStatsResponse;
import org.botstandards.apix.common.SandboxDashboardResponse;
import org.botstandards.apix.admin.client.AdminRegistryClient;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.eclipse.microprofile.rest.client.inject.RestClient;
import org.jboss.logging.Logger;
@Path("/")
@Produces(MediaType.TEXT_HTML)
public class DashboardResource {
private static final Logger LOG = Logger.getLogger(DashboardResource.class);
@Inject @RestClient
AdminRegistryClient registry;
@Inject
ObjectMapper mapper;
@ConfigProperty(name = "apix.admin.key")
String adminKey;
@CheckedTemplate
static class Templates {
static native TemplateInstance global(AdminStatsResponse stats, String statsJson);
static native TemplateInstance sandbox(SandboxDashboardResponse dashboard, String dataJson);
static native TemplateInstance error(String message);
}
@GET
public TemplateInstance globalDashboard() {
try {
AdminStatsResponse stats = registry.stats(adminKey);
String json = mapper.writeValueAsString(stats).replace("</", "<\\/");
return Templates.global(stats, json);
} catch (Exception e) {
LOG.errorf(e, "Failed to load global stats");
return Templates.error("Could not load stats from registry");
}
}
@GET
@Path("/sandbox/{uuid}/poll")
@Produces(MediaType.APPLICATION_JSON)
public Response sandboxPoll(@PathParam("uuid") String uuid) {
try {
SandboxDashboardResponse d = registry.sandboxDetail(adminKey, uuid);
long total = d.usage() == null ? 0L :
d.usage().values().stream().mapToLong(Long::longValue).sum();
String json = mapper.writeValueAsString(Map.of(
"totalRequests", total,
"ratePerMinute", d.ratePerMinute()
));
return Response.ok(json).type(MediaType.APPLICATION_JSON).build();
} catch (Exception e) {
LOG.errorf(e, "Poll failed for sandbox %s", uuid);
return Response.serverError().build();
}
}
@GET
@Path("/sandbox/{uuid}")
public TemplateInstance sandboxDashboard(@PathParam("uuid") String uuid) {
try {
SandboxDashboardResponse dashboard = registry.sandboxDetail(adminKey, uuid);
String json = mapper.writeValueAsString(dashboard).replace("</", "<\\/");
return Templates.sandbox(dashboard, json);
} catch (Exception e) {
LOG.errorf(e, "Failed to load sandbox %s", uuid);
return Templates.error("Could not load sandbox " + uuid);
}
}
}
@@ -0,0 +1,59 @@
package org.botstandards.apix.admin.resource;
import io.quarkus.qute.CheckedTemplate;
import io.quarkus.qute.TemplateInstance;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.*;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import java.net.URI;
@Path("/login")
public class LoginResource {
@ConfigProperty(name = "apix.admin.key")
String adminKey;
@ConfigProperty(name = "apix.admin.cookie-name")
String cookieName;
@CheckedTemplate
static class Templates {
static native TemplateInstance login(boolean invalid);
}
@GET
@Produces(MediaType.TEXT_HTML)
public TemplateInstance loginPage() {
return Templates.login(false);
}
@POST
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
public Response authenticate(@FormParam("key") String key) {
if (key == null || !adminKey.equals(key.strip())) {
return Response.ok(Templates.login(true).render())
.type(MediaType.TEXT_HTML)
.build();
}
NewCookie session = new NewCookie.Builder(cookieName)
.value(adminKey)
.path("/")
.httpOnly(true)
.sameSite(NewCookie.SameSite.STRICT)
.maxAge(28800) // 8 hours
.build();
return Response.seeOther(URI.create("/")).cookie(session).build();
}
@GET
@Path("/logout")
public Response logout() {
NewCookie expired = new NewCookie.Builder(cookieName)
.value("")
.path("/")
.maxAge(0)
.build();
return Response.seeOther(URI.create("/login")).cookie(expired).build();
}
}
@@ -0,0 +1,65 @@
package org.botstandards.apix.admin.resource;
import io.quarkus.qute.CheckedTemplate;
import io.quarkus.qute.TemplateInstance;
import jakarta.inject.Inject;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.*;
import org.botstandards.apix.common.AdminSandboxListResponse;
import org.botstandards.apix.admin.client.AdminRegistryClient;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.eclipse.microprofile.rest.client.inject.RestClient;
import org.jboss.logging.Logger;
import java.net.URI;
import java.util.Map;
@Path("/sandboxes")
@Produces(MediaType.TEXT_HTML)
public class SandboxListResource {
private static final Logger LOG = Logger.getLogger(SandboxListResource.class);
@Inject @RestClient
AdminRegistryClient registry;
@ConfigProperty(name = "apix.admin.key")
String adminKey;
@CheckedTemplate
static class Templates {
static native TemplateInstance list(AdminSandboxListResponse result, int page, int size, String tier,
boolean hasPrev, boolean hasNext, int displayPage, int prevPage, int nextPage);
static native TemplateInstance error(String message);
}
@GET
public TemplateInstance list(
@QueryParam("page") @DefaultValue("0") int page,
@QueryParam("size") @DefaultValue("50") int size,
@QueryParam("tier") @DefaultValue("") String tier) {
try {
String tierParam = tier.isBlank() ? null : tier;
AdminSandboxListResponse result = registry.listSandboxes(adminKey, page, size, tierParam);
boolean hasPrev = page > 0;
boolean hasNext = (long)(page + 1) * size < result.total();
return Templates.list(result, page, size, tier,
hasPrev, hasNext, page + 1, page - 1, page + 1);
} catch (Exception e) {
LOG.errorf(e, "Failed to load sandbox list");
return Templates.error("Could not load sandbox list");
}
}
@POST
@Path("/{uuid}/promote")
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
public Response promote(@PathParam("uuid") String uuid, @FormParam("tier") String tier) {
try {
registry.promoteTier(adminKey, uuid, Map.of("tier", tier.toUpperCase()));
} catch (Exception e) {
LOG.errorf(e, "Failed to promote sandbox %s to %s", uuid, tier);
}
return Response.seeOther(URI.create("/sandbox/" + uuid)).build();
}
}
@@ -0,0 +1,247 @@
/* admin.js — shared between global dashboard and sandbox drill-down */
(function () {
// ── Relative expiry display ─────────────────────────────────────────────────
const expiresRaw = document.getElementById('expires-raw');
const expiresVal = document.getElementById('expires-val');
if (expiresRaw && expiresVal) {
const d = new Date(expiresRaw.textContent.trim());
const days = Math.round((d - Date.now()) / 86400000);
expiresVal.textContent = days < 0 ? `expired ${-days}d ago` : `${days}d left`;
expiresVal.style.color = days < 7 ? '#f85149' : days < 30 ? '#e3b341' : '#3fb950';
}
// ── Request count formatting ────────────────────────────────────────────────
const reqFmt = document.getElementById('req-fmt');
if (reqFmt && window.__D) {
const n = __D.totalRequests || 0;
reqFmt.textContent = n >= 1e6 ? (n / 1e6).toFixed(1) + 'M'
: n >= 1e3 ? (n / 1e3).toFixed(1) + 'k'
: n.toString();
}
// ── Timestamp ───────────────────────────────────────────────────────────────
const ts = document.getElementById('refresh-ts');
if (ts) ts.textContent = 'loaded ' + new Date().toLocaleTimeString();
// ── D3 world map ────────────────────────────────────────────────────────────
const mapEl = document.getElementById('world-map');
if (!mapEl || typeof d3 === 'undefined') return;
const W = mapEl.parentElement.clientWidth || 960;
const H = Math.round(W * 0.42);
mapEl.setAttribute('viewBox', `0 0 ${W} ${H}`);
mapEl.style.height = H + 'px';
const projection = d3.geoNaturalEarth1()
.scale(W / 6.3)
.translate([W / 2, H / 2]);
const path = d3.geoPath().projection(projection);
const svg = d3.select(mapEl);
const TIER_COLORS = {
FREE: '#484f58', STANDARD: '#388bfd', PROFESSIONAL: '#a371f7',
COMMUNITY: '#3fb950', FOUNDER: '#e3b341', DEMO: '#58a6ff'
};
fetch('https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json')
.then(r => r.json())
.then(world => {
svg.append('g').selectAll('path')
.data(topojson.feature(world, world.objects.countries).features)
.join('path')
.attr('d', path)
.attr('fill', '#0d2033')
.attr('stroke', '#1c2d3f')
.attr('stroke-width', 0.4);
// Points: from stats (global) or sandbox (drill-down)
let points = [];
if (window.__D) {
if (__D.registrarPoints) {
// global dashboard
points = __D.registrarPoints.map(p => ({
lat: p.lat, lon: p.lon, label: p.name, tier: p.tier, type: 'registrar'
}));
} else if (__D.sandboxId) {
// sandbox drill-down
if (__D.registrarLat != null) {
points.push({ lat: __D.registrarLat, lon: __D.registrarLon, label: __D.name, tier: __D.tier, type: 'registrar' });
}
// agent blinks — always, even when sandbox has no declared location
(__D.recentVisits || []).forEach(v => {
points.push({ lat: v.lat, lon: v.lon, type: 'agent' });
});
}
}
// Agent blinks
svg.append('g').selectAll('circle.agent')
.data(points.filter(p => p.type === 'agent'))
.join('circle')
.attr('class', 'agent-blink')
.attr('cx', p => projection([p.lon, p.lat])?.[0])
.attr('cy', p => projection([p.lon, p.lat])?.[1])
.attr('r', 3)
.style('animation-delay', (_, i) => `${(i * 0.4) % 2.8}s`);
// Registrar stars
svg.append('g').selectAll('text.star')
.data(points.filter(p => p.type === 'registrar'))
.join('text')
.attr('class', 'registrar-star')
.attr('x', p => projection([p.lon, p.lat])?.[0])
.attr('y', p => projection([p.lon, p.lat])?.[1])
.attr('fill', p => TIER_COLORS[p.tier] || '#ffd700')
.text('★')
.append('title').text(p => `${p.label} (${p.tier})`);
});
// ── Tier donut (global dashboard only) ─────────────────────────────────────
const donutEl = document.getElementById('donut');
const legendEl = document.getElementById('tier-legend');
if (donutEl && window.__D && __D.byTier) {
const data = Object.entries(__D.byTier).map(([tier, count]) => ({ tier, count }));
const total = data.reduce((s, d) => s + d.count, 0);
const cx = 60, cy = 60, r = 46, ri = 28;
const svgD = d3.select(donutEl);
const pie = d3.pie().value(d => d.count).sort(null);
const arc = d3.arc().innerRadius(ri).outerRadius(r);
svgD.append('g').attr('transform', `translate(${cx},${cy})`)
.selectAll('path').data(pie(data)).join('path')
.attr('d', arc)
.attr('fill', d => TIER_COLORS[d.data.tier] || '#484f58')
.attr('stroke', '#0d1117').attr('stroke-width', 1.5)
.append('title').text(d => `${d.data.tier}: ${d.data.count}`);
svgD.append('text').attr('x', cx).attr('y', cy + 5)
.attr('text-anchor', 'middle').attr('dominant-baseline', 'middle')
.attr('fill', '#e6edf3').attr('font-size', '18').attr('font-weight', '700')
.text(total);
data.forEach(d => {
const li = document.createElement('li');
li.innerHTML = `<span class="tier-dot" style="background:${TIER_COLORS[d.tier] || '#484f58'}"></span>
<span style="color:#8b949e">${d.tier}</span>
<span style="margin-left:auto;color:#e6edf3">${d.count}</span>`;
legendEl.appendChild(li);
});
}
})();
// ── Sandbox live rate chart (sandbox drill-down only) ──────────────────────
(function () {
var el = document.getElementById('rate-chart');
if (!el || !window.__D || !__D.sandboxId) return;
// Admin always polls at 1 s when active; idles back to 5 s after 15 s of no traffic
// Tier-dependent rate would live in a future sandbox-owner dashboard, not here
var FAST_INTERVAL = 1000;
var IDLE_INTERVAL = 5000;
var IDLE_AFTER_MS = 15000; // 15 s inactivity → idle
var CHART_TICK = 5000; // sliding-window size for rate calculation
var BUCKETS = 72; // 72 s visible (1 s/bucket)
var rateLimit = __D.ratePerMinute || 1;
var sid = __D.sandboxId;
var buf = new Array(BUCKETS).fill(0);
var lastTotal = null;
var lastActivity = Date.now();
var currentInterval = FAST_INTERVAL;
var recentPolls = []; // {t, delta} — sliding window entries
var ML = 36, MB = 18;
var W = el.parentElement.clientWidth || 900;
var H = 96, CH = H - MB;
el.setAttribute('viewBox', '0 0 ' + W + ' ' + H);
el.style.width = '100%';
var yS = d3.scaleLinear().domain([0, rateLimit * 1.1]).range([CH, 0]);
var xS = d3.scaleLinear().domain([0, BUCKETS - 1]).range([ML, W]);
var areaFn = d3.area()
.x(function (_, i) { return xS(i); })
.y0(CH)
.y1(function (d) { return yS(d); })
.curve(d3.curveCatmullRom.alpha(0.5));
var svg = d3.select(el);
[0, 50, 100].forEach(function (pct) {
var y = yS(rateLimit * pct / 100);
svg.append('line').attr('x1', ML).attr('x2', W).attr('y1', y).attr('y2', y).attr('class', 'axis-line');
svg.append('text').attr('x', ML - 4).attr('y', y + 3).attr('text-anchor', 'end').attr('class', 'axis-label').text(pct + '%');
});
svg.append('line').attr('x1', ML).attr('x2', W).attr('y1', CH).attr('y2', CH).attr('class', 'axis-line');
[{bucket: 0, label: '-72s', anchor: 'start'}, {bucket: BUCKETS / 2, label: '-36s', anchor: 'middle'}, {bucket: BUCKETS - 1, label: 'now', anchor: 'end'}]
.forEach(function (t) {
svg.append('text').attr('x', xS(t.bucket)).attr('y', H - 4).attr('text-anchor', t.anchor).attr('class', 'axis-label').text(t.label);
});
var ly = yS(rateLimit);
svg.append('line').attr('x1', ML).attr('x2', W).attr('y1', ly).attr('y2', ly).attr('class', 'rate-limit-line');
// Clip path — area can never escape the plot region regardless of data spikes
var clipId = 'rc-' + sid.slice(0, 8);
svg.append('defs').append('clipPath').attr('id', clipId)
.append('rect').attr('x', ML).attr('y', 0).attr('width', W - ML).attr('height', CH);
var path = svg.append('path').attr('class', 'rate-area').attr('clip-path', 'url(#' + clipId + ')');
function fmt(n) {
return n >= 1e6 ? (n / 1e6).toFixed(1) + 'M' : n >= 1e3 ? (n / 1e3).toFixed(1) + 'k' : String(n);
}
function updateLabel(rpm) {
var pct = (rpm / rateLimit * 100).toFixed(1);
var elNow = document.getElementById('rate-now');
var elLive = document.getElementById('rate-live-val');
if (elNow) elNow.textContent = fmt(rpm);
if (elLive) elLive.textContent = fmt(rpm) + ' req/min (' + pct + '%)';
}
// Sliding-window rate: sum of deltas in the last CHART_TICK ms, extrapolated to rpm.
// No hard reset at bucket boundaries — rate transitions are smooth.
function liveRpm() {
var now = Date.now();
var cutoff = now - CHART_TICK;
while (recentPolls.length && recentPolls[0].t < cutoff) recentPolls.shift();
var sum = recentPolls.reduce(function (s, e) { return s + e.delta; }, 0);
return Math.min(Math.round(sum * (60000 / CHART_TICK)), rateLimit);
}
async function poll() {
try {
var r = await fetch('/sandbox/' + sid + '/poll');
if (!r.ok) return;
var d = await r.json();
var total = Number(d.totalRequests) || 0;
if (lastTotal !== null) {
var delta = Math.max(0, total - lastTotal);
if (delta > 0) {
recentPolls.push({t: Date.now(), delta: delta});
lastActivity = Date.now();
}
var rpm = liveRpm();
updateLabel(rpm);
// Flush + redraw in one step — no separate timer, no competing transitions
buf.push(rpm); buf.shift();
path.transition().duration(950).ease(d3.easeLinear).attr('d', areaFn(buf));
}
lastTotal = total;
currentInterval = (Date.now() - lastActivity > IDLE_AFTER_MS) ? IDLE_INTERVAL : FAST_INTERVAL;
} catch (_) {}
}
function scheduleNext() {
var interval = currentInterval;
setTimeout(function () { poll().then(scheduleNext); }, interval);
}
poll(); // set lastTotal immediately
scheduleNext();
})();
@@ -0,0 +1,18 @@
quarkus.http.port=8084
quarkus.smallrye-health.root-path=/q/health
quarkus.log.level=${LOG_LEVEL:INFO}
# Registry REST client — all admin API calls go through here
quarkus.rest-client.registry.url=${APIX_REGISTRY_URL:http://registry:8180}
quarkus.rest-client.registry.connect-timeout=5000
quarkus.rest-client.registry.read-timeout=15000
# Admin API key — must match APIX_API_KEY on the registry
apix.admin.key=${APIX_API_KEY:dev-insecure-key-change-in-prod}
# Session cookie name
apix.admin.cookie-name=apix_admin_session
# Static assets — short cache so deploys take effect without hard-refresh
quarkus.http.static-resources.max-age=300
quarkus.http.static-resources.immutable-paths=
@@ -0,0 +1,22 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>APIX Admin — Error</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { background: #0d1117; color: #c9d1d9; font-family: 'SF Mono','Consolas',monospace; display: flex; align-items: center; justify-content: center; min-height: 100vh; }
.card { text-align: center; }
.label { font-size: 0.75rem; color: #f85149; letter-spacing: 0.1em; margin-bottom: 0.5rem; }
.msg { color: #8b949e; font-size: 0.9rem; margin-bottom: 1.5rem; }
a { color: #58a6ff; }
</style>
</head>
<body>
<div class="card">
<div class="label">ERROR</div>
<div class="msg">{message}</div>
<a href="/">← Back to dashboard</a>
</div>
</body>
</html>
@@ -0,0 +1,111 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>APIX Admin — Dashboard</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; }
a { color: #58a6ff; text-decoration: none; }
a:hover { text-decoration: underline; }
nav {
display: flex; align-items: center; gap: 1.5rem;
padding: 0.75rem 1.5rem; border-bottom: 1px solid #21262d;
background: #161b22;
}
.nav-logo { color: #8b949e; font-size: 0.75rem; }
.nav-logo span { color: #e6edf3; font-weight: 600; font-size: 0.9rem; }
.nav-link { font-size: 0.8rem; color: #8b949e; }
.nav-link.active, .nav-link:hover { color: #e6edf3; }
.nav-right { margin-left: auto; }
.stats-grid {
display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 1px; background: #21262d; border-bottom: 1px solid #21262d;
}
.stat { background: #0d1117; padding: 1.2rem 1.5rem; }
.stat-label { font-size: 0.65rem; color: #484f58; text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 0.3rem; }
.stat-value { font-size: 1.8rem; color: #e6edf3; font-weight: 700; }
.stat-sub { font-size: 0.7rem; color: #484f58; margin-top: 0.2rem; }
.layout { display: grid; grid-template-columns: 1fr 320px; gap: 1px; background: #21262d; }
.panel { background: #0d1117; padding: 1.5rem; }
.panel-title { font-size: 0.7rem; color: #484f58; text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 1rem; }
#map-wrap { background: #060d18; border-bottom: 1px solid #21262d; }
#world-map { display: block; width: 100%; }
.donut-wrap { display: flex; align-items: center; gap: 1.5rem; }
#donut { width: 120px; height: 120px; flex-shrink: 0; }
.tier-list { list-style: none; }
.tier-list li { display: flex; align-items: center; gap: 0.5rem; font-size: 0.8rem; margin-bottom: 0.4rem; }
.tier-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
.footer { padding: 0.75rem 1.5rem; border-top: 1px solid #21262d; font-size: 0.7rem; color: #484f58; display: flex; justify-content: space-between; }
@keyframes star-glow {
0%,100% { filter: drop-shadow(0 0 3px #ffd700) drop-shadow(0 0 6px #ffd70066); }
50% { filter: drop-shadow(0 0 8px #ffd700) drop-shadow(0 0 18px #ffd700aa); }
}
.registrar-star { font-size: 12px; fill: #ffd700; dominant-baseline: central; text-anchor: middle; animation: star-glow 2.4s ease-in-out infinite; cursor: default; }
</style>
</head>
<body>
<nav>
<div class="nav-logo">APIX · <span>Admin</span></div>
<a class="nav-link active" href="/">Dashboard</a>
<a class="nav-link" href="/sandboxes">Sandboxes</a>
<div class="nav-right"><a class="nav-link" href="/login/logout">Sign out</a></div>
</nav>
<div class="stats-grid">
<div class="stat">
<div class="stat-label">Total Sandboxes</div>
<div class="stat-value">{stats.totalSandboxes}</div>
<div class="stat-sub">{stats.activeSandboxes} active · {stats.expiredSandboxes} expired</div>
</div>
<div class="stat">
<div class="stat-label">Services registered</div>
<div class="stat-value">{stats.totalServices}</div>
<div class="stat-sub">across all sandboxes</div>
</div>
<div class="stat">
<div class="stat-label">Total Requests</div>
<div class="stat-value" id="req-fmt"></div>
<div class="stat-sub">cumulative</div>
</div>
</div>
<div id="map-wrap"><svg id="world-map"></svg></div>
<div class="layout">
<div class="panel">
<div class="panel-title">Tier distribution</div>
<div class="donut-wrap">
<svg id="donut" viewBox="0 0 120 120"></svg>
<ul class="tier-list" id="tier-legend"></ul>
</div>
</div>
<div class="panel">
<div class="panel-title">Quick links</div>
<p style="font-size:0.8rem;color:#8b949e;line-height:1.8">
<a href="/sandboxes">All sandboxes →</a><br>
<a href="/sandboxes?tier=COMMUNITY">COMMUNITY tier →</a><br>
<a href="/sandboxes?tier=FREE">FREE tier →</a><br>
<a href="/sandboxes?tier=FOUNDER">FOUNDER tier →</a>
</p>
</div>
</div>
<div class="footer">
<span>APIX Admin · internal</span>
<span id="refresh-ts"></span>
</div>
<script>var __D = {statsJson.raw};</script>
<script src="https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/topojson-client@3/dist/topojson-client.min.js"></script>
<script src="/admin.js?v=12"></script>
</body>
</html>
@@ -0,0 +1,156 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{dashboard.name} · APIX Admin</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; }
a { color: #58a6ff; text-decoration: none; }
a:hover { text-decoration: underline; }
nav {
display: flex; align-items: center; gap: 1.5rem;
padding: 0.75rem 1.5rem; border-bottom: 1px solid #21262d; background: #161b22;
}
.nav-logo { color: #8b949e; font-size: 0.75rem; }
.nav-logo span { color: #e6edf3; font-weight: 600; font-size: 0.9rem; }
.nav-link { font-size: 0.8rem; color: #8b949e; }
.nav-link:hover { color: #e6edf3; }
.nav-right { margin-left: auto; }
.header { padding: 1rem 1.5rem; border-bottom: 1px solid #21262d; display: flex; align-items: center; gap: 1rem; }
.sb-name { font-size: 1rem; color: #e6edf3; font-weight: 600; }
.tier-badge {
background: #161b22; border: 1px solid #30363d;
color: #8b949e; padding: 0.1rem 0.5rem; border-radius: 12px;
font-size: 0.7rem; letter-spacing: 0.05em;
}
.sb-id { font-size: 0.75rem; color: #484f58; }
.stats-grid {
display: grid; grid-template-columns: repeat(auto-fit, minmax(200px,1fr));
gap: 1px; background: #21262d; border-bottom: 1px solid #21262d;
}
.stat { background: #0d1117; padding: 1rem 1.5rem; }
.stat-label { font-size: 0.65rem; color: #484f58; text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 0.3rem; }
.stat-value { font-size: 1.4rem; color: #e6edf3; font-weight: 600; }
.stat-sub { font-size: 0.7rem; color: #484f58; margin-top: 0.2rem; }
#map-wrap { background: #060d18; }
#world-map { display: block; width: 100%; }
.actions { padding: 1.5rem; border-top: 1px solid #21262d; display: flex; gap: 1rem; flex-wrap: wrap; }
.actions-title { width: 100%; font-size: 0.7rem; color: #484f58; text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 0.5rem; }
form { display: flex; gap: 0.5rem; align-items: center; }
select, button {
background: #161b22; border: 1px solid #30363d; color: #c9d1d9;
font-family: inherit; font-size: 0.8rem;
border-radius: 4px; padding: 0.4rem 0.7rem; cursor: pointer;
}
select:focus, button:focus { outline: none; border-color: #58a6ff; }
button.promote { background: #238636; border-color: #238636; color: #fff; font-weight: 600; }
button.promote:hover { background: #2ea043; }
.footer { padding: 0.75rem 1.5rem; border-top: 1px solid #21262d; font-size: 0.7rem; color: #484f58; }
@keyframes star-glow {
0%,100% { filter: drop-shadow(0 0 3px #ffd700) drop-shadow(0 0 6px #ffd70066); }
50% { filter: drop-shadow(0 0 8px #ffd700) drop-shadow(0 0 18px #ffd700aa); }
}
.registrar-star { font-size: 14px; fill: #ffd700; dominant-baseline: central; text-anchor: middle; animation: star-glow 2.4s ease-in-out infinite; }
@keyframes blink-pulse {
0% { opacity:0; r:2; } 25% { opacity:0.9; r:5; } 65% { opacity:0.4; r:4; } 100% { opacity:0; r:2; }
}
.agent-blink { fill: #3d8bfd; opacity:0; animation: blink-pulse 2.8s ease-in-out infinite; }
#rate-wrap { background: #060d18; padding: 0 1.5rem 0.5rem; border-bottom: 1px solid #21262d; }
.rate-header { display: flex; align-items: baseline; gap: 0.75rem; padding: 0.6rem 0 0.4rem; }
.rate-title { font-size: 0.65rem; color: #484f58; text-transform: uppercase; letter-spacing: 0.06em; }
.rate-live { font-size: 0.75rem; color: #388bfd; }
.rate-limit-label { font-size: 0.65rem; color: #f8514966; margin-left: auto; }
#rate-chart { display: block; width: 100%; overflow: visible; }
.rate-area { fill: #1f6feb33; stroke: #388bfd; stroke-width: 1.5; }
.rate-limit-line { stroke: #f85149; stroke-width: 0.8; stroke-dasharray: 4 3; opacity: 0.5; }
.axis-label { font-size: 9px; fill: #484f58; font-family: 'SF Mono','Consolas','Fira Code',monospace; }
.axis-line { stroke: #21262d; stroke-width: 0.5; }
</style>
</head>
<body>
<nav>
<div class="nav-logo">APIX · <span>Admin</span></div>
<a class="nav-link" href="/">Dashboard</a>
<a class="nav-link" href="/sandboxes">Sandboxes</a>
<div class="nav-right"><a class="nav-link" href="/login/logout">Sign out</a></div>
</nav>
<div class="header">
<span class="sb-name">{dashboard.name}</span>
<span class="tier-badge">{dashboard.tier}</span>
<span class="sb-id">{dashboard.sandboxId}</span>
</div>
<div class="stats-grid">
<div class="stat">
<div class="stat-label">Services</div>
<div class="stat-value">{#if dashboard.usage.get('SERVICE_REGISTERED') != null}{dashboard.usage.get('SERVICE_REGISTERED')}{#else}0{/if}</div>
<div class="stat-sub">of {#if dashboard.maxServices != null}{dashboard.maxServices}{#else}∞{/if} allowed</div>
</div>
<div class="stat">
<div class="stat-label">Searches</div>
<div class="stat-value">{#if dashboard.usage.get('SERVICE_SEARCHED') != null}{dashboard.usage.get('SERVICE_SEARCHED')}{#else}0{/if}</div>
<div class="stat-sub">capability discovery calls</div>
</div>
<div class="stat">
<div class="stat-label">Rate limit</div>
<div class="stat-value">{dashboard.ratePerMinute}</div>
<div class="stat-sub">now: <span id="rate-now"></span> req/min</div>
</div>
<div class="stat">
<div class="stat-label">Expires</div>
<div class="stat-value" id="expires-val"></div>
<div class="stat-sub" id="expires-raw">{dashboard.expiresAt}</div>
</div>
{#if dashboard.registrarLocation != null}
<div class="stat">
<div class="stat-label">Location</div>
<div class="stat-value" style="font-size:1rem">{dashboard.registrarLocation}</div>
<div class="stat-sub">owner-declared</div>
</div>
{/if}
</div>
<div id="rate-wrap">
<div class="rate-header">
<span class="rate-title">Live rate</span>
<span class="rate-live" id="rate-live-val">— req/min</span>
<span class="rate-limit-label">limit: {dashboard.ratePerMinute}/min ──</span>
</div>
<svg id="rate-chart" height="96"></svg>
</div>
<div id="map-wrap"><svg id="world-map"></svg></div>
<div class="actions">
<div class="actions-title">Admin actions</div>
<form method="POST" action="/sandboxes/{dashboard.sandboxId}/promote">
<select name="tier">
<option value="FREE">FREE</option>
<option value="STANDARD">STANDARD</option>
<option value="PROFESSIONAL">PROFESSIONAL</option>
<option value="COMMUNITY">COMMUNITY</option>
<option value="FOUNDER">FOUNDER</option>
<option value="DEMO">DEMO</option>
</select>
<button type="submit" class="promote">Promote tier</button>
</form>
</div>
<div class="footer">sandbox id: {dashboard.sandboxId} · created {dashboard.createdAt}</div>
<script>var __D = {dataJson.raw};</script>
<script src="https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/topojson-client@3/dist/topojson-client.min.js"></script>
<script src="/admin.js?v=12"></script>
</body>
</html>
@@ -0,0 +1,60 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>APIX Admin — Login</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: #0d1117; color: #c9d1d9;
font-family: 'SF Mono', 'Consolas', 'Fira Code', monospace;
display: flex; align-items: center; justify-content: center;
min-height: 100vh;
}
.card {
width: 380px;
background: #161b22;
border: 1px solid #30363d;
border-radius: 6px;
padding: 2rem;
}
.logo { color: #8b949e; font-size: 0.75rem; letter-spacing: 0.1em; margin-bottom: 1.5rem; }
.logo span { color: #e6edf3; font-size: 1rem; font-weight: 600; }
label { display: block; font-size: 0.75rem; color: #8b949e; margin-bottom: 0.4rem; letter-spacing: 0.04em; }
input[type=password] {
width: 100%; padding: 0.6rem 0.8rem;
background: #0d1117; border: 1px solid #30363d;
border-radius: 4px; color: #e6edf3;
font-family: inherit; font-size: 0.9rem;
outline: none; margin-bottom: 1rem;
}
input[type=password]:focus { border-color: #58a6ff; }
button {
width: 100%; padding: 0.65rem;
background: #238636; border: none;
border-radius: 4px; color: #fff;
font-family: inherit; font-size: 0.9rem;
cursor: pointer; font-weight: 600;
}
button:hover { background: #2ea043; }
.error {
background: #2d1b1b; border: 1px solid #f85149;
color: #f85149; border-radius: 4px;
padding: 0.5rem 0.8rem; font-size: 0.8rem;
margin-bottom: 1rem;
}
</style>
</head>
<body>
<div class="card">
<div class="logo">APIX · <span>Admin</span></div>
{#if invalid}<div class="error">Invalid admin key. Try again.</div>{/if}
<form method="POST" action="/login">
<label>Admin API Key</label>
<input type="password" name="key" autofocus autocomplete="current-password" placeholder="apix_...">
<button type="submit">Sign in</button>
</form>
</div>
</body>
</html>
@@ -0,0 +1,7 @@
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><title>Error · APIX Admin</title>
<style>* { box-sizing:border-box;margin:0;padding:0; } body { background:#0d1117;color:#c9d1d9;font-family:monospace;display:flex;align-items:center;justify-content:center;min-height:100vh; } .card { text-align:center; } .label { color:#f85149;font-size:.75rem;margin-bottom:.5rem; } .msg { color:#8b949e;margin-bottom:1.5rem; } a { color:#58a6ff; }</style>
</head>
<body><div class="card"><div class="label">ERROR</div><div class="msg">{message}</div><a href="/sandboxes">← Back to list</a></div></body>
</html>
@@ -0,0 +1,130 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sandboxes · APIX Admin</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: 13px; }
a { color: #58a6ff; text-decoration: none; }
a:hover { text-decoration: underline; }
nav {
display: flex; align-items: center; gap: 1.5rem;
padding: 0.75rem 1.5rem; border-bottom: 1px solid #21262d; background: #161b22;
}
.nav-logo { color: #8b949e; font-size: 0.75rem; }
.nav-logo span { color: #e6edf3; font-weight: 600; font-size: 0.9rem; }
.nav-link { font-size: 0.8rem; color: #8b949e; }
.nav-link.active, .nav-link:hover { color: #e6edf3; }
.nav-right { margin-left: auto; }
.toolbar {
padding: 0.75rem 1.5rem; border-bottom: 1px solid #21262d;
display: flex; gap: 0.75rem; align-items: center; flex-wrap: wrap;
}
.toolbar-label { font-size: 0.7rem; color: #484f58; }
.filter-link {
font-size: 0.75rem; color: #8b949e;
padding: 0.2rem 0.6rem; border: 1px solid #30363d; border-radius: 12px;
}
.filter-link:hover, .filter-link.active { color: #e6edf3; border-color: #58a6ff; }
.count { margin-left: auto; font-size: 0.75rem; color: #484f58; }
table { width: 100%; border-collapse: collapse; }
th {
text-align: left; padding: 0.6rem 1.5rem;
font-size: 0.65rem; color: #484f58; text-transform: uppercase; letter-spacing: 0.06em;
border-bottom: 1px solid #21262d; background: #161b22;
}
td { padding: 0.55rem 1.5rem; border-bottom: 1px solid #161b22; vertical-align: middle; }
tr:hover td { background: #161b22; }
.tier-badge {
background: #0d1117; border: 1px solid #30363d;
color: #8b949e; padding: 0.1rem 0.4rem; border-radius: 10px;
font-size: 0.65rem; letter-spacing: 0.04em;
}
.tier-COMMUNITY { border-color: #1f6feb; color: #58a6ff; }
.tier-FOUNDER { border-color: #9e6a03; color: #e3b341; }
.tier-DEMO { border-color: #388bfd; color: #79c0ff; }
.expired { color: #f85149; }
.ok { color: #3fb950; }
.pagination { padding: 1rem 1.5rem; display: flex; gap: 1rem; align-items: center; border-top: 1px solid #21262d; }
.page-link { font-size: 0.8rem; color: #58a6ff; }
.page-info { font-size: 0.75rem; color: #484f58; }
</style>
</head>
<body>
<nav>
<div class="nav-logo">APIX · <span>Admin</span></div>
<a class="nav-link" href="/">Dashboard</a>
<a class="nav-link active" href="/sandboxes">Sandboxes</a>
<div class="nav-right"><a class="nav-link" href="/login/logout">Sign out</a></div>
</nav>
<div class="toolbar">
<span class="toolbar-label">Filter:</span>
<a class="filter-link {#if tier == ''}active{/if}" href="/sandboxes">All</a>
<a class="filter-link {#if tier == 'FREE'}active{/if}" href="/sandboxes?tier=FREE">FREE</a>
<a class="filter-link {#if tier == 'COMMUNITY'}active{/if}" href="/sandboxes?tier=COMMUNITY">COMMUNITY</a>
<a class="filter-link {#if tier == 'FOUNDER'}active{/if}" href="/sandboxes?tier=FOUNDER">FOUNDER</a>
<a class="filter-link {#if tier == 'DEMO'}active{/if}" href="/sandboxes?tier=DEMO">DEMO</a>
<span class="count">{result.total} total</span>
</div>
<table>
<thead>
<tr>
<th>Name</th>
<th>Tier</th>
<th>Services</th>
<th>Requests</th>
<th>Location</th>
<th>Created</th>
<th>Expires</th>
</tr>
</thead>
<tbody>
{#for sb in result.sandboxes}
<tr>
<td><a href="/sandbox/{sb.sandboxId}">{sb.name}</a>
<div style="font-size:0.65rem;color:#484f58;margin-top:2px">{sb.sandboxId}</div>
</td>
<td><span class="tier-badge tier-{sb.tier}">{sb.tier}</span></td>
<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}
</tbody>
</table>
<div class="pagination">
{#if hasPrev}
<a class="page-link" href="/sandboxes?page={prevPage}&size={size}&tier={tier}">← Previous</a>
{/if}
<span class="page-info">Page {displayPage} · {result.sandboxes.size} of {result.total}</span>
{#if hasNext}
<a class="page-link" href="/sandboxes?page={nextPage}&size={size}&tier={tier}">Next →</a>
{/if}
</div>
<script>
document.querySelectorAll('[data-expires]').forEach(td => {
const d = new Date(td.dataset.expires);
const now = new Date();
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>
+408
View File
@@ -0,0 +1,408 @@
// @ts-check
/**
* Playwright tests for the sandbox live rate chart (admin.js).
*
* Strategy
* ─────────
* Tests are fully self-contained — no live Quarkus server required.
*
* page.route() intercepts:
* /sandbox/:id → minimal HTML fixture with __D baked in
* /admin.js → real admin.js from local disk
* /sandbox/:id/poll → controlled JSON per test
* CDN (d3, topojson) → pass-through (real CDN, requires internet)
* world-atlas CDN → empty TopoJSON stub (no countries, no network dep)
*
* Environment variables
* ─────────────────────
* APIX_ADMIN_URL Base URL used as origin for route interception
* (default: http://localhost:8084). Does not need to be live.
*/
const { test, expect } = require('@playwright/test');
const path = require('path');
const fs = require('fs');
const SANDBOX_ID = '29a22236-d2fa-471a-902d-caec592f19b0';
const RATE_LIMIT = 60;
const POLL_PATH = `/sandbox/${SANDBOX_ID}/poll`;
const PAGE_PATH = `/sandbox/${SANDBOX_ID}`;
const ADMIN_JS_PATH = path.resolve(
__dirname,
'../src/main/resources/META-INF/resources/admin.js'
);
// Minimal world-atlas stub — valid TopoJSON, no countries, no CDN hit.
const TOPO_STUB = JSON.stringify({
type: 'Topology',
objects: { countries: { type: 'GeometryCollection', geometries: [] } },
arcs: [],
transform: { scale: [1, 1], translate: [0, 0] },
});
// Default __D payload (mirrors SandboxDashboardResponse).
const BASE_D = {
sandboxId: SANDBOX_ID,
name: 'chart-demo',
tier: 'FREE',
ratePerMinute: RATE_LIMIT,
expiresAt: new Date(Date.now() + 30 * 86400000).toISOString(),
registrarLat: 52.52,
registrarLon: 13.405,
recentVisits: [],
usage: {},
};
/**
* Renders the minimal sandbox HTML fixture with __D embedded.
* Mirrors the structure of sandbox.html just enough for admin.js to work.
*/
function buildFixture(D) {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>${D.name} · APIX Admin (test)</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; }
a { color: #58a6ff; text-decoration: none; }
nav { display: flex; align-items: center; gap: 1.5rem;
padding: 0.75rem 1.5rem; border-bottom: 1px solid #21262d; background: #161b22; }
.nav-logo { color: #8b949e; font-size: 0.75rem; }
.nav-logo span { color: #e6edf3; font-weight: 600; font-size: 0.9rem; }
.nav-link { font-size: 0.8rem; color: #8b949e; }
.nav-right { margin-left: auto; }
.header { padding: 1rem 1.5rem; border-bottom: 1px solid #21262d;
display: flex; align-items: center; gap: 1rem; }
.sb-name { font-size: 1rem; color: #e6edf3; font-weight: 600; }
.tier-badge { background: #161b22; border: 1px solid #30363d; color: #8b949e;
padding: 0.1rem 0.5rem; border-radius: 12px;
font-size: 0.7rem; letter-spacing: 0.05em; }
.sb-id { font-size: 0.75rem; color: #484f58; }
.stat { background: #0d1117; padding: 1rem 1.5rem; }
.stat-label { font-size: 0.65rem; color: #484f58; text-transform: uppercase;
letter-spacing: 0.06em; margin-bottom: 0.3rem; }
.stat-value { font-size: 1.4rem; color: #e6edf3; font-weight: 600; }
.stat-sub { font-size: 0.7rem; color: #484f58; margin-top: 0.2rem; }
#map-wrap { background: #060d18; }
#world-map { display: block; width: 100%; }
#rate-wrap { background: #060d18; padding: 0 1.5rem 0.5rem; border-bottom: 1px solid #21262d; }
.rate-header { display: flex; align-items: baseline; gap: 0.75rem; padding: 0.6rem 0 0.4rem; }
.rate-title { font-size: 0.65rem; color: #484f58; text-transform: uppercase; letter-spacing: 0.06em; }
.rate-live { font-size: 0.75rem; color: #388bfd; }
.rate-limit-label { font-size: 0.65rem; color: #f8514966; margin-left: auto; }
#rate-chart { display: block; width: 100%; overflow: visible; }
.rate-area { fill: #1f6feb33; stroke: #388bfd; stroke-width: 1.5; }
.rate-limit-line { stroke: #f85149; stroke-width: 0.8; stroke-dasharray: 4 3; opacity: 0.5; }
.axis-label { font-size: 9px; fill: #484f58;
font-family: 'SF Mono','Consolas','Fira Code',monospace; }
.axis-line { stroke: #21262d; stroke-width: 0.5; }
@keyframes star-glow {
0%,100% { filter: drop-shadow(0 0 3px #ffd700); }
50% { filter: drop-shadow(0 0 8px #ffd700); }
}
.registrar-star { font-size: 14px; fill: #ffd700; dominant-baseline: central;
text-anchor: middle; animation: star-glow 2.4s ease-in-out infinite; }
@keyframes blink-pulse {
0% { opacity:0; r:2; } 25% { opacity:0.9; r:5; } 65% { opacity:0.4; r:4; } 100% { opacity:0; r:2; }
}
.agent-blink { fill: #3d8bfd; opacity:0; animation: blink-pulse 2.8s ease-in-out infinite; }
</style>
</head>
<body>
<nav>
<div class="nav-logo">APIX · <span>Admin</span></div>
<a class="nav-link" href="/">Dashboard</a>
<a class="nav-link" href="/sandboxes">Sandboxes</a>
<div class="nav-right"><a class="nav-link" href="/login/logout">Sign out</a></div>
</nav>
<div class="header">
<span class="sb-name">${D.name}</span>
<span class="tier-badge">${D.tier}</span>
<span class="sb-id">${D.sandboxId}</span>
</div>
<div class="stat">
<div class="stat-label">Rate limit</div>
<div class="stat-value">${D.ratePerMinute}</div>
<div class="stat-sub">now: <span id="rate-now">—</span> req/min</div>
</div>
<div class="stat">
<div class="stat-label">Expires</div>
<div class="stat-value" id="expires-val">—</div>
<div class="stat-sub" id="expires-raw">${D.expiresAt}</div>
</div>
<div id="rate-wrap">
<div class="rate-header">
<span class="rate-title">Live rate</span>
<span class="rate-live" id="rate-live-val">— req/min</span>
<span class="rate-limit-label">limit: ${D.ratePerMinute}/min ──</span>
</div>
<svg id="rate-chart" height="96"></svg>
</div>
<div id="map-wrap"><svg id="world-map"></svg></div>
<script>var __D = ${JSON.stringify(D)};</script>
<script src="https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/topojson-client@3/dist/topojson-client.min.js"></script>
<script src="/admin.js"></script>
</body>
</html>`;
}
// ── helpers ───────────────────────────────────────────────────────────────────
/**
* Wire common routes (page HTML, admin.js, CDN stubs) and navigate.
*
* @param {import('@playwright/test').Page} page
* @param {object} [dOverride] Fields merged into BASE_D for this test
*/
async function loadPage(page, dOverride = {}) {
const D = { ...BASE_D, ...dOverride };
const html = buildFixture(D);
const js = fs.readFileSync(ADMIN_JS_PATH, 'utf8');
await page.route(PAGE_PATH, route =>
route.fulfill({ contentType: 'text/html; charset=utf-8', body: html })
);
await page.route('**/admin.js**', route =>
route.fulfill({ contentType: 'application/javascript', body: js })
);
// Stub world-atlas so map renders without internet
await page.route('**/world-atlas*/**', route =>
route.fulfill({ contentType: 'application/json', body: TOPO_STUB })
);
await page.goto(PAGE_PATH);
// Wait for D3 and admin.js to initialise
await page.waitForFunction(() => typeof d3 !== 'undefined', { timeout: 15000 });
}
/**
* Wire a controllable poll mock and return control handles.
*
* @param {import('@playwright/test').Page} page
* @returns {{ setTotal: (n: number) => void, getCallCount: () => number }}
*/
function mockPoll(page) {
let total = 0;
let callCount = 0;
page.route(`**${POLL_PATH}`, route => {
callCount++;
route.fulfill({
contentType: 'application/json',
body: JSON.stringify({ totalRequests: total, ratePerMinute: RATE_LIMIT }),
});
});
return {
setTotal: (n) => { total = n; },
getCallCount: () => callCount,
};
}
// ── tests ─────────────────────────────────────────────────────────────────────
test.describe('Sandbox live rate chart', () => {
// ── 1. Static structure ───────────────────────────────────────────────────
test('axes render — Y labels 0/50/100% and X labels -72s/-36s/now', async ({ page }) => {
mockPoll(page);
await loadPage(page);
const chart = page.locator('#rate-chart');
await expect(chart).toBeVisible();
for (const label of ['0%', '50%', '100%']) {
await expect(
chart.locator(`text.axis-label:text-is("${label}")`)
).toBeVisible();
}
for (const label of ['-72s', '-36s', 'now']) {
await expect(
chart.locator(`text.axis-label:text-is("${label}")`)
).toBeVisible();
}
});
test('rate-limit line is rendered', async ({ page }) => {
mockPoll(page);
await loadPage(page);
const limitLine = page.locator('#rate-chart line.rate-limit-line');
await expect(limitLine).toHaveCount(1);
const y = await limitLine.getAttribute('y1');
expect(Number(y)).toBeGreaterThan(0);
});
test('area path exists with clip-path attribute', async ({ page }) => {
mockPoll(page);
await loadPage(page);
const areaPath = page.locator('#rate-chart path.rate-area');
await expect(areaPath).toHaveCount(1);
const clip = await areaPath.getAttribute('clip-path');
expect(clip).toMatch(/url\(#rc-/);
});
// ── 2. Live label ─────────────────────────────────────────────────────────
test('live label shows "req/min (N%)" format after traffic', async ({ page }) => {
const poll = mockPoll(page);
await loadPage(page);
// First poll sets lastTotal = 0; second poll with total=30 → delta=30 → rpm > 0
poll.setTotal(0);
await page.waitForTimeout(1300); // first poll fires
poll.setTotal(30);
await page.waitForFunction(
() => {
const el = document.getElementById('rate-live-val');
return el && el.textContent.includes('req/min') && el.textContent.includes('%');
},
{ timeout: 6000 }
);
const label = await page.locator('#rate-live-val').textContent();
expect(label).toMatch(/\d+\s*req\/min\s*\(\d+(\.\d+)?%\)/);
});
test('live label starts as placeholder before any traffic', async ({ page }) => {
mockPoll(page);
await loadPage(page);
const label = await page.locator('#rate-live-val').textContent();
expect(label).toMatch(/—|0\s*req\/min/);
});
// ── 3. Chart path updates ─────────────────────────────────────────────────
test('area path d attribute fills in after positive delta', async ({ page }) => {
const poll = mockPoll(page);
await loadPage(page);
poll.setTotal(0);
await page.waitForTimeout(1300); // first poll → lastTotal = 0
poll.setTotal(10);
await page.waitForFunction(
() => {
const p = document.querySelector('#rate-chart path.rate-area');
const d = p && p.getAttribute('d');
return d && d.length > 10;
},
{ timeout: 5000 }
);
const d = await page.locator('#rate-chart path.rate-area').getAttribute('d');
expect(d).toBeTruthy();
expect(d.length).toBeGreaterThan(10);
});
test('poll fires at ~1s intervals when traffic is active', async ({ page }) => {
const poll = mockPoll(page);
await loadPage(page);
poll.setTotal(5);
await page.waitForTimeout(1300);
poll.setTotal(10);
await page.waitForTimeout(3500); // ~3 more 1s intervals
// At 1s cadence, 4.8s total → ≥ 4 calls
expect(poll.getCallCount()).toBeGreaterThanOrEqual(4);
});
// ── 4. Rate decay ─────────────────────────────────────────────────────────
test('live rpm decays to 0 after CHART_TICK (5s) with no new requests', async ({ page }) => {
const poll = mockPoll(page);
await loadPage(page);
// Prime: two polls with delta=10 each
poll.setTotal(10);
await page.waitForTimeout(1300);
poll.setTotal(20);
await page.waitForTimeout(1300);
// Confirm non-zero before cutting traffic
const before = await page.locator('#rate-live-val').textContent();
expect(before).not.toMatch(/^0\s*req/);
// Total stays at 20 → all subsequent deltas = 0
// After CHART_TICK (5000ms) the sliding window expires
await page.waitForFunction(
() => {
const el = document.getElementById('rate-live-val');
return el && el.textContent.startsWith('0');
},
{ timeout: 9000 }
);
const after = await page.locator('#rate-live-val').textContent();
expect(after).toMatch(/^0\s*req\/min/);
});
// ── 5. Overflow clamping ──────────────────────────────────────────────────
test('area path stays within chart bounds under burst traffic', async ({ page }) => {
const poll = mockPoll(page);
await loadPage(page);
poll.setTotal(0);
await page.waitForTimeout(1300);
poll.setTotal(RATE_LIMIT * 10); // 600 req — 10× rate limit burst
await page.waitForTimeout(2000);
const inBounds = await page.evaluate(() => {
const p = document.querySelector('#rate-chart path.rate-area');
if (!p) return true; // path not drawn yet — not a clipping failure
const svgEl = document.getElementById('rate-chart');
const chartH = svgEl ? Number(svgEl.getAttribute('height')) : 96;
const bbox = p.getBBox();
return bbox.y >= 0 && bbox.y + bbox.height <= chartH;
});
expect(inBounds).toBe(true);
});
// ── 6. Header elements ────────────────────────────────────────────────────
test('sandbox name and tier badge visible in header', async ({ page }) => {
mockPoll(page);
await loadPage(page);
await expect(page.locator('.sb-name')).toContainText('chart-demo');
await expect(page.locator('.tier-badge')).toContainText('FREE');
});
test('expires stat shows days-left with green color when > 30d out', async ({ page }) => {
mockPoll(page);
await loadPage(page);
const expiresVal = page.locator('#expires-val');
await expect(expiresVal).toBeVisible();
const text = await expiresVal.textContent();
expect(text).toMatch(/\d+d left/);
const color = await expiresVal.evaluate(el =>
window.getComputedStyle(el).color
);
expect(color).toBe('rgb(63, 185, 80)'); // #3fb950
});
});
@@ -0,0 +1,10 @@
package org.botstandards.apix.common;
import java.util.List;
public record AdminSandboxListResponse(
long total,
int page,
int size,
List<AdminSandboxSummary> sandboxes
) {}
@@ -0,0 +1,21 @@
package org.botstandards.apix.common;
import com.fasterxml.jackson.annotation.JsonInclude;
import java.time.Instant;
/** Per-sandbox row in the admin list and stats endpoints. */
public record AdminSandboxSummary(
String sandboxId,
String name,
String tier,
int ratePerMinute,
@JsonInclude(JsonInclude.Include.ALWAYS) Integer maxServices,
Instant createdAt,
Instant expiresAt,
boolean expired,
@JsonInclude(JsonInclude.Include.NON_NULL) String location,
@JsonInclude(JsonInclude.Include.NON_NULL) Double lat,
@JsonInclude(JsonInclude.Include.NON_NULL) Double lon,
long serviceCount,
long totalRequests
) {}
@@ -0,0 +1,17 @@
package org.botstandards.apix.common;
import java.util.List;
import java.util.Map;
public record AdminStatsResponse(
long totalSandboxes,
long activeSandboxes,
long expiredSandboxes,
Map<String, Long> byTier,
long totalServices,
long totalRequests,
/** Geo-points for the world map: one point per sandbox that provided a location. */
List<GeoPoint> registrarPoints
) {
public record GeoPoint(double lat, double lon, String name, String tier) {}
}
@@ -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
) {}
@@ -14,6 +14,6 @@ import org.eclipse.microprofile.rest.client.inject.RegisterRestClient;
public interface RegistryClient {
@GET
@Path("/{uuid}")
@Path("/{uuid}/dashboard")
SandboxDashboardResponse getDashboard(@PathParam("uuid") String uuid);
}
@@ -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>
@@ -453,6 +453,13 @@ public class IotTransitionSteps {
lastResponse = given().get("/services/" + currentServiceId + "/history");
}
// ── Then — HTTP status assertion ──────────────────────────────────────────
@Then("the response is HTTP {int}")
public void theResponseIsHttp(int status) {
lastResponse.then().statusCode(status);
}
// ── Then — lifecycle state assertions ────────────────────────────────────
@Then("the service stage is {string}")
@@ -511,13 +518,6 @@ public class IotTransitionSteps {
.body("find { it.type == 'LOCK_RELEASED' }.previousValue", equalTo("true"));
}
// ── Then — HTTP response assertions ──────────────────────────────────────
@Then("the response is HTTP {int}")
public void theResponseIsHttp(int statusCode) {
lastResponse.then().statusCode(statusCode);
}
@Then("the error message contains {string}")
public void theErrorMessageContains(String message) {
lastResponse.then().body("message", containsString(message));
@@ -861,8 +861,9 @@ public class IotTransitionSteps {
@When("^GET /services/\\{smartHubCloudId\\}/replacements\\?iotReady=true is called with no (?:authentication|Authorization) header$")
public void getReplacementsIotReady() {
actionPhase = true;
lastResponse = given().get(
"/services/" + serviceIds.get("SmartHub Cloud") + "/replacements?iotReady=true");
lastResponse = given()
.queryParam("iotReady", "true")
.get("/services/" + serviceIds.get("SmartHub Cloud") + "/replacements");
}
@When("^GET /services/\\{smartHubCloudId\\}/replacements\\?deviceClass=([^ ]+) is called with no (?:authentication|Authorization) header$")
@@ -33,7 +33,9 @@ public class SandboxSteps {
/** Name of the most recently created sandbox. */
private String currentSandboxName;
/** Keys indexed by sandbox name for multi-sandbox scenarios. */
private final Map<String, String> sandboxKeys = new HashMap<>();
private final Map<String, String> sandboxKeys = new HashMap<>();
/** UUIDs indexed by sandbox name — used for resource routing (name is display-only). */
private final Map<String, String> sandboxUuids = new HashMap<>();
// ── Given — sandbox creation ──────────────────────────────────────────────
@@ -45,6 +47,7 @@ public class SandboxSteps {
@Given("a production service {string} with endpoint {string} is registered")
public void aProductionServiceIsRegistered(String serviceName, String endpoint) {
Map<String, Object> payload = buildServicePayload(serviceName, endpoint, "device.telemetry");
payload.put("serviceStage", "PRODUCTION");
given()
.contentType(JSON)
.header(ADMIN_API_KEY_HEADER, ADMIN_API_KEY)
@@ -57,22 +60,25 @@ public class SandboxSteps {
@Given("a sandbox service with endpoint {string} and capability {string} is registered in {string}")
public void aSandboxServiceIsRegistered(String endpoint, String capability, String sandboxName) {
String key = resolveKey(sandboxName);
String key = resolveKey(sandboxName);
String uuid = resolveUuid(sandboxName);
Map<String, Object> payload = buildServicePayload("SandboxService-" + endpoint.hashCode(), endpoint, capability);
given()
.contentType(JSON)
.header(SANDBOX_API_KEY_HEADER, key)
.body(payload)
.when()
.post("/sandbox/" + sandboxName + "/services")
.post("/sandbox/" + uuid + "/services")
.then()
.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) {
String key = resolveKey(sandboxName);
String key = resolveKey(sandboxName);
String uuid = resolveUuid(sandboxName);
int colon = extension.indexOf(':');
Map<String, Object> extensions = colon > 0
? Map.of(extension.substring(0, colon), extension.substring(colon + 1))
@@ -84,19 +90,19 @@ public class SandboxSteps {
.header(SANDBOX_API_KEY_HEADER, key)
.body(payload)
.when()
.post("/sandbox/" + sandboxName + "/services")
.post("/sandbox/" + uuid + "/services")
.then()
.statusCode(201);
}
@Given("the sandbox root for {string} has been viewed once")
public void sandboxRootHasBeenViewedOnce(String sandboxName) {
given().get("/sandbox/" + sandboxName).then().statusCode(200);
given().get("/sandbox/" + resolveUuid(sandboxName)).then().statusCode(200);
}
@Given("the sandbox service list for {string} has been requested once")
public void sandboxServiceListHasBeenRequestedOnce(String sandboxName) {
given().get("/sandbox/" + sandboxName + "/services").then().statusCode(200);
given().get("/sandbox/" + resolveUuid(sandboxName) + "/services").then().statusCode(200);
}
@Given("feedback has been submitted to {string} with scores {word}={int} {word}={int}")
@@ -111,7 +117,8 @@ public class SandboxSteps {
@Given("{int} services have been registered in sandbox {string}")
public void nServicesRegisteredInSandbox(int count, String sandboxName) {
String key = resolveKey(sandboxName);
String key = resolveKey(sandboxName);
String uuid = resolveUuid(sandboxName);
for (int i = 0; i < count; i++) {
Map<String, Object> payload = buildServicePayload(
"CapService-" + i, "https://cap-" + i + ".example.com", "cap.test");
@@ -120,7 +127,7 @@ public class SandboxSteps {
.header(SANDBOX_API_KEY_HEADER, key)
.body(payload)
.when()
.post("/sandbox/" + sandboxName + "/services")
.post("/sandbox/" + uuid + "/services")
.then()
.statusCode(201);
}
@@ -141,6 +148,7 @@ public class SandboxSteps {
currentSandboxKey = lastResponse.jsonPath().getString("apiKey");
currentSandboxName = name;
sandboxKeys.put(name, currentSandboxKey);
sandboxUuids.put(name, lastResponse.jsonPath().getString("sandboxId"));
}
}
@@ -172,19 +180,29 @@ public class SandboxSteps {
@When("the sandbox root for {string} is requested")
public void sandboxRootRequested(String sandboxName) {
lastResponse = given().get("/sandbox/" + sandboxName).andReturn();
lastResponse = given().get("/sandbox/" + resolveUuid(sandboxName)).andReturn();
}
@When("the sandbox root for UUID {string} is requested")
public void sandboxRootRequestedByUuid(String uuid) {
lastResponse = given().get("/sandbox/" + uuid).andReturn();
}
// ── When — service operations ─────────────────────────────────────────────
@When("GET /services is called without authentication")
@When("GET \\/services is called without authentication")
public void getServicesWithoutAuth() {
lastResponse = given().get("/services").andReturn();
}
@When("GET \\/services is called with capability {string} without authentication")
public void getServicesWithCapabilityWithoutAuth(String capability) {
lastResponse = given().get("/services?capability=" + capability).andReturn();
}
@When("the sandbox service list for {string} is requested")
public void sandboxServiceListRequested(String sandboxName) {
lastResponse = given().get("/sandbox/" + sandboxName + "/services").andReturn();
lastResponse = given().get("/sandbox/" + resolveUuid(sandboxName) + "/services").andReturn();
}
@When("a service is registered in sandbox {string} without an API key")
@@ -194,7 +212,7 @@ public class SandboxSteps {
.contentType(JSON)
.body(payload)
.when()
.post("/sandbox/" + sandboxName + "/services")
.post("/sandbox/" + resolveUuid(sandboxName) + "/services")
.andReturn();
}
@@ -206,7 +224,7 @@ public class SandboxSteps {
.header(SANDBOX_API_KEY_HEADER, key)
.body(payload)
.when()
.post("/sandbox/" + sandboxName + "/services")
.post("/sandbox/" + resolveUuid(sandboxName) + "/services")
.andReturn();
}
@@ -219,21 +237,60 @@ public class SandboxSteps {
.header(SANDBOX_API_KEY_HEADER, key)
.body(payload)
.when()
.post("/sandbox/" + sandboxName + "/services")
.post("/sandbox/" + resolveUuid(sandboxName) + "/services")
.andReturn();
}
@When("sandbox {string} services are searched by capability {string}")
public void sandboxServiceSearchCalled(String sandboxName, String capability) {
lastResponse = given()
.get("/sandbox/" + sandboxName + "/services?capability=" + capability)
.get("/sandbox/" + resolveUuid(sandboxName) + "/services?capability=" + capability)
.andReturn();
}
@When("sandbox {string} services are searched by capability {string} and property {string}")
public void sandboxServiceSearchCalledWithProperty(String sandboxName, String capability, String property) {
lastResponse = given()
.get("/sandbox/" + sandboxName + "/services?capability=" + capability + "&property=" + property)
.get("/sandbox/" + resolveUuid(sandboxName) + "/services?capability=" + capability + "&property=" + property)
.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);
lastResponse = given()
.header(SANDBOX_API_KEY_HEADER, key)
.get("/services?capability=" + capability)
.andReturn();
}
@When("GET \\/services is called with capability {string} and API key {string}")
public void getServicesWithCapabilityAndLiteralKey(String capability, String apiKey) {
lastResponse = given()
.header(SANDBOX_API_KEY_HEADER, apiKey)
.get("/services?capability=" + capability)
.andReturn();
}
@@ -241,17 +298,18 @@ public class SandboxSteps {
@When("the telemetry for {string} is requested with the sandbox API key")
public void telemetryRequestedWithKey(String sandboxName) {
String key = resolveKey(sandboxName);
String key = resolveKey(sandboxName);
String uuid = resolveUuid(sandboxName);
lastResponse = given()
.header(SANDBOX_API_KEY_HEADER, key)
.when()
.get("/sandbox/" + sandboxName + "/telemetry")
.get("/sandbox/" + uuid + "/telemetry")
.andReturn();
}
@When("the telemetry for {string} is requested without an API key")
public void telemetryRequestedWithoutKey(String sandboxName) {
lastResponse = given().get("/sandbox/" + sandboxName + "/telemetry").andReturn();
lastResponse = given().get("/sandbox/" + resolveUuid(sandboxName) + "/telemetry").andReturn();
}
@When("the telemetry for {string} is requested with API key {string}")
@@ -259,13 +317,13 @@ public class SandboxSteps {
lastResponse = given()
.header(SANDBOX_API_KEY_HEADER, key)
.when()
.get("/sandbox/" + sandboxName + "/telemetry")
.get("/sandbox/" + resolveUuid(sandboxName) + "/telemetry")
.andReturn();
}
// ── When — feedback ───────────────────────────────────────────────────────
@When("GET /sandbox/feedback-schema is called without authentication")
@When("GET \\/sandbox\\/feedback-schema is called without authentication")
public void getFeedbackSchema() {
lastResponse = given().get("/sandbox/feedback-schema").andReturn();
}
@@ -291,23 +349,24 @@ public class SandboxSteps {
.contentType(JSON)
.body(Map.of("scores", Map.of()))
.when()
.post("/sandbox/" + sandboxName + "/feedback")
.post("/sandbox/" + resolveUuid(sandboxName) + "/feedback")
.andReturn();
}
@When("the feedback aggregate for {string} is requested with the sandbox API key")
public void feedbackAggregateWithKey(String sandboxName) {
String key = resolveKey(sandboxName);
String key = resolveKey(sandboxName);
String uuid = resolveUuid(sandboxName);
lastResponse = given()
.header(SANDBOX_API_KEY_HEADER, key)
.when()
.get("/sandbox/" + sandboxName + "/feedback")
.get("/sandbox/" + uuid + "/feedback")
.andReturn();
}
@When("the feedback aggregate for {string} is requested without an API key")
public void feedbackAggregateWithoutKey(String sandboxName) {
lastResponse = given().get("/sandbox/" + sandboxName + "/feedback").andReturn();
lastResponse = given().get("/sandbox/" + resolveUuid(sandboxName) + "/feedback").andReturn();
}
// ── Then — HTTP status ────────────────────────────────────────────────────
@@ -339,6 +398,11 @@ public class SandboxSteps {
assertThat(lastResponse.jsonPath().getString("expiresAt")).isNotBlank();
}
@Then("the response contains _links.self")
public void responseContainsLinksSelf() {
assertThat(lastResponse.jsonPath().getString("_links.self.href")).as("_links.self.href").isNotBlank();
}
@Then("the response contains _links.self ending with {string}")
public void responseContainsLinksSelfEndingWith(String suffix) {
String href = lastResponse.jsonPath().getString("_links.self.href");
@@ -491,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}")
@@ -510,8 +626,10 @@ public class SandboxSteps {
.andReturn();
assertThat(r.statusCode()).as("sandbox creation for '%s' must return 201", name).isEqualTo(201);
String key = r.jsonPath().getString("apiKey");
String key = r.jsonPath().getString("apiKey");
String uuid = r.jsonPath().getString("sandboxId");
sandboxKeys.put(name, key);
sandboxUuids.put(name, uuid);
currentSandboxKey = key;
currentSandboxName = name;
}
@@ -522,6 +640,12 @@ public class SandboxSteps {
return key;
}
private String resolveUuid(String sandboxName) {
String uuid = sandboxUuids.get(sandboxName);
assertThat(uuid).as("sandbox UUID for '%s' must be known — call createSandbox first", sandboxName).isNotNull();
return uuid;
}
private void submitFeedback(String sandboxName, Map<String, Integer> scores, String model, String provider) {
submitFeedbackResponse(sandboxName, scores, model, provider);
}
@@ -536,7 +660,7 @@ public class SandboxSteps {
.contentType(JSON)
.body(body)
.when()
.post("/sandbox/" + sandboxName + "/feedback")
.post("/sandbox/" + resolveUuid(sandboxName) + "/feedback")
.andReturn();
}
@@ -1,4 +1,4 @@
package org.botstandards.apix.registry.bdd.device;
package org.botstandards.apix.registry.devicebdd;
import io.cucumber.core.cli.Main;
import io.quarkus.test.junit.QuarkusTest;
@@ -9,8 +9,9 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* Runs all device navigation BDD scenarios inside the Quarkus test context.
*
* Uses its own glue package so step definitions do not conflict with
* IotTransitionSteps or OrgOnboardingSteps.
* Uses two glue packages: bdd (for TestSetup/truncateTables) and devicebdd
* (for DeviceNavigationSteps). The separation prevents DeviceNavigationSteps
* from being picked up by non-device CucumberITs that only scan --glue bdd.
*/
@QuarkusTest
public class DeviceCucumberIT {
@@ -18,7 +19,7 @@ public class DeviceCucumberIT {
@Test
public void run() {
byte exitCode = Main.run(
"--glue", "org.botstandards.apix.registry.bdd.device",
"--glue", "org.botstandards.apix.registry.devicebdd",
"--plugin", "pretty",
"--plugin", "json:target/cucumber-report-devices.json",
"--plugin", "io.qameta.allure.cucumber7jvm.AllureCucumber7Jvm",
@@ -1,4 +1,4 @@
package org.botstandards.apix.registry.bdd.device;
package org.botstandards.apix.registry.devicebdd;
import io.cucumber.java.en.And;
import io.cucumber.java.en.Given;
@@ -16,7 +16,8 @@ import static org.hamcrest.Matchers.*;
/**
* Self-contained step definitions for the /devices top-level resource.
* Uses its own glue package so it does not conflict with IotTransitionSteps.
* Lives in a sibling package (devicebdd) to avoid recursive inclusion by
* non-device CucumberITs that use --glue bdd only.
* Cucumber creates a fresh instance per scenario instance fields are scenario-scoped.
*/
public class DeviceNavigationSteps {
@@ -97,27 +98,27 @@ public class DeviceNavigationSteps {
// When
@When("GET /devices is called with no query params")
@When("GET \\/devices is called with no query params")
public void getDevicesRoot() {
lastResponse = given().get("/devices");
}
@When("GET / is called")
@When("GET \\/ is called")
public void getRoot() {
lastResponse = given().get("/");
}
@When("GET /devices?capability={string} is called")
@When("^GET /devices\\?capability=(.+) is called$")
public void getDevicesByCapability(String capability) {
lastResponse = given().get("/devices?capability=" + capability);
}
@When("GET /devices?deviceClass={string} is called")
@When("^GET /devices\\?deviceClass=(.+) is called$")
public void getDevicesByDeviceClass(String deviceClass) {
lastResponse = given().get("/devices?deviceClass=" + deviceClass);
}
@When("GET /devices?protocol={string} is called")
@When("^GET /devices\\?protocol=(.+) is called$")
public void getDevicesByProtocol(String protocol) {
lastResponse = given().get("/devices?protocol=" + protocol);
}
@@ -0,0 +1,36 @@
package org.botstandards.apix.registry.devicebdd;
import io.cucumber.java.After;
import io.cucumber.java.Before;
import io.quarkus.arc.Arc;
import io.restassured.RestAssured;
import org.botstandards.apix.registry.service.ClockService;
import java.sql.DriverManager;
import java.time.Instant;
public class TestSetup {
private static final Instant REFERENCE_INSTANT = Instant.parse("2025-01-01T00:00:00Z");
@Before(order = 0)
public void configureRestAssured() {
RestAssured.port = 8181;
RestAssured.enableLoggingOfRequestAndResponseIfValidationFails();
Arc.container().instance(ClockService.class).get().advance(REFERENCE_INSTANT);
}
@Before(order = 1)
public void truncateTables() throws Exception {
try (var conn = DriverManager.getConnection(
"jdbc:postgresql://localhost:5432/apix", "apix", "apix");
var stmt = conn.createStatement()) {
stmt.execute("TRUNCATE TABLE service_replacements, service_versions, services, org_verification_events, organizations, sandbox_feedback, sandbox_usage_stats, sandboxes CASCADE");
}
}
@After
public void resetClock() {
Arc.container().instance(ClockService.class).get().reset();
}
}
@@ -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
@@ -13,7 +13,7 @@ Feature: HATEOAS navigation and root key resolution
Scenario: Root resource with valid sandbox API key includes _links.sandbox
When the root resource is requested with the sandbox API key for "nav-demo"
Then the response code is 200
And the response contains _links.sandbox ending with "/sandbox/nav-demo"
And the response contains _links.sandbox
Scenario: Root resource with unknown API key omits _links.sandbox
When the root resource is requested with API key "apix_sb_unknownkey"
@@ -28,6 +28,6 @@ Feature: HATEOAS navigation and root key resolution
And the response contains _links.submitFeedback
And the response contains _links.feedbackSchema
Scenario: Sandbox root for unknown name returns 404
When the sandbox root for "does-not-exist" is requested
Scenario: Sandbox root for unknown UUID returns 404
When the sandbox root for UUID "00000000-0000-0000-0000-000000000000" is requested
Then the response code is 404
@@ -7,7 +7,7 @@ Feature: Sandbox registration
And the response contains an API key with prefix "apix_sb_"
And the response contains tier "FREE"
And the response contains a non-null expiresAt
And the response contains _links.self ending with "/sandbox/peter-demo"
And the response contains _links.self
And the response contains _links.services
Scenario: Registration fails when name contains uppercase letters
@@ -26,7 +26,7 @@ Feature: Sandbox registration
When an agent registers a sandbox named "valid-name" with email "not-an-email"
Then the response code is 400
Scenario: Duplicate name is rejected with 409
Scenario: Duplicate name is allowed since names are display-only labels
Given a sandbox named "duplicate-test" exists
When an agent registers a sandbox named "duplicate-test" with email "other@example.com"
Then the response code is 409
Then the response code is 201
@@ -6,7 +6,7 @@ Feature: Sandbox service isolation from production
Scenario: Service registered in sandbox does not appear in production list
Given a sandbox service with endpoint "https://sandbox.example.com" and capability "test.cap" is registered in "isolation-test"
When GET /services is called without authentication
When GET /services is called with capability "test.cap" without authentication
Then the response code is 200
And "https://sandbox.example.com" is not in the endpoint list
@@ -43,3 +43,19 @@ Feature: Sandbox service isolation from production
Then the response code is 200
And "https://ext-eu.example.com" is in the endpoint list
And "https://ext-us.example.com" is not in the endpoint list
Scenario: GET /services with sandbox key scopes results to sandbox only
Given a sandbox service with endpoint "https://keyed-sb.example.com" and capability "keyed.cap" is registered in "isolation-test"
When GET /services is called with capability "keyed.cap" and the sandbox key for "isolation-test"
Then the response code is 200
And "https://keyed-sb.example.com" is in the endpoint list
Scenario: GET /services with sandbox key does not return production services
When GET /services is called with capability "device.telemetry" and the sandbox key for "isolation-test"
Then the response code is 200
And "https://prod.example.com" is not in the endpoint list
Scenario: GET /services with invalid sandbox key falls back to production
When GET /services is called with capability "device.telemetry" and API key "apix_sb_000invalid"
Then the response code is 200
And "https://prod.example.com" is in the endpoint list
@@ -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
@@ -61,11 +61,16 @@ public class QueryNormalisationService {
public String canonicalForReplacements(MultivaluedMap<String, String> raw) {
Map<String, String> out = new TreeMap<>();
put(out, "deviceClass", normaliseLower(raw.getFirst("deviceClass")));
put(out, "iotReady", normaliseIotReady(raw.getFirst("iotReady")));
put(out, "minOLevel", normaliseEnum(raw.getFirst("minOLevel")));
put(out, "protocol", normaliseEnum(raw.getFirst("protocol")));
return render(out);
}
String normaliseIotReady(String v) {
return "true".equalsIgnoreCase(v) ? "true" : null;
}
// ── Individual value normalisers — package-private for unit tests ──────────
String normaliseCapability(String v) {
@@ -0,0 +1,114 @@
package org.botstandards.apix.registry.resource;
import jakarta.inject.Inject;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import org.botstandards.apix.common.AdminSandboxListResponse;
import org.botstandards.apix.common.AdminStatsResponse;
import org.botstandards.apix.common.SandboxDashboardResponse;
import org.botstandards.apix.registry.service.SandboxService;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.eclipse.microprofile.openapi.annotations.Operation;
import java.util.Map;
import java.util.UUID;
@Path("/admin")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class AdminResource {
@Inject
SandboxService sandboxService;
@ConfigProperty(name = "apix.api-key", defaultValue = "")
String adminApiKey;
@GET
@Path("/sandboxes")
@Operation(summary = "List all sandboxes (admin)", description = "Requires X-Admin-Key.")
public AdminSandboxListResponse listSandboxes(
@HeaderParam("X-Admin-Key") String adminKey,
@QueryParam("page") @DefaultValue("0") int page,
@QueryParam("size") @DefaultValue("50") int size,
@QueryParam("tier") String tier) {
requireAdmin(adminKey);
if (size > 200) size = 200;
return sandboxService.listAllSandboxes(page, size, tier);
}
@GET
@Path("/stats")
@Operation(summary = "Global registry statistics (admin)", description = "Requires X-Admin-Key.")
public AdminStatsResponse stats(@HeaderParam("X-Admin-Key") String adminKey) {
requireAdmin(adminKey);
return sandboxService.getGlobalStats();
}
@GET
@Path("/sandboxes/{uuid}")
@Operation(summary = "Full dashboard view of any sandbox (admin)", description = "Requires X-Admin-Key.")
public SandboxDashboardResponse sandboxDetail(
@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());
}
return sandboxService.getDashboard(id);
}
@PATCH
@Path("/sandboxes/{uuid}/tier")
@Operation(summary = "Promote sandbox tier (admin)", description = "Requires X-Admin-Key.")
public Response promoteTier(
@HeaderParam("X-Admin-Key") String adminKey,
@PathParam("uuid") String uuidStr,
Map<String, String> body) {
requireAdmin(adminKey);
String newTier = body == null ? null : body.get("tier");
if (newTier == null || newTier.isBlank())
return Response.status(400).entity(Map.of("message", "Body must contain 'tier'")).build();
UUID id;
try { id = UUID.fromString(uuidStr); }
catch (IllegalArgumentException e) {
throw new WebApplicationException(
Response.status(404).entity(Map.of("message", "Sandbox not found")).build());
}
var sb = sandboxService.requireById(id);
sandboxService.promoteTier(sb, newTier.toUpperCase());
return Response.ok(Map.of(
"sandboxId", sb.id.toString(),
"tier", sb.tier,
"ratePerMinute", sb.ratePerMinute,
"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(
Response.status(401).entity(Map.of("message", "Invalid or missing admin key")).build());
}
}
}
@@ -47,7 +47,7 @@ public class IndexResource {
if (apiKey != null && !apiKey.isBlank()) {
SandboxEntity sandbox = sandboxService.findByKey(apiKey);
if (sandbox != null) {
sandboxLink = new LinkRef(baseUrl + "/sandbox/" + sandbox.name);
sandboxLink = new LinkRef(baseUrl + "/sandbox/" + sandbox.id);
}
}
@@ -69,16 +69,31 @@ public class SandboxResource {
@GET
@Path("/{uuid}")
@Operation(
summary = "Sandbox root — HATEOAS navigation and dashboard data",
description = "Returns sandbox metadata, navigation links, usage stats, and agent visit data. " +
summary = "Sandbox root — HATEOAS navigation",
description = "Returns sandbox metadata and navigation links. " +
"No authentication required. The UUID is the permanent resource identifier."
)
public SandboxDashboardResponse index(@PathParam("uuid") String uuidStr) {
public SandboxIndexResponse index(@PathParam("uuid") String uuidStr) {
UUID id = parseUuid(uuidStr);
SandboxDashboardResponse dashboard = sandboxService.getDashboard(id);
var sb = sandboxService.requireById(id);
sandboxService.recordUsage(id.toString(), SandboxService.EVENT_SANDBOX_VIEWED);
meters.counter("apix.sandbox.views", "sandbox", id.toString()).increment();
return dashboard;
String base = baseUrl + "/sandbox/" + id;
return new SandboxIndexResponse(sb.id.toString(), sb.name, sb.tier, sb.ratePerMinute,
sb.expiresAt, sandboxService.sandboxLinks(base, id.toString()));
}
@GET
@Path("/{uuid}/dashboard")
@Operation(
summary = "Full sandbox dashboard — for the portal",
description = "Returns the complete sandbox view: tier caps, usage counters, " +
"recent agent visits with geo-resolved coordinates. " +
"Intended for the portal UI; no authentication required."
)
public SandboxDashboardResponse dashboard(@PathParam("uuid") String uuidStr) {
UUID id = parseUuid(uuidStr);
return sandboxService.getDashboard(id);
}
@POST
@@ -7,16 +7,20 @@ 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;
import org.botstandards.apix.registry.dto.VersionHistoryEntry;
import org.botstandards.apix.registry.entity.SandboxEntity;
import org.botstandards.apix.registry.service.RegistryService;
import org.botstandards.apix.registry.service.SandboxService;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.eclipse.microprofile.openapi.annotations.Operation;
import org.eclipse.microprofile.openapi.annotations.parameters.Parameter;
import org.eclipse.microprofile.openapi.annotations.security.SecurityRequirement;
import jakarta.ws.rs.core.UriInfo;
import java.net.URI;
import java.util.List;
import java.util.Map;
@@ -30,6 +34,9 @@ public class ServiceResource {
@Inject
RegistryService registryService;
@Inject
SandboxService sandboxService;
@Inject
MeterRegistry meters;
@@ -52,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(
@@ -83,6 +111,8 @@ public class ServiceResource {
summary = "Search services by capability and optional extension properties",
description = "Returns services matching the given capability keyword. " +
"Omitting ?stage= defaults to PRODUCTION only — this is the standard agent query. " +
"When a valid sandbox API key is provided via X-Api-Key, results are scoped to that sandbox " +
"and stage defaults to DEVELOPMENT. " +
"Use ?stage=DEVELOPMENT or ?stage=BETA to discover pre-production services. " +
"Capability values are lowercase kebab-case strings defined by the registrant (e.g. nlp, translation, speech-to-text). " +
"The search matches exact capability tokens, not substrings. " +
@@ -90,17 +120,20 @@ public class ServiceResource {
"Multiple ?property= parameters are ANDed together. " +
"Example: ?capability=translation&property=region:eu&property=dataResidency:DE"
)
public List<ServiceResponse> search(
public Response search(
@Parameter(description = "Capability keyword to search for (e.g. nlp, translation, speech-to-text). Required.", example = "nlp")
@QueryParam("capability") String capability,
@Parameter(description = "Lifecycle stage filter. Defaults to PRODUCTION if omitted. Valid values: DEVELOPMENT, BETA, PRODUCTION, DEPRECATED.", example = "PRODUCTION")
@QueryParam("stage") String stage,
@Parameter(description = "Extension property filter in key:value format. Matches against the service's extensions object. Repeatable for AND logic.", example = "region:eu")
@QueryParam("property") List<String> properties) {
@QueryParam("property") List<String> properties,
@HeaderParam("X-Api-Key") String incomingKey) {
if (capability == null || capability.isBlank()) {
throw new BadRequestException("capability query parameter is required");
}
var results = registryService.search(capability, stage, properties).stream()
SandboxEntity sandbox = sandboxService.findByKey(incomingKey);
String sandboxId = sandbox != null ? sandbox.id.toString() : null;
var results = registryService.search(capability, stage, properties, sandboxId).stream()
.map(ServiceResponse::from)
.toList();
@@ -114,7 +147,12 @@ public class ServiceResource {
"capability", tv(capability))
.record(results.size());
return results;
Response.ResponseBuilder rb = Response.ok(results);
// Sandbox responses are scoped to a specific API key — must not be cached by the CDN.
if (sandboxId != null) {
rb.header("Cache-Control", "private, no-store");
}
return rb.build();
}
@PATCH
@@ -143,14 +181,15 @@ public class ServiceResource {
"Optionally filter by minimum O-level, IoT readiness, device class, or protocol."
)
public Response getReplacements(@PathParam("id") UUID id,
@Context UriInfo uriInfo,
@Parameter(description = "Minimum O-level of replacement candidates.", example = "IDENTITY_VERIFIED")
@QueryParam("minOLevel") String minOLevel,
@Parameter(description = "If true, only return services with an IoT profile.")
@QueryParam("iotReady") Boolean iotReady,
@Parameter(description = "Filter by IoT device class (e.g. sensor, actuator, gateway).")
@QueryParam("deviceClass") String deviceClass,
@Parameter(description = "Filter by IoT protocol (e.g. MQTT, AMQP, HTTP).")
@QueryParam("protocol") String protocol) {
String iotReadyStr = uriInfo.getQueryParameters().getFirst("iotReady");
boolean iotReady = "true".equalsIgnoreCase(iotReadyStr);
ReplacementsResponse body = registryService.getReplacements(id, minOLevel, iotReady, deviceClass, protocol);
return Response.ok(body)
.header("Cache-Control", "public, max-age=60")
@@ -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;
@@ -127,18 +130,22 @@ public class RegistryService {
}
@SuppressWarnings("unchecked")
public List<ServiceEntity> search(String capability, String stage, List<String> properties) {
public List<ServiceEntity> search(String capability, String stage, List<String> properties,
String sandboxId) {
ServiceStage targetStage = stage != null
? ServiceStage.valueOf(stage.toUpperCase())
: ServiceStage.PRODUCTION;
: sandboxId != null ? ServiceStage.DEVELOPMENT : ServiceStage.PRODUCTION;
List<String[]> props = parsePropertyFilters(properties);
String scopeClause = sandboxId != null
? "AND s.sandbox_id = :sid"
: "AND s.sandbox_id IS NULL";
StringBuilder sql = new StringBuilder(
"SELECT s.* FROM services s " +
"WHERE s.bsm_payload @> jsonb_build_object('capabilities', jsonb_build_array(:cap)) " +
"AND s.registry_status = 'ACTIVE' " +
"AND s.service_stage = :stage " +
"AND s.sandbox_id IS NULL");
scopeClause);
for (int i = 0; i < props.size(); i++) {
sql.append(" AND s.bsm_payload -> 'extensions' ->> :propKey").append(i)
.append(" = :propValue").append(i);
@@ -147,6 +154,7 @@ public class RegistryService {
Query q = em.createNativeQuery(sql.toString(), ServiceEntity.class)
.setParameter("cap", capability)
.setParameter("stage", targetStage.name());
if (sandboxId != null) q.setParameter("sid", sandboxId);
for (int i = 0; i < props.size(); i++) {
q.setParameter("propKey" + i, props.get(i)[0]);
q.setParameter("propValue" + i, props.get(i)[1]);
@@ -167,7 +175,7 @@ public class RegistryService {
@SuppressWarnings("unchecked")
public ReplacementsResponse getReplacements(UUID deprecatedId, String minOLevelStr,
Boolean iotReady, String deviceClass, String protocol) {
boolean iotReady, String deviceClass, String protocol) {
ServiceEntity deprecated = requireById(deprecatedId);
// Locked services that are not yet DECOMMISSIONED block replacement discovery
@@ -175,36 +183,39 @@ public class RegistryService {
return new ReplacementsResponse(deprecated.locked, deprecated.sunsetAt, List.of());
}
List<ServiceEntity> candidates = em.createNativeQuery(
// INNER JOIN iot_profiles when IoT filters are active — explicit join guarantees
// row existence without relying on Hibernate EAGER proxy null-checks.
boolean needsIotJoin = iotReady
|| (deviceClass != null && !deviceClass.isBlank())
|| (protocol != null && !protocol.isBlank());
StringBuilder sql = new StringBuilder(
"SELECT s.* FROM services s " +
"INNER JOIN service_replacements sr ON sr.replacement_service_id = s.id " +
"WHERE sr.deprecated_service_id = :depId AND s.registry_status = 'ACTIVE'",
ServiceEntity.class)
.setParameter("depId", deprecatedId)
.getResultList();
OLevel minOLevel = minOLevelStr != null ? OLevel.valueOf(minOLevelStr) : null;
Stream<ServiceEntity> stream = candidates.stream()
.filter(c -> minOLevel == null || c.olevel.ordinal() >= minOLevel.ordinal());
if (Boolean.TRUE.equals(iotReady)) {
stream = stream.filter(c -> c.iotProfile != null);
"INNER JOIN service_replacements sr ON sr.replacement_service_id = s.id ");
if (needsIotJoin) {
sql.append("INNER JOIN iot_profiles ip ON ip.service_id = s.id ");
}
sql.append("WHERE sr.deprecated_service_id = :depId AND s.registry_status = 'ACTIVE'");
if (deviceClass != null && !deviceClass.isBlank()) {
stream = stream.filter(c -> c.iotProfile != null
&& c.iotProfile.deviceClasses != null
&& c.iotProfile.deviceClasses.contains(deviceClass));
sql.append(" AND ip.device_classes @> jsonb_build_array(:deviceClass)");
}
if (protocol != null && !protocol.isBlank()) {
stream = stream.filter(c -> c.iotProfile != null
&& c.iotProfile.protocols != null
&& c.iotProfile.protocols.contains(protocol));
sql.append(" AND ip.protocols @> jsonb_build_array(:protocol)");
}
Query q = em.createNativeQuery(sql.toString(), ServiceEntity.class)
.setParameter("depId", deprecatedId);
if (deviceClass != null && !deviceClass.isBlank()) q.setParameter("deviceClass", deviceClass);
if (protocol != null && !protocol.isBlank()) q.setParameter("protocol", protocol);
List<ServiceEntity> candidates = q.getResultList();
OLevel minOLevel = minOLevelStr != null ? OLevel.valueOf(minOLevelStr) : null;
return new ReplacementsResponse(
deprecated.locked,
deprecated.sunsetAt,
stream
candidates.stream()
.filter(c -> minOLevel == null || c.olevel.ordinal() >= minOLevel.ordinal())
.sorted(Comparator.comparingInt((ServiceEntity c) -> c.olevel.ordinal()).reversed())
.map(c -> new ReplacementsResponse.Candidate(
c.id, c.bsmPayload.name(), c.endpointUrl, c.olevel, c.serviceStage,
@@ -245,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;
@@ -377,7 +384,7 @@ public class SandboxService {
em.createNativeQuery(
"INSERT INTO sandbox_feedback " +
"(sandbox_id, scores, agent_identifier, comment, model_identifier, model_provider) " +
"VALUES (:sid, :scores::jsonb, :agent, :comment, :modelId, :modelProvider)")
"VALUES (:sid, CAST(:scores AS jsonb), :agent, :comment, :modelId, :modelProvider)")
.setParameter("sid", sandboxId)
.setParameter("scores", scoresJson)
.setParameter("agent", req.agentIdentifier())
@@ -454,6 +461,91 @@ public class SandboxService {
schemaLinks);
}
@SuppressWarnings("unchecked")
public AdminSandboxListResponse listAllSandboxes(int page, int size, String tierFilter) {
String tierClause = (tierFilter != null && !tierFilter.isBlank()) ? " AND s.tier = :tier" : "";
String countSql = "SELECT COUNT(*) FROM sandboxes s WHERE 1=1" + tierClause;
String listSql =
"SELECT s.id, s.name, s.tier, s.rate_per_minute, s.max_services, " +
"s.created_at, s.expires_at, s.registrar_location, s.registrar_lat, s.registrar_lon, " +
"COALESCE((SELECT COUNT(*) FROM services sv WHERE sv.sandbox_id = s.id::text), 0), " +
"COALESCE((SELECT SUM(request_count) FROM sandbox_usage_stats u WHERE u.sandbox_id = s.id::text), 0) " +
"FROM sandboxes s WHERE 1=1" + tierClause +
" ORDER BY s.created_at DESC LIMIT :lim OFFSET :off";
var countQ = em.createNativeQuery(countSql);
var listQ = em.createNativeQuery(listSql);
if (!tierClause.isBlank()) {
countQ.setParameter("tier", tierFilter.toUpperCase());
listQ.setParameter("tier", tierFilter.toUpperCase());
}
listQ.setParameter("lim", size).setParameter("off", page * size);
long total = ((Number) countQ.getSingleResult()).longValue();
Instant now = Instant.now();
List<AdminSandboxSummary> rows = ((List<Object[]>) listQ.getResultList()).stream()
.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);
}
@SuppressWarnings("unchecked")
public AdminStatsResponse getGlobalStats() {
Instant now = Instant.now();
long total = ((Number) em.createNativeQuery("SELECT COUNT(*) FROM sandboxes").getSingleResult()).longValue();
long active = ((Number) em.createNativeQuery("SELECT COUNT(*) FROM sandboxes WHERE expires_at > now()").getSingleResult()).longValue();
long expired = total - active;
List<Object[]> tierRows = em.createNativeQuery(
"SELECT tier, COUNT(*) FROM sandboxes GROUP BY tier ORDER BY tier")
.getResultList();
Map<String, Long> byTier = new LinkedHashMap<>();
for (Object[] r : tierRows) byTier.put((String) r[0], ((Number) r[1]).longValue());
long totalServices = ((Number) em.createNativeQuery(
"SELECT COUNT(*) FROM services").getSingleResult()).longValue();
long totalRequests = 0;
Object reqResult = em.createNativeQuery(
"SELECT COALESCE(SUM(request_count), 0) FROM sandbox_usage_stats").getSingleResult();
if (reqResult != null) totalRequests = ((Number) reqResult).longValue();
List<Object[]> geoRows = em.createNativeQuery(
"SELECT registrar_lat, registrar_lon, name, tier FROM sandboxes " +
"WHERE registrar_lat IS NOT NULL AND registrar_lon IS NOT NULL")
.getResultList();
List<AdminStatsResponse.GeoPoint> points = geoRows.stream()
.map(r -> new AdminStatsResponse.GeoPoint(
((Number) r[0]).doubleValue(),
((Number) r[1]).doubleValue(),
(String) r[2],
(String) r[3]))
.toList();
return new AdminStatsResponse(total, active, expired, byTier, totalServices, totalRequests, points);
}
public SandboxLinks sandboxLinks(String base) {
return sandboxLinks(base, null);
}
@@ -578,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);
});
+14
View File
@@ -41,6 +41,20 @@ demo.api-index.org {
header -Server
}
admin.api-index.org {
reverse_proxy admin-a:8084 admin-b:8084 {
lb_policy first
health_uri /q/health/live
health_interval 5s
fail_duration 30s
}
header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
header X-Content-Type-Options "nosniff"
header X-Frame-Options "DENY"
header Referrer-Policy "strict-origin"
header -Server
}
git.api-index.org {
reverse_proxy gitea:3001
header Strict-Transport-Security "max-age=31536000; includeSubDomains"
+8
View File
@@ -0,0 +1,8 @@
FROM eclipse-temurin:21-jre-alpine
RUN addgroup -S apix && adduser -S apix -G apix
WORKDIR /app
COPY apix-admin/target/quarkus-app/ quarkus-app/
RUN chown -R apix:apix /app
USER apix
EXPOSE 8084
ENTRYPOINT ["java", "-jar", "quarkus-app/quarkus-run.jar"]
+32
View File
@@ -173,6 +173,38 @@ services:
retries: 3
restart: unless-stopped
# ── Admin UI (a/b) ────────────────────────────────────────────────────────
admin-a:
image: apix-admin:latest
environment:
APIX_REGISTRY_URL: http://registry-a:8180
APIX_API_KEY: ${APIX_API_KEY}
LOG_LEVEL: ${LOG_LEVEL:-INFO}
depends_on:
- registry-a
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8084/q/health/live || exit 1"]
interval: 30s
timeout: 10s
retries: 3
restart: unless-stopped
admin-b:
image: apix-admin:latest
environment:
APIX_REGISTRY_URL: http://registry-b:8180
APIX_API_KEY: ${APIX_API_KEY}
LOG_LEVEL: ${LOG_LEVEL:-INFO}
depends_on:
- registry-b
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8084/q/health/live || exit 1"]
interval: 30s
timeout: 10s
retries: 3
restart: unless-stopped
# ── Edge proxy ────────────────────────────────────────────────────────────
caddy:
+1
View File
@@ -18,6 +18,7 @@
<module>apix-spider</module>
<module>apix-portal</module>
<module>apix-demo</module>
<module>apix-admin</module>
</modules>
<properties>
+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);
});
+5 -2
View File
@@ -59,24 +59,27 @@ docker build -f infra/Dockerfile.registry -t apix-registry:latest . -q
docker build -f infra/Dockerfile.portal -t apix-portal:latest . -q
docker build -f infra/Dockerfile.demo -t apix-demo:latest . -q
docker build -f infra/Dockerfile.spider -t apix-spider:latest . -q
docker build -f infra/Dockerfile.admin -t apix-admin:latest . -q
# ── 4. Rolling restart: a-stack (registry-a → portal-a + demo-a) ─────────────
log "Rolling restart: a-stack..."
$COMPOSE up -d --no-deps --force-recreate registry-a
wait_healthy "registry-a"
$COMPOSE up -d --no-deps --force-recreate portal-a demo-a
$COMPOSE up -d --no-deps --force-recreate portal-a demo-a admin-a
wait_healthy "portal-a"
wait_healthy "demo-a"
wait_healthy "admin-a"
# ── 5. Rolling restart: b-stack ───────────────────────────────────────────────
log "Rolling restart: b-stack..."
$COMPOSE up -d --no-deps --force-recreate registry-b
wait_healthy "registry-b"
$COMPOSE up -d --no-deps --force-recreate portal-b demo-b
$COMPOSE up -d --no-deps --force-recreate portal-b demo-b admin-b
wait_healthy "portal-b"
wait_healthy "demo-b"
wait_healthy "admin-b"
# ── 6. Spider (cron job — restart acceptable) ─────────────────────────────────
log "Restarting spider..."
+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
}
]
}
]
}
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env bash
# promote-sandbox.sh — Promote a sandbox tier + fetch demo ecosystem UUID
# Run on the server: bash promote-sandbox.sh
set -euo pipefail
ENV_FILE="/opt/apix/.env"
REGISTRY="https://api-index.org"
# ── Adjust these ───────────────────────────────────────────────────────────────
SANDBOX_ID="25321050-4707-4e85-bd9f-877ab04e4a55"
NEW_TIER="COMMUNITY"
# ──────────────────────────────────────────────────────────────────────────────
ADMIN_KEY=$(grep -oP '(?<=APIX_API_KEY=).+' "$ENV_FILE" | tr -d '[:space:]')
if [[ -z "$ADMIN_KEY" ]]; then
echo "ERROR: APIX_API_KEY not found in $ENV_FILE" >&2
exit 1
fi
echo "==> Promoting sandbox $SANDBOX_ID to $NEW_TIER ..."
curl -s -X PATCH "$REGISTRY/sandbox/admin/$SANDBOX_ID/tier" \
-H "X-Admin-Key: $ADMIN_KEY" \
-H "Content-Type: application/json" \
-d "{\"tier\":\"$NEW_TIER\"}" | python3 -m json.tool
echo ""
echo "==> Demo ecosystem sandbox UUID ..."
docker exec "$(docker ps -qf name=demo-a)" \
psql -U apix -d apix -t -A \
-c "SELECT value FROM demo_config WHERE key='sandbox.id';"
+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}"
+8 -9
View File
@@ -11,6 +11,12 @@ info() { echo -e "${GREEN}[apix]${NC} $*"; }
warn() { echo -e "${YELLOW}[warn]${NC} $*"; }
die() { echo -e "${RED}[fail]${NC} $*" >&2; exit 1; }
_on_exit() {
echo ""
read -rp "Press Enter to close…" _
}
trap _on_exit EXIT
warn "This will DROP and recreate the local 'apix' database."
read -rp "Continue? [y/N] " confirm
[[ "${confirm,,}" == "y" ]] || { echo "Aborted."; exit 0; }
@@ -58,12 +64,5 @@ for i in $(seq 1 30); do
sleep 1
done
info "Running Liquibase migrations"
mvn -q liquibase:update -pl apix-registry \
-Dliquibase.url="jdbc:postgresql://localhost:${DB_PORT}/${DB_NAME}" \
-Dliquibase.username="$DB_USER" \
-Dliquibase.password="$DB_PASS"
info "Migrations applied"
info "Reset complete — starting dev servers"
"$SCRIPT_DIR/dev.sh"
info "Reset complete — starting dev servers (Quarkus applies migrations at startup)"
exec "$SCRIPT_DIR/dev.sh"
+3
View File
@@ -13,6 +13,9 @@ TARGET="${1:-all}"
GREEN='\033[0;32m'; NC='\033[0m'
info() { echo -e "${GREEN}[apix]${NC} $*"; }
_on_exit() { echo ""; read -rp "Press Enter to close…" _; }
trap _on_exit EXIT
# ── tmux mode ────────────────────────────────────────────────────────────────
if command -v tmux &>/dev/null && tmux has-session -t apix-dev 2>/dev/null; then
_restart_window() {
+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
+6 -20
View File
@@ -116,27 +116,13 @@ for i in $(seq 1 30); do
sleep 1
done
# ── 6. Liquibase migrations ───────────────────────────────────────────────────
# ── 6. Note on migrations ─────────────────────────────────────────────────────
step "Database migrations"
JDBC_URL="jdbc:postgresql://localhost:${DB_PORT}/${DB_NAME}"
if [[ ! -f "$PROJECT_ROOT/pom.xml" ]]; then
warn "No pom.xml found — Maven project not scaffolded yet (WORKLOG Block 1 / C-00)."
warn "Skipping Liquibase migrations. Run this script again after completing Block 1."
else
# apix-registry depends on apix-common and apix-verification; install them
# to the local repository first so Maven can resolve them during the
# liquibase:update goal (no source files yet — this completes in seconds).
info "Installing shared modules to local repository…"
mvn -q install -pl apix-common,apix-verification -DskipTests
info "Running Liquibase migrations on $JDBC_URL"
mvn -q liquibase:update -pl apix-registry \
-Dliquibase.url="$JDBC_URL" \
-Dliquibase.username="$DB_USER" \
-Dliquibase.password="$DB_PASS"
info "Migrations applied"
fi
# Migrations are applied automatically by Quarkus at startup via
# quarkus.liquibase.migrate-at-start=true. Running them separately via the
# Maven Liquibase plugin records a different classpath prefix in DATABASECHANGELOG
# than Quarkus uses, causing a "relation already exists" conflict on next start.
info "Migrations will run automatically when Quarkus starts (migrate-at-start=true)."
# ── Done ──────────────────────────────────────────────────────────────────────
echo ""