diff --git a/apix-registry/src/main/java/org/botstandards/apix/registry/resource/AdminResource.java b/apix-registry/src/main/java/org/botstandards/apix/registry/resource/AdminResource.java index 1cb6fb8..31b2dbf 100644 --- a/apix-registry/src/main/java/org/botstandards/apix/registry/resource/AdminResource.java +++ b/apix-registry/src/main/java/org/botstandards/apix/registry/resource/AdminResource.java @@ -88,6 +88,23 @@ public class AdminResource { "expiresAt", sb.expiresAt.toString())).build(); } + @DELETE + @Path("/sandboxes/{uuid}") + @Operation(summary = "Delete a sandbox and all its services (admin)", description = "Requires X-Admin-Key. DEMO-tier sandboxes are protected.") + public Response deleteSandbox( + @HeaderParam("X-Admin-Key") String adminKey, + @PathParam("uuid") String uuidStr) { + requireAdmin(adminKey); + UUID id; + try { id = UUID.fromString(uuidStr); } + catch (IllegalArgumentException e) { + throw new WebApplicationException( + Response.status(404).entity(Map.of("message", "Sandbox not found")).build()); + } + sandboxService.deleteSandbox(id); + return Response.noContent().build(); + } + private void requireAdmin(String key) { if (adminApiKey.isBlank() || !adminApiKey.equals(key)) { throw new WebApplicationException( diff --git a/apix-registry/src/main/java/org/botstandards/apix/registry/service/SandboxService.java b/apix-registry/src/main/java/org/botstandards/apix/registry/service/SandboxService.java index d2adf42..7545129 100644 --- a/apix-registry/src/main/java/org/botstandards/apix/registry/service/SandboxService.java +++ b/apix-registry/src/main/java/org/botstandards/apix/registry/service/SandboxService.java @@ -237,7 +237,7 @@ public class SandboxService { List properties) { ServiceStage targetStage = stage != null ? ServiceStage.valueOf(stage.toUpperCase()) - : ServiceStage.DEVELOPMENT; + : ServiceStage.PRODUCTION; List props = RegistryService.parsePropertyFilters(properties); StringBuilder sql = new StringBuilder( @@ -659,5 +659,19 @@ 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) {} } diff --git a/demo-node/apix-demo.mjs b/demo-node/apix-demo.mjs index e0373e4..31912e2 100644 --- a/demo-node/apix-demo.mjs +++ b/demo-node/apix-demo.mjs @@ -76,63 +76,65 @@ async function main() { const carriers = [ { - serviceName: 'NordLogistik Economy EU', + 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', - orgType: 'COMMERCIAL', - pricing: { model: 'PER_CALL', amount: 0.012, currency: 'APX', unit: 'per-call' }, + registrantOrgType: 'COMMERCIAL', + pricing: { billingModel: 'PER_CALL', pricePerCall: 0.012, currency: 'APX', billingUnit: 'per-call' }, bsmVersion: '0.1', - stage: 'PRODUCTION', + serviceStage: 'PRODUCTION', }, { - serviceName: 'SwiftCargo Express', + 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', - orgType: 'COMMERCIAL', - pricing: { model: 'PER_CALL', amount: 0.035, currency: 'APX', unit: 'per-call' }, + registrantOrgType: 'COMMERCIAL', + pricing: { billingModel: 'PER_CALL', pricePerCall: 0.035, currency: 'APX', billingUnit: 'per-call' }, bsmVersion: '0.1', - stage: 'PRODUCTION', + serviceStage: 'PRODUCTION', }, { - serviceName: 'PacRim Express', + 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', - orgType: 'COMMERCIAL', - pricing: { model: 'PER_CALL', amount: 0.018, currency: 'APX', unit: 'per-call' }, + registrantOrgType: 'COMMERCIAL', + pricing: { billingModel: 'PER_CALL', pricePerCall: 0.018, currency: 'APX', billingUnit: 'per-call' }, bsmVersion: '0.1', - stage: 'PRODUCTION', + serviceStage: 'PRODUCTION', }, ]; for (const manifest of carriers) { await post(servicesHref, manifest, { 'X-Api-Key': apiKey }); - console.log(` ✓ Registered: ${manifest.serviceName} (${manifest.registrantJurisdiction})`); + 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?"'); - const searchUrl = searchHrefTpl.replace('{?capability,stage}', '') - + '?capability=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 price = svc.pricing ? `${svc.pricing.amount} ${svc.pricing.currency}/${svc.pricing.unit}` : 'unknown'; - console.log(` ┌─ ${svc.serviceName}`); + 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}`); @@ -151,23 +153,23 @@ async function main() { const quoteRequests = found.map(async svc => { const start = Date.now(); try { - const quote = await post(svc.endpoint, shipment).catch(() => mockQuote(svc)); - return { name: svc.serviceName, quote, ms: Date.now() - start }; + const quote = await post(svc.endpoint, shipment).catch(() => mockQuote(svc.name)); + return { svcName: svc.name, quote, ms: Date.now() - start }; } catch { - return { name: svc.serviceName, quote: mockQuote(svc), ms: Date.now() - start }; + 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 { name, quote, ms } of results) { - const best = results[0].name === name ? ' ← best price' : ''; - console.log(` ${name.padEnd(28)} ${quote.price.amount.toFixed(2)} ${quote.price.currency} · ${quote.estimatedDays}d${best}`); + 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.name} (${winner.quote.price.amount.toFixed(2)} ${winner.quote.price.currency})`); + console.log(`\n Selected: ${winner.svcName} (${winner.quote.price.amount.toFixed(2)} ${winner.quote.price.currency})`); // ── Summary ──────────────────────────────────────────────────────────────── hdr('Summary'); @@ -189,7 +191,7 @@ async function main() { } // Fallback mock quotes (in case demo endpoints are not reachable) -function mockQuote(svc) { +function mockQuote(name) { const prices = { 'NordLogistik Economy EU': { amount: 12.40, currency: 'APX' }, 'SwiftCargo Express': { amount: 35.00, currency: 'APX' }, @@ -197,9 +199,9 @@ function mockQuote(svc) { }; const days = { 'NordLogistik Economy EU': 5, 'SwiftCargo Express': 2, 'PacRim Express': 7 }; return { - carrier: svc.serviceName, - price: prices[svc.serviceName] ?? { amount: 20.00, currency: 'APX' }, - estimatedDays: days[svc.serviceName] ?? 5, + carrier: name, + price: prices[name] ?? { amount: 20.00, currency: 'APX' }, + estimatedDays: days[name] ?? 5, trackingAvailable: true, }; } diff --git a/scripts/cleanup-sandboxes.mjs b/scripts/cleanup-sandboxes.mjs new file mode 100644 index 0000000..2c075b1 --- /dev/null +++ b/scripts/cleanup-sandboxes.mjs @@ -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= 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); +});