@aiassesstech/grillo 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +56 -0
- package/LICENSE +21 -0
- package/README.md +512 -0
- package/SKILL.md +87 -0
- package/dist/api/server.d.ts +68 -0
- package/dist/api/server.d.ts.map +1 -0
- package/dist/api/server.js +596 -0
- package/dist/api/server.js.map +1 -0
- package/dist/audit/audit-log.d.ts +88 -0
- package/dist/audit/audit-log.d.ts.map +1 -0
- package/dist/audit/audit-log.js +195 -0
- package/dist/audit/audit-log.js.map +1 -0
- package/dist/certification/certificate.d.ts +80 -0
- package/dist/certification/certificate.d.ts.map +1 -0
- package/dist/certification/certificate.js +176 -0
- package/dist/certification/certificate.js.map +1 -0
- package/dist/cli/bin.d.ts +8 -0
- package/dist/cli/bin.d.ts.map +1 -0
- package/dist/cli/bin.js +12 -0
- package/dist/cli/bin.js.map +1 -0
- package/dist/cli/config-loader.d.ts +66 -0
- package/dist/cli/config-loader.d.ts.map +1 -0
- package/dist/cli/config-loader.js +243 -0
- package/dist/cli/config-loader.js.map +1 -0
- package/dist/cli/runner.d.ts +27 -0
- package/dist/cli/runner.d.ts.map +1 -0
- package/dist/cli/runner.js +388 -0
- package/dist/cli/runner.js.map +1 -0
- package/dist/commands/grillo-commands.d.ts +50 -0
- package/dist/commands/grillo-commands.d.ts.map +1 -0
- package/dist/commands/grillo-commands.js +752 -0
- package/dist/commands/grillo-commands.js.map +1 -0
- package/dist/commands/inline-commands.d.ts +16 -0
- package/dist/commands/inline-commands.d.ts.map +1 -0
- package/dist/commands/inline-commands.js +277 -0
- package/dist/commands/inline-commands.js.map +1 -0
- package/dist/commands/router.d.ts +56 -0
- package/dist/commands/router.d.ts.map +1 -0
- package/dist/commands/router.js +154 -0
- package/dist/commands/router.js.map +1 -0
- package/dist/config/defaults.d.ts +9 -0
- package/dist/config/defaults.d.ts.map +1 -0
- package/dist/config/defaults.js +78 -0
- package/dist/config/defaults.js.map +1 -0
- package/dist/config/schema.d.ts +573 -0
- package/dist/config/schema.d.ts.map +1 -0
- package/dist/config/schema.js +142 -0
- package/dist/config/schema.js.map +1 -0
- package/dist/dashboard/metrics.d.ts +100 -0
- package/dist/dashboard/metrics.d.ts.map +1 -0
- package/dist/dashboard/metrics.js +282 -0
- package/dist/dashboard/metrics.js.map +1 -0
- package/dist/dashboard/ui.d.ts +19 -0
- package/dist/dashboard/ui.d.ts.map +1 -0
- package/dist/dashboard/ui.js +951 -0
- package/dist/dashboard/ui.js.map +1 -0
- package/dist/discovery/discovery-adapter.d.ts +94 -0
- package/dist/discovery/discovery-adapter.d.ts.map +1 -0
- package/dist/discovery/discovery-adapter.js +114 -0
- package/dist/discovery/discovery-adapter.js.map +1 -0
- package/dist/discovery/discovery-service.d.ts +77 -0
- package/dist/discovery/discovery-service.d.ts.map +1 -0
- package/dist/discovery/discovery-service.js +240 -0
- package/dist/discovery/discovery-service.js.map +1 -0
- package/dist/drift/detector.d.ts +51 -0
- package/dist/drift/detector.d.ts.map +1 -0
- package/dist/drift/detector.js +148 -0
- package/dist/drift/detector.js.map +1 -0
- package/dist/drift/fleet-anomaly.d.ts +28 -0
- package/dist/drift/fleet-anomaly.d.ts.map +1 -0
- package/dist/drift/fleet-anomaly.js +186 -0
- package/dist/drift/fleet-anomaly.js.map +1 -0
- package/dist/events/event-bus.d.ts +209 -0
- package/dist/events/event-bus.d.ts.map +1 -0
- package/dist/events/event-bus.js +184 -0
- package/dist/events/event-bus.js.map +1 -0
- package/dist/frameworks/framework-registry.d.ts +116 -0
- package/dist/frameworks/framework-registry.d.ts.map +1 -0
- package/dist/frameworks/framework-registry.js +241 -0
- package/dist/frameworks/framework-registry.js.map +1 -0
- package/dist/index.d.ts +94 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +254 -0
- package/dist/index.js.map +1 -0
- package/dist/monitoring/continuous-monitor.d.ts +61 -0
- package/dist/monitoring/continuous-monitor.d.ts.map +1 -0
- package/dist/monitoring/continuous-monitor.js +191 -0
- package/dist/monitoring/continuous-monitor.js.map +1 -0
- package/dist/notifications/notifier.d.ts +21 -0
- package/dist/notifications/notifier.d.ts.map +1 -0
- package/dist/notifications/notifier.js +119 -0
- package/dist/notifications/notifier.js.map +1 -0
- package/dist/notifications/templates.d.ts +14 -0
- package/dist/notifications/templates.d.ts.map +1 -0
- package/dist/notifications/templates.js +105 -0
- package/dist/notifications/templates.js.map +1 -0
- package/dist/orchestration/orchestrator.d.ts +99 -0
- package/dist/orchestration/orchestrator.d.ts.map +1 -0
- package/dist/orchestration/orchestrator.js +426 -0
- package/dist/orchestration/orchestrator.js.map +1 -0
- package/dist/orchestration/queue.d.ts +17 -0
- package/dist/orchestration/queue.d.ts.map +1 -0
- package/dist/orchestration/queue.js +121 -0
- package/dist/orchestration/queue.js.map +1 -0
- package/dist/orchestration/scheduler.d.ts +26 -0
- package/dist/orchestration/scheduler.d.ts.map +1 -0
- package/dist/orchestration/scheduler.js +110 -0
- package/dist/orchestration/scheduler.js.map +1 -0
- package/dist/registry/agent-registry.d.ts +106 -0
- package/dist/registry/agent-registry.d.ts.map +1 -0
- package/dist/registry/agent-registry.js +349 -0
- package/dist/registry/agent-registry.js.map +1 -0
- package/dist/registry/types.d.ts +158 -0
- package/dist/registry/types.d.ts.map +1 -0
- package/dist/registry/types.js +44 -0
- package/dist/registry/types.js.map +1 -0
- package/dist/reports/compliance-report.d.ts +66 -0
- package/dist/reports/compliance-report.d.ts.map +1 -0
- package/dist/reports/compliance-report.js +208 -0
- package/dist/reports/compliance-report.js.map +1 -0
- package/package.json +67 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Grillo Cricket — REST API Server
|
|
3
|
+
*
|
|
4
|
+
* Lightweight HTTP server exposing Grillo's fleet management API.
|
|
5
|
+
* Uses Node.js built-in http module (zero external dependencies).
|
|
6
|
+
*
|
|
7
|
+
* Endpoints map 1:1 to the spec in SPEC-GRILLO-CRICKET section 10:
|
|
8
|
+
* /api/grillo/agents/* — Agent management
|
|
9
|
+
* /api/grillo/assess/* — Assessment operations
|
|
10
|
+
* /api/grillo/certifications/* — Certification & compliance
|
|
11
|
+
* /api/grillo/drift/* — Drift detection
|
|
12
|
+
*/
|
|
13
|
+
import type { AgentRegistry } from "../registry/agent-registry.js";
|
|
14
|
+
import type { GrilloOrchestrator } from "../orchestration/orchestrator.js";
|
|
15
|
+
import type { DriftDetector } from "../drift/detector.js";
|
|
16
|
+
import type { FleetAnomalyDetector } from "../drift/fleet-anomaly.js";
|
|
17
|
+
import type { AuditLog } from "../audit/audit-log.js";
|
|
18
|
+
import type { ComplianceReportGenerator } from "../reports/compliance-report.js";
|
|
19
|
+
import type { GrilloConfig } from "../config/schema.js";
|
|
20
|
+
import type { DiscoveryService } from "../discovery/discovery-service.js";
|
|
21
|
+
import type { GrilloEventBus } from "../events/event-bus.js";
|
|
22
|
+
import type { ContinuousMonitor } from "../monitoring/continuous-monitor.js";
|
|
23
|
+
import type { DashboardMetrics } from "../dashboard/metrics.js";
|
|
24
|
+
export interface GrilloAPIServerOptions {
|
|
25
|
+
registry: AgentRegistry;
|
|
26
|
+
orchestrator: GrilloOrchestrator;
|
|
27
|
+
driftDetector: DriftDetector;
|
|
28
|
+
fleetAnomalyDetector: FleetAnomalyDetector;
|
|
29
|
+
auditLog: AuditLog;
|
|
30
|
+
reportGenerator: ComplianceReportGenerator;
|
|
31
|
+
config: GrilloConfig;
|
|
32
|
+
/** Phase 4 additions */
|
|
33
|
+
discoveryService?: DiscoveryService;
|
|
34
|
+
eventBus?: GrilloEventBus;
|
|
35
|
+
continuousMonitor?: ContinuousMonitor;
|
|
36
|
+
/** Phase 7: Dashboard */
|
|
37
|
+
dashboardMetrics?: DashboardMetrics;
|
|
38
|
+
port?: number;
|
|
39
|
+
host?: string;
|
|
40
|
+
}
|
|
41
|
+
export declare class GrilloAPIServer {
|
|
42
|
+
private readonly opts;
|
|
43
|
+
private server;
|
|
44
|
+
private readonly startedAt;
|
|
45
|
+
private activeConnections;
|
|
46
|
+
private _shuttingDown;
|
|
47
|
+
/** Phase 7: Cached dashboard HTML (regenerated on config change) */
|
|
48
|
+
private dashboardHTML;
|
|
49
|
+
constructor(opts: GrilloAPIServerOptions);
|
|
50
|
+
/**
|
|
51
|
+
* Start the API server.
|
|
52
|
+
*/
|
|
53
|
+
start(): Promise<void>;
|
|
54
|
+
/**
|
|
55
|
+
* Graceful shutdown: stop accepting new connections,
|
|
56
|
+
* drain active connections, then close.
|
|
57
|
+
*/
|
|
58
|
+
stop(options?: {
|
|
59
|
+
timeoutMs?: number;
|
|
60
|
+
}): Promise<void>;
|
|
61
|
+
/**
|
|
62
|
+
* Whether the server is in shutdown mode.
|
|
63
|
+
*/
|
|
64
|
+
get shuttingDown(): boolean;
|
|
65
|
+
private handleRequest;
|
|
66
|
+
private json;
|
|
67
|
+
}
|
|
68
|
+
//# sourceMappingURL=server.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../src/api/server.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAGH,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,+BAA+B,CAAC;AACnE,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,kCAAkC,CAAC;AAC3E,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAC;AAC1D,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,2BAA2B,CAAC;AACtE,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,uBAAuB,CAAC;AACtD,OAAO,KAAK,EAAE,yBAAyB,EAAE,MAAM,iCAAiC,CAAC;AACjF,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC;AAGxD,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,mCAAmC,CAAC;AAC1E,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,wBAAwB,CAAC;AAC7D,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,qCAAqC,CAAC;AAC7E,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,yBAAyB,CAAC;AAOhE,MAAM,WAAW,sBAAsB;IACrC,QAAQ,EAAE,aAAa,CAAC;IACxB,YAAY,EAAE,kBAAkB,CAAC;IACjC,aAAa,EAAE,aAAa,CAAC;IAC7B,oBAAoB,EAAE,oBAAoB,CAAC;IAC3C,QAAQ,EAAE,QAAQ,CAAC;IACnB,eAAe,EAAE,yBAAyB,CAAC;IAC3C,MAAM,EAAE,YAAY,CAAC;IACrB,wBAAwB;IACxB,gBAAgB,CAAC,EAAE,gBAAgB,CAAC;IACpC,QAAQ,CAAC,EAAE,cAAc,CAAC;IAC1B,iBAAiB,CAAC,EAAE,iBAAiB,CAAC;IACtC,yBAAyB;IACzB,gBAAgB,CAAC,EAAE,gBAAgB,CAAC;IACpC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED,qBAAa,eAAe;IAC1B,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAyB;IAC9C,OAAO,CAAC,MAAM,CAA4B;IAC1C,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAoB;IAC9C,OAAO,CAAC,iBAAiB,CAAuC;IAChE,OAAO,CAAC,aAAa,CAAS;IAC9B,oEAAoE;IACpE,OAAO,CAAC,aAAa,CAAS;gBAElB,IAAI,EAAE,sBAAsB;IAKxC;;OAEG;IACH,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAkBtB;;;OAGG;IACG,IAAI,CAAC,OAAO,CAAC,EAAE;QAAE,SAAS,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,IAAI,CAAC;IAgC3D;;OAEG;IACH,IAAI,YAAY,IAAI,OAAO,CAE1B;YAMa,aAAa;IAmgB3B,OAAO,CAAC,IAAI;CAIb"}
|
|
@@ -0,0 +1,596 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Grillo Cricket — REST API Server
|
|
3
|
+
*
|
|
4
|
+
* Lightweight HTTP server exposing Grillo's fleet management API.
|
|
5
|
+
* Uses Node.js built-in http module (zero external dependencies).
|
|
6
|
+
*
|
|
7
|
+
* Endpoints map 1:1 to the spec in SPEC-GRILLO-CRICKET section 10:
|
|
8
|
+
* /api/grillo/agents/* — Agent management
|
|
9
|
+
* /api/grillo/assess/* — Assessment operations
|
|
10
|
+
* /api/grillo/certifications/* — Certification & compliance
|
|
11
|
+
* /api/grillo/drift/* — Drift detection
|
|
12
|
+
*/
|
|
13
|
+
import * as http from "node:http";
|
|
14
|
+
import { AgentCategory, RiskTier } from "../registry/types.js";
|
|
15
|
+
import { generateDashboardHTML } from "../dashboard/ui.js";
|
|
16
|
+
export class GrilloAPIServer {
|
|
17
|
+
opts;
|
|
18
|
+
server = null;
|
|
19
|
+
startedAt = new Date();
|
|
20
|
+
activeConnections = new Set();
|
|
21
|
+
_shuttingDown = false;
|
|
22
|
+
/** Phase 7: Cached dashboard HTML (regenerated on config change) */
|
|
23
|
+
dashboardHTML;
|
|
24
|
+
constructor(opts) {
|
|
25
|
+
this.opts = opts;
|
|
26
|
+
this.dashboardHTML = generateDashboardHTML(opts.config);
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Start the API server.
|
|
30
|
+
*/
|
|
31
|
+
start() {
|
|
32
|
+
const port = this.opts.port ?? 18800;
|
|
33
|
+
const host = this.opts.host ?? "127.0.0.1";
|
|
34
|
+
return new Promise((resolve) => {
|
|
35
|
+
this.server = http.createServer((req, res) => {
|
|
36
|
+
// Track active connections for graceful shutdown
|
|
37
|
+
this.activeConnections.add(res);
|
|
38
|
+
res.on("close", () => this.activeConnections.delete(res));
|
|
39
|
+
this.handleRequest(req, res);
|
|
40
|
+
});
|
|
41
|
+
this.server.listen(port, host, () => {
|
|
42
|
+
console.log(`[grillo:api] Listening on http://${host}:${port}`);
|
|
43
|
+
resolve();
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Graceful shutdown: stop accepting new connections,
|
|
49
|
+
* drain active connections, then close.
|
|
50
|
+
*/
|
|
51
|
+
async stop(options) {
|
|
52
|
+
if (this._shuttingDown)
|
|
53
|
+
return;
|
|
54
|
+
this._shuttingDown = true;
|
|
55
|
+
const timeoutMs = options?.timeoutMs ?? 10000;
|
|
56
|
+
console.log(`[grillo:api] Shutting down gracefully (${timeoutMs}ms timeout, ` +
|
|
57
|
+
`${this.activeConnections.size} active connections)...`);
|
|
58
|
+
return new Promise((resolve) => {
|
|
59
|
+
// Force-resolve after timeout
|
|
60
|
+
const forceTimer = setTimeout(() => {
|
|
61
|
+
console.log("[grillo:api] Shutdown timeout reached, forcing close.");
|
|
62
|
+
this.server?.close();
|
|
63
|
+
resolve();
|
|
64
|
+
}, timeoutMs);
|
|
65
|
+
if (this.server) {
|
|
66
|
+
// Stop accepting new connections
|
|
67
|
+
this.server.close(() => {
|
|
68
|
+
clearTimeout(forceTimer);
|
|
69
|
+
console.log("[grillo:api] All connections drained. Shutdown complete.");
|
|
70
|
+
resolve();
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
clearTimeout(forceTimer);
|
|
75
|
+
resolve();
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Whether the server is in shutdown mode.
|
|
81
|
+
*/
|
|
82
|
+
get shuttingDown() {
|
|
83
|
+
return this._shuttingDown;
|
|
84
|
+
}
|
|
85
|
+
// ============================================================
|
|
86
|
+
// Request Router
|
|
87
|
+
// ============================================================
|
|
88
|
+
async handleRequest(req, res) {
|
|
89
|
+
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
|
90
|
+
const method = req.method ?? "GET";
|
|
91
|
+
const pathname = url.pathname;
|
|
92
|
+
// CORS headers
|
|
93
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
94
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
|
|
95
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
|
96
|
+
if (method === "OPTIONS") {
|
|
97
|
+
res.writeHead(204);
|
|
98
|
+
res.end();
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
try {
|
|
102
|
+
// Reject new requests during shutdown
|
|
103
|
+
if (this._shuttingDown) {
|
|
104
|
+
res.setHeader("Connection", "close");
|
|
105
|
+
return this.json(res, 503, { error: "Server is shutting down" });
|
|
106
|
+
}
|
|
107
|
+
// ---- Phase 7: Dashboard Web UI ----
|
|
108
|
+
if (pathname === "/dashboard" || pathname === "/dashboard/") {
|
|
109
|
+
res.writeHead(200, {
|
|
110
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
111
|
+
"Cache-Control": "no-cache",
|
|
112
|
+
});
|
|
113
|
+
res.end(this.dashboardHTML);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
// Dashboard data API — aggregated metrics
|
|
117
|
+
if (pathname === "/api/grillo/dashboard/overview" && method === "GET") {
|
|
118
|
+
if (!this.opts.dashboardMetrics) {
|
|
119
|
+
return this.json(res, 501, { error: "Dashboard metrics not configured" });
|
|
120
|
+
}
|
|
121
|
+
const overview = this.opts.dashboardMetrics.getFleetOverview();
|
|
122
|
+
return this.json(res, 200, overview);
|
|
123
|
+
}
|
|
124
|
+
if (pathname === "/api/grillo/dashboard/hierarchy" && method === "GET") {
|
|
125
|
+
if (!this.opts.dashboardMetrics) {
|
|
126
|
+
return this.json(res, 501, { error: "Dashboard metrics not configured" });
|
|
127
|
+
}
|
|
128
|
+
const hierarchy = this.opts.dashboardMetrics.getHierarchyOverview();
|
|
129
|
+
return this.json(res, 200, hierarchy);
|
|
130
|
+
}
|
|
131
|
+
if (pathname === "/api/grillo/dashboard/trends" && method === "GET") {
|
|
132
|
+
if (!this.opts.dashboardMetrics) {
|
|
133
|
+
return this.json(res, 501, { error: "Dashboard metrics not configured" });
|
|
134
|
+
}
|
|
135
|
+
const days = Number(url.searchParams.get("days") ?? 30);
|
|
136
|
+
const trends = this.opts.dashboardMetrics.getFleetTrend(days);
|
|
137
|
+
return this.json(res, 200, trends);
|
|
138
|
+
}
|
|
139
|
+
const agentTrendMatch = pathname.match(/^\/api\/grillo\/dashboard\/agent\/([^/]+)$/);
|
|
140
|
+
if (agentTrendMatch && method === "GET") {
|
|
141
|
+
if (!this.opts.dashboardMetrics) {
|
|
142
|
+
return this.json(res, 501, { error: "Dashboard metrics not configured" });
|
|
143
|
+
}
|
|
144
|
+
const agentId = agentTrendMatch[1];
|
|
145
|
+
const trend = this.opts.dashboardMetrics.getAgentTrend(agentId);
|
|
146
|
+
if (!trend)
|
|
147
|
+
return this.json(res, 404, { error: `Agent not found: ${agentId}` });
|
|
148
|
+
return this.json(res, 200, trend);
|
|
149
|
+
}
|
|
150
|
+
// Health check (enhanced in Phase 4)
|
|
151
|
+
if (pathname === "/api/grillo/health") {
|
|
152
|
+
const uptimeMs = Date.now() - this.startedAt.getTime();
|
|
153
|
+
return this.json(res, 200, {
|
|
154
|
+
status: "ok",
|
|
155
|
+
version: "0.1.0",
|
|
156
|
+
uptime: {
|
|
157
|
+
ms: uptimeMs,
|
|
158
|
+
human: formatUptime(uptimeMs),
|
|
159
|
+
},
|
|
160
|
+
services: {
|
|
161
|
+
registry: { healthy: true, agents: this.opts.registry.size },
|
|
162
|
+
auditLog: { healthy: true, entries: this.opts.auditLog.size },
|
|
163
|
+
orchestrator: { healthy: true, running: this.opts.orchestrator.isRunning },
|
|
164
|
+
monitor: {
|
|
165
|
+
healthy: true,
|
|
166
|
+
running: this.opts.continuousMonitor?.running ?? false,
|
|
167
|
+
},
|
|
168
|
+
discovery: {
|
|
169
|
+
healthy: true,
|
|
170
|
+
adapters: this.opts.discoveryService?.listAdapters().length ?? 0,
|
|
171
|
+
},
|
|
172
|
+
eventBus: {
|
|
173
|
+
healthy: true,
|
|
174
|
+
webhooks: this.opts.eventBus?.listWebhooks().length ?? 0,
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
startedAt: this.startedAt.toISOString(),
|
|
178
|
+
activeConnections: this.activeConnections.size,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
// Readiness probe (Kubernetes-style)
|
|
182
|
+
if (pathname === "/api/grillo/ready") {
|
|
183
|
+
return this.json(res, 200, { ready: true });
|
|
184
|
+
}
|
|
185
|
+
// Liveness probe
|
|
186
|
+
if (pathname === "/api/grillo/live") {
|
|
187
|
+
return this.json(res, 200, { alive: true });
|
|
188
|
+
}
|
|
189
|
+
// ---- Discovery (Phase 4) ----
|
|
190
|
+
if (pathname === "/api/grillo/discover" && method === "POST") {
|
|
191
|
+
if (!this.opts.discoveryService) {
|
|
192
|
+
return this.json(res, 501, { error: "Discovery service not configured" });
|
|
193
|
+
}
|
|
194
|
+
const result = await this.opts.discoveryService.discoverAndReconcile();
|
|
195
|
+
this.opts.eventBus?.emit("discovery:scan_completed", {
|
|
196
|
+
source: "api",
|
|
197
|
+
newAgents: result.registered.length,
|
|
198
|
+
existingAgents: result.existing.length,
|
|
199
|
+
orphanedAgents: result.orphaned.length,
|
|
200
|
+
durationMs: result.durationMs,
|
|
201
|
+
});
|
|
202
|
+
return this.json(res, 200, result);
|
|
203
|
+
}
|
|
204
|
+
if (pathname === "/api/grillo/discover/health" && method === "GET") {
|
|
205
|
+
if (!this.opts.discoveryService) {
|
|
206
|
+
return this.json(res, 501, { error: "Discovery service not configured" });
|
|
207
|
+
}
|
|
208
|
+
const health = await this.opts.discoveryService.healthCheck();
|
|
209
|
+
return this.json(res, 200, { adapters: health });
|
|
210
|
+
}
|
|
211
|
+
// ---- Events (Phase 4) ----
|
|
212
|
+
if (pathname === "/api/grillo/events" && method === "GET") {
|
|
213
|
+
if (!this.opts.eventBus) {
|
|
214
|
+
return this.json(res, 501, { error: "Event bus not configured" });
|
|
215
|
+
}
|
|
216
|
+
const limitParam = url.searchParams.get("limit");
|
|
217
|
+
const eventFilter = url.searchParams.get("event");
|
|
218
|
+
const limit = limitParam ? parseInt(limitParam, 10) : 50;
|
|
219
|
+
const history = this.opts.eventBus.history({
|
|
220
|
+
event: eventFilter,
|
|
221
|
+
limit,
|
|
222
|
+
});
|
|
223
|
+
return this.json(res, 200, { events: history, total: history.length });
|
|
224
|
+
}
|
|
225
|
+
if (pathname === "/api/grillo/events/webhooks" && method === "GET") {
|
|
226
|
+
if (!this.opts.eventBus) {
|
|
227
|
+
return this.json(res, 501, { error: "Event bus not configured" });
|
|
228
|
+
}
|
|
229
|
+
return this.json(res, 200, { webhooks: this.opts.eventBus.listWebhooks() });
|
|
230
|
+
}
|
|
231
|
+
if (pathname === "/api/grillo/events/webhooks" && method === "POST") {
|
|
232
|
+
if (!this.opts.eventBus) {
|
|
233
|
+
return this.json(res, 501, { error: "Event bus not configured" });
|
|
234
|
+
}
|
|
235
|
+
const body = await readBody(req);
|
|
236
|
+
const webhookUrl = String(body.url ?? "");
|
|
237
|
+
const events = body.events ?? ["*"];
|
|
238
|
+
if (!webhookUrl) {
|
|
239
|
+
return this.json(res, 400, { error: "Missing url" });
|
|
240
|
+
}
|
|
241
|
+
this.opts.eventBus.registerWebhook(webhookUrl, events);
|
|
242
|
+
return this.json(res, 201, { registered: true, url: webhookUrl, events });
|
|
243
|
+
}
|
|
244
|
+
// ---- Agent Management ----
|
|
245
|
+
if (pathname === "/api/grillo/agents" && method === "GET") {
|
|
246
|
+
return this.json(res, 200, {
|
|
247
|
+
agents: this.opts.registry.all(),
|
|
248
|
+
total: this.opts.registry.size,
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
if (pathname === "/api/grillo/agents/register" && method === "POST") {
|
|
252
|
+
const body = await readBody(req);
|
|
253
|
+
const agent = this.opts.registry.register({
|
|
254
|
+
agentId: String(body.agentId ?? ""),
|
|
255
|
+
agentName: String(body.agentName ?? body.agentId ?? ""),
|
|
256
|
+
agentType: String(body.agentType ?? "agent"),
|
|
257
|
+
category: body.category ?? AgentCategory.INTERNAL_UTILITY,
|
|
258
|
+
provider: String(body.provider ?? ""),
|
|
259
|
+
model: String(body.model ?? ""),
|
|
260
|
+
riskTier: body.riskTier ?? RiskTier.MEDIUM,
|
|
261
|
+
tags: Array.isArray(body.tags) ? body.tags : [],
|
|
262
|
+
metadata: body.metadata ?? {},
|
|
263
|
+
});
|
|
264
|
+
this.opts.auditLog.append({
|
|
265
|
+
action: "agent_registered",
|
|
266
|
+
actor: "api",
|
|
267
|
+
agentId: agent.agentId,
|
|
268
|
+
description: `Agent ${agent.agentName} registered via API`,
|
|
269
|
+
});
|
|
270
|
+
return this.json(res, 201, agent);
|
|
271
|
+
}
|
|
272
|
+
if (pathname === "/api/grillo/agents/discover" && method === "POST") {
|
|
273
|
+
return this.json(res, 200, {
|
|
274
|
+
message: "Discovery scan complete",
|
|
275
|
+
registeredAgents: this.opts.registry.size,
|
|
276
|
+
note: "Full auto-discovery integration coming in Phase 3",
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
// Agent by ID routes
|
|
280
|
+
const agentMatch = pathname.match(/^\/api\/grillo\/agents\/([^/]+)$/);
|
|
281
|
+
if (agentMatch) {
|
|
282
|
+
const agentId = agentMatch[1];
|
|
283
|
+
if (method === "GET") {
|
|
284
|
+
const agent = this.opts.registry.get(agentId);
|
|
285
|
+
if (!agent)
|
|
286
|
+
return this.json(res, 404, { error: `Agent not found: ${agentId}` });
|
|
287
|
+
return this.json(res, 200, agent);
|
|
288
|
+
}
|
|
289
|
+
if (method === "DELETE") {
|
|
290
|
+
const removed = this.opts.registry.deregister(agentId);
|
|
291
|
+
if (!removed)
|
|
292
|
+
return this.json(res, 404, { error: `Agent not found: ${agentId}` });
|
|
293
|
+
this.opts.auditLog.append({
|
|
294
|
+
action: "agent_deregistered",
|
|
295
|
+
actor: "api",
|
|
296
|
+
agentId,
|
|
297
|
+
description: `Agent ${agentId} deregistered via API`,
|
|
298
|
+
});
|
|
299
|
+
return this.json(res, 200, { message: `Agent ${agentId} deregistered` });
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
// Agent category update
|
|
303
|
+
const categoryMatch = pathname.match(/^\/api\/grillo\/agents\/([^/]+)\/category$/);
|
|
304
|
+
if (categoryMatch && method === "PUT") {
|
|
305
|
+
const agentId = categoryMatch[1];
|
|
306
|
+
const agent = this.opts.registry.get(agentId);
|
|
307
|
+
if (!agent)
|
|
308
|
+
return this.json(res, 404, { error: `Agent not found: ${agentId}` });
|
|
309
|
+
const body = await readBody(req);
|
|
310
|
+
// Re-register with new category
|
|
311
|
+
this.opts.registry.register({
|
|
312
|
+
agentId,
|
|
313
|
+
agentName: agent.agentName,
|
|
314
|
+
agentType: agent.agentType,
|
|
315
|
+
category: body.category,
|
|
316
|
+
provider: agent.provider,
|
|
317
|
+
model: agent.model,
|
|
318
|
+
riskTier: body.riskTier ?? agent.riskTier,
|
|
319
|
+
});
|
|
320
|
+
return this.json(res, 200, this.opts.registry.get(agentId));
|
|
321
|
+
}
|
|
322
|
+
// ---- Assessment Operations ----
|
|
323
|
+
const assessMatch = pathname.match(/^\/api\/grillo\/assess\/([^/]+)$/);
|
|
324
|
+
if (assessMatch && method === "POST") {
|
|
325
|
+
const target = assessMatch[1];
|
|
326
|
+
if (target === "fleet") {
|
|
327
|
+
const body = await readBody(req);
|
|
328
|
+
const result = await this.opts.orchestrator.assessFleet({
|
|
329
|
+
dryRun: body.dryRun === true,
|
|
330
|
+
});
|
|
331
|
+
return this.json(res, 200, result);
|
|
332
|
+
}
|
|
333
|
+
const agent = this.opts.registry.get(target);
|
|
334
|
+
if (!agent)
|
|
335
|
+
return this.json(res, 404, { error: `Agent not found: ${target}` });
|
|
336
|
+
const body = await readBody(req);
|
|
337
|
+
const result = await this.opts.orchestrator.assessAgent(agent, {
|
|
338
|
+
framework: body.framework ? String(body.framework) : undefined,
|
|
339
|
+
dryRun: body.dryRun === true,
|
|
340
|
+
});
|
|
341
|
+
return this.json(res, 200, result);
|
|
342
|
+
}
|
|
343
|
+
// ---- Assessment Bypass (Phase 6) ----
|
|
344
|
+
const bypassMatch = pathname.match(/^\/api\/grillo\/assess\/([^/]+)\/bypass$/);
|
|
345
|
+
if (bypassMatch && method === "POST") {
|
|
346
|
+
const agentId = bypassMatch[1];
|
|
347
|
+
const agent = this.opts.registry.get(agentId);
|
|
348
|
+
if (!agent)
|
|
349
|
+
return this.json(res, 404, { error: `Agent not found: ${agentId}` });
|
|
350
|
+
const body = await readBody(req);
|
|
351
|
+
const reason = String(body.reason ?? "");
|
|
352
|
+
const authorizedBy = String(body.authorizedBy ?? "");
|
|
353
|
+
const expiresInDays = Number(body.expiresInDays ?? 30);
|
|
354
|
+
if (!reason || !authorizedBy) {
|
|
355
|
+
return this.json(res, 400, {
|
|
356
|
+
error: "Both 'reason' and 'authorizedBy' are required for bypass authorization",
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
// Grant bypass certification
|
|
360
|
+
const now = new Date();
|
|
361
|
+
const expiresAt = new Date(now.getTime() + expiresInDays * 24 * 60 * 60 * 1000);
|
|
362
|
+
const bypassRecord = {
|
|
363
|
+
runId: `bypass-${Date.now()}-${agentId}`,
|
|
364
|
+
framework: "bypass",
|
|
365
|
+
level: 0,
|
|
366
|
+
scores: {},
|
|
367
|
+
passed: true,
|
|
368
|
+
classification: "BYPASS — Manual Authorization",
|
|
369
|
+
assessedAt: now,
|
|
370
|
+
expiresAt,
|
|
371
|
+
verifyUrl: "",
|
|
372
|
+
metadata: {
|
|
373
|
+
bypass: true,
|
|
374
|
+
reason,
|
|
375
|
+
authorizedBy,
|
|
376
|
+
expiresInDays,
|
|
377
|
+
},
|
|
378
|
+
};
|
|
379
|
+
this.opts.registry.recordAssessment(agentId, bypassRecord);
|
|
380
|
+
// Force status to CERTIFIED (bypass overrides PROBATION gate)
|
|
381
|
+
const { CertificationStatus } = await import("../registry/types.js");
|
|
382
|
+
this.opts.registry.updateStatus(agentId, CertificationStatus.CERTIFIED);
|
|
383
|
+
this.opts.auditLog.append({
|
|
384
|
+
action: "bypass_requested",
|
|
385
|
+
actor: authorizedBy,
|
|
386
|
+
agentId,
|
|
387
|
+
description: `Assessment bypass granted for ${agent.agentName}. Reason: ${reason}. Expires: ${expiresAt.toISOString()}`,
|
|
388
|
+
data: { reason, authorizedBy, expiresInDays, expiresAt: expiresAt.toISOString() },
|
|
389
|
+
});
|
|
390
|
+
this.opts.eventBus?.emit("assessment:bypass_granted", {
|
|
391
|
+
agentId,
|
|
392
|
+
agentName: agent.agentName,
|
|
393
|
+
reason,
|
|
394
|
+
authorizedBy,
|
|
395
|
+
expiresAt: expiresAt.toISOString(),
|
|
396
|
+
});
|
|
397
|
+
return this.json(res, 200, {
|
|
398
|
+
message: `Bypass granted for ${agent.agentName}`,
|
|
399
|
+
agentId,
|
|
400
|
+
status: "certified",
|
|
401
|
+
bypassRecord,
|
|
402
|
+
expiresAt: expiresAt.toISOString(),
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
const assessStatusMatch = pathname.match(/^\/api\/grillo\/assess\/([^/]+)\/status$/);
|
|
406
|
+
if (assessStatusMatch && method === "GET") {
|
|
407
|
+
const agentId = assessStatusMatch[1];
|
|
408
|
+
const agent = this.opts.registry.get(agentId);
|
|
409
|
+
if (!agent)
|
|
410
|
+
return this.json(res, 404, { error: `Agent not found: ${agentId}` });
|
|
411
|
+
return this.json(res, 200, {
|
|
412
|
+
agentId: agent.agentId,
|
|
413
|
+
certificationStatus: agent.certificationStatus,
|
|
414
|
+
lastAssessedAt: agent.lastAssessedAt,
|
|
415
|
+
nextAssessmentDue: agent.nextAssessmentDue,
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
const assessHistoryMatch = pathname.match(/^\/api\/grillo\/assess\/([^/]+)\/history$/);
|
|
419
|
+
if (assessHistoryMatch && method === "GET") {
|
|
420
|
+
const agentId = assessHistoryMatch[1];
|
|
421
|
+
const agent = this.opts.registry.get(agentId);
|
|
422
|
+
if (!agent)
|
|
423
|
+
return this.json(res, 404, { error: `Agent not found: ${agentId}` });
|
|
424
|
+
return this.json(res, 200, {
|
|
425
|
+
agentId: agent.agentId,
|
|
426
|
+
history: agent.certificationHistory,
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
if (pathname === "/api/grillo/assess/queue" && method === "GET") {
|
|
430
|
+
const { buildAssessmentQueue } = await import("../orchestration/queue.js");
|
|
431
|
+
const queue = buildAssessmentQueue(this.opts.registry.all(), this.opts.config);
|
|
432
|
+
return this.json(res, 200, { queue: queue.map((q) => ({
|
|
433
|
+
agentId: q.agent.agentId,
|
|
434
|
+
agentName: q.agent.agentName,
|
|
435
|
+
priority: q.priority,
|
|
436
|
+
reason: q.reason,
|
|
437
|
+
framework: q.framework,
|
|
438
|
+
level: q.level,
|
|
439
|
+
})) });
|
|
440
|
+
}
|
|
441
|
+
// ---- Certifications & Compliance ----
|
|
442
|
+
if (pathname === "/api/grillo/certifications" && method === "GET") {
|
|
443
|
+
return this.json(res, 200, this.opts.registry.summary());
|
|
444
|
+
}
|
|
445
|
+
const certMatch = pathname.match(/^\/api\/grillo\/certifications\/([^/]+)$/);
|
|
446
|
+
if (certMatch && method === "GET") {
|
|
447
|
+
const agentId = certMatch[1];
|
|
448
|
+
const agent = this.opts.registry.get(agentId);
|
|
449
|
+
if (!agent)
|
|
450
|
+
return this.json(res, 404, { error: `Agent not found: ${agentId}` });
|
|
451
|
+
const lastCert = agent.certificationHistory[agent.certificationHistory.length - 1];
|
|
452
|
+
return this.json(res, 200, {
|
|
453
|
+
agentId: agent.agentId,
|
|
454
|
+
status: agent.certificationStatus,
|
|
455
|
+
hierarchicalProgress: agent.hierarchicalProgress,
|
|
456
|
+
latestCertification: lastCert ?? null,
|
|
457
|
+
totalAssessments: agent.certificationHistory.length,
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
const suspendMatch = pathname.match(/^\/api\/grillo\/certifications\/([^/]+)\/suspend$/);
|
|
461
|
+
if (suspendMatch && method === "POST") {
|
|
462
|
+
const agentId = suspendMatch[1];
|
|
463
|
+
try {
|
|
464
|
+
const agent = this.opts.registry.updateStatus(agentId, "suspended");
|
|
465
|
+
this.opts.auditLog.append({
|
|
466
|
+
action: "certification_revoked",
|
|
467
|
+
actor: "api",
|
|
468
|
+
agentId,
|
|
469
|
+
description: `Agent ${agentId} suspended via API`,
|
|
470
|
+
});
|
|
471
|
+
return this.json(res, 200, agent);
|
|
472
|
+
}
|
|
473
|
+
catch {
|
|
474
|
+
return this.json(res, 404, { error: `Agent not found: ${agentId}` });
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
const reinstateMatch = pathname.match(/^\/api\/grillo\/certifications\/([^/]+)\/reinstate$/);
|
|
478
|
+
if (reinstateMatch && method === "POST") {
|
|
479
|
+
const agentId = reinstateMatch[1];
|
|
480
|
+
try {
|
|
481
|
+
const agent = this.opts.registry.updateStatus(agentId, "expired");
|
|
482
|
+
this.opts.auditLog.append({
|
|
483
|
+
action: "agent_reinstated",
|
|
484
|
+
actor: "api",
|
|
485
|
+
agentId,
|
|
486
|
+
description: `Agent ${agentId} reinstated via API`,
|
|
487
|
+
});
|
|
488
|
+
return this.json(res, 200, agent);
|
|
489
|
+
}
|
|
490
|
+
catch {
|
|
491
|
+
return this.json(res, 404, { error: `Agent not found: ${agentId}` });
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
if (pathname === "/api/grillo/compliance/report" && method === "GET") {
|
|
495
|
+
const format = url.searchParams.get("format") ?? "json";
|
|
496
|
+
const period = url.searchParams.get("period") ?? "30d";
|
|
497
|
+
const report = this.opts.reportGenerator.generate({ format, period });
|
|
498
|
+
if (format === "json") {
|
|
499
|
+
return this.json(res, 200, JSON.parse(report));
|
|
500
|
+
}
|
|
501
|
+
res.writeHead(200, { "Content-Type": format === "csv" ? "text/csv" : "text/markdown" });
|
|
502
|
+
res.end(report);
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
// ---- Drift ----
|
|
506
|
+
const driftAgentMatch = pathname.match(/^\/api\/grillo\/drift\/([^/]+)$/);
|
|
507
|
+
if (driftAgentMatch && method === "GET") {
|
|
508
|
+
const target = driftAgentMatch[1];
|
|
509
|
+
if (target === "fleet") {
|
|
510
|
+
const agents = this.opts.registry.all().filter((a) => a.driftBaseline !== null);
|
|
511
|
+
const reports = agents.map((a) => {
|
|
512
|
+
const lastCert = a.certificationHistory[a.certificationHistory.length - 1];
|
|
513
|
+
return this.opts.driftDetector.analyze(a, lastCert?.scores ?? {});
|
|
514
|
+
});
|
|
515
|
+
const anomalies = this.opts.fleetAnomalyDetector.analyze(reports, agents);
|
|
516
|
+
return this.json(res, 200, { reports, anomalies });
|
|
517
|
+
}
|
|
518
|
+
const agent = this.opts.registry.get(target);
|
|
519
|
+
if (!agent)
|
|
520
|
+
return this.json(res, 404, { error: `Agent not found: ${target}` });
|
|
521
|
+
if (!agent.driftBaseline)
|
|
522
|
+
return this.json(res, 200, { message: "No baseline established" });
|
|
523
|
+
const lastCert = agent.certificationHistory[agent.certificationHistory.length - 1];
|
|
524
|
+
const report = this.opts.driftDetector.analyze(agent, lastCert?.scores ?? {});
|
|
525
|
+
return this.json(res, 200, report);
|
|
526
|
+
}
|
|
527
|
+
// ---- Audit ----
|
|
528
|
+
if (pathname === "/api/grillo/audit" && method === "GET") {
|
|
529
|
+
const agentId = url.searchParams.get("agentId");
|
|
530
|
+
const entries = agentId
|
|
531
|
+
? this.opts.auditLog.forAgent(agentId)
|
|
532
|
+
: this.opts.auditLog.recent(100);
|
|
533
|
+
const verification = this.opts.auditLog.verifyChain();
|
|
534
|
+
return this.json(res, 200, { entries, verification });
|
|
535
|
+
}
|
|
536
|
+
// ---- 404 ----
|
|
537
|
+
return this.json(res, 404, {
|
|
538
|
+
error: "Not Found",
|
|
539
|
+
path: pathname,
|
|
540
|
+
method,
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
catch (error) {
|
|
544
|
+
console.error("[grillo:api] Error:", error);
|
|
545
|
+
return this.json(res, 500, {
|
|
546
|
+
error: "Internal Server Error",
|
|
547
|
+
message: error instanceof Error ? error.message : String(error),
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
// ============================================================
|
|
552
|
+
// Helpers
|
|
553
|
+
// ============================================================
|
|
554
|
+
json(res, status, data) {
|
|
555
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
556
|
+
res.end(JSON.stringify(data, dateReplacer, 2));
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
// ================================================================
|
|
560
|
+
// Utilities
|
|
561
|
+
// ================================================================
|
|
562
|
+
function readBody(req) {
|
|
563
|
+
return new Promise((resolve, reject) => {
|
|
564
|
+
const chunks = [];
|
|
565
|
+
req.on("data", (chunk) => chunks.push(chunk));
|
|
566
|
+
req.on("end", () => {
|
|
567
|
+
try {
|
|
568
|
+
const raw = Buffer.concat(chunks).toString("utf-8");
|
|
569
|
+
resolve(raw ? JSON.parse(raw) : {});
|
|
570
|
+
}
|
|
571
|
+
catch {
|
|
572
|
+
resolve({});
|
|
573
|
+
}
|
|
574
|
+
});
|
|
575
|
+
req.on("error", reject);
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
function dateReplacer(_key, value) {
|
|
579
|
+
if (value instanceof Date)
|
|
580
|
+
return value.toISOString();
|
|
581
|
+
return value;
|
|
582
|
+
}
|
|
583
|
+
function formatUptime(ms) {
|
|
584
|
+
const seconds = Math.floor(ms / 1000);
|
|
585
|
+
const minutes = Math.floor(seconds / 60);
|
|
586
|
+
const hours = Math.floor(minutes / 60);
|
|
587
|
+
const days = Math.floor(hours / 24);
|
|
588
|
+
if (days > 0)
|
|
589
|
+
return `${days}d ${hours % 24}h ${minutes % 60}m`;
|
|
590
|
+
if (hours > 0)
|
|
591
|
+
return `${hours}h ${minutes % 60}m ${seconds % 60}s`;
|
|
592
|
+
if (minutes > 0)
|
|
593
|
+
return `${minutes}m ${seconds % 60}s`;
|
|
594
|
+
return `${seconds}s`;
|
|
595
|
+
}
|
|
596
|
+
//# sourceMappingURL=server.js.map
|