@advicenxt/sbp-server 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/benchmarks/bench.ts +272 -0
- package/dist/auth.d.ts +20 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +69 -0
- package/dist/auth.js.map +1 -0
- package/dist/blackboard.d.ts +84 -0
- package/dist/blackboard.d.ts.map +1 -0
- package/dist/blackboard.js +502 -0
- package/dist/blackboard.js.map +1 -0
- package/dist/cli.d.ts +7 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +102 -0
- package/dist/cli.js.map +1 -0
- package/dist/conditions.d.ts +27 -0
- package/dist/conditions.d.ts.map +1 -0
- package/dist/conditions.js +240 -0
- package/dist/conditions.js.map +1 -0
- package/dist/decay.d.ts +21 -0
- package/dist/decay.d.ts.map +1 -0
- package/dist/decay.js +88 -0
- package/dist/decay.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +13 -0
- package/dist/index.js.map +1 -0
- package/dist/rate-limiter.d.ts +21 -0
- package/dist/rate-limiter.d.ts.map +1 -0
- package/dist/rate-limiter.js +75 -0
- package/dist/rate-limiter.js.map +1 -0
- package/dist/server.d.ts +63 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +401 -0
- package/dist/server.js.map +1 -0
- package/dist/store.d.ts +54 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +55 -0
- package/dist/store.js.map +1 -0
- package/dist/types.d.ts +247 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +26 -0
- package/dist/types.js.map +1 -0
- package/dist/validation.d.ts +296 -0
- package/dist/validation.d.ts.map +1 -0
- package/dist/validation.js +205 -0
- package/dist/validation.js.map +1 -0
- package/eslint.config.js +26 -0
- package/package.json +66 -0
- package/src/auth.ts +89 -0
- package/src/blackboard.test.ts +287 -0
- package/src/blackboard.ts +651 -0
- package/src/cli.ts +116 -0
- package/src/conditions.ts +305 -0
- package/src/conformance.test.ts +686 -0
- package/src/decay.ts +103 -0
- package/src/index.ts +24 -0
- package/src/rate-limiter.ts +104 -0
- package/src/server.integration.test.ts +436 -0
- package/src/server.ts +500 -0
- package/src/store.ts +108 -0
- package/src/types.ts +314 -0
- package/src/validation.ts +251 -0
- package/tsconfig.eslint.json +5 -0
- package/tsconfig.json +20 -0
package/src/decay.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decay computation utilities
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { DecayModel, Pheromone } from "./types.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Compute the current intensity of a pheromone after decay
|
|
9
|
+
*/
|
|
10
|
+
export function computeIntensity(pheromone: Pheromone, now: number): number {
|
|
11
|
+
const elapsed = now - pheromone.last_reinforced_at;
|
|
12
|
+
|
|
13
|
+
if (elapsed <= 0) {
|
|
14
|
+
return pheromone.initial_intensity;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
switch (pheromone.decay_model.type) {
|
|
18
|
+
case "exponential": {
|
|
19
|
+
const halfLife = pheromone.decay_model.half_life_ms;
|
|
20
|
+
return pheromone.initial_intensity * Math.pow(0.5, elapsed / halfLife);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
case "linear": {
|
|
24
|
+
const rate = pheromone.decay_model.rate_per_ms;
|
|
25
|
+
return Math.max(0, pheromone.initial_intensity - rate * elapsed);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
case "step": {
|
|
29
|
+
const steps = pheromone.decay_model.steps;
|
|
30
|
+
// Find the applicable step (steps should be sorted by at_ms)
|
|
31
|
+
for (let i = steps.length - 1; i >= 0; i--) {
|
|
32
|
+
if (elapsed >= steps[i].at_ms) {
|
|
33
|
+
return steps[i].intensity;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return pheromone.initial_intensity;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
case "immortal":
|
|
40
|
+
return pheromone.initial_intensity;
|
|
41
|
+
|
|
42
|
+
default:
|
|
43
|
+
return 0;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Check if a pheromone has evaporated below its floor
|
|
49
|
+
*/
|
|
50
|
+
export function isEvaporated(pheromone: Pheromone, now: number): boolean {
|
|
51
|
+
return computeIntensity(pheromone, now) < pheromone.ttl_floor;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Get the default decay model
|
|
56
|
+
*/
|
|
57
|
+
export function defaultDecay(): DecayModel {
|
|
58
|
+
return { type: "exponential", half_life_ms: 300000 }; // 5 minutes
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Estimate time until pheromone evaporates
|
|
63
|
+
*/
|
|
64
|
+
export function timeToEvaporation(pheromone: Pheromone, now: number): number | null {
|
|
65
|
+
const currentIntensity = computeIntensity(pheromone, now);
|
|
66
|
+
|
|
67
|
+
if (currentIntensity <= pheromone.ttl_floor) {
|
|
68
|
+
return 0; // Already evaporated
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
switch (pheromone.decay_model.type) {
|
|
72
|
+
case "exponential": {
|
|
73
|
+
// Solve: ttl_floor = current * 0.5^(t/halfLife)
|
|
74
|
+
// t = halfLife * log2(current / ttl_floor)
|
|
75
|
+
const halfLife = pheromone.decay_model.half_life_ms;
|
|
76
|
+
const ratio = currentIntensity / pheromone.ttl_floor;
|
|
77
|
+
return halfLife * Math.log2(ratio);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
case "linear": {
|
|
81
|
+
// Solve: ttl_floor = current - rate * t
|
|
82
|
+
// t = (current - ttl_floor) / rate
|
|
83
|
+
const rate = pheromone.decay_model.rate_per_ms;
|
|
84
|
+
if (rate <= 0) return null;
|
|
85
|
+
return (currentIntensity - pheromone.ttl_floor) / rate;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
case "step": {
|
|
89
|
+
// Find next step below threshold
|
|
90
|
+
const steps = pheromone.decay_model.steps;
|
|
91
|
+
const elapsed = now - pheromone.last_reinforced_at;
|
|
92
|
+
for (const step of steps) {
|
|
93
|
+
if (step.at_ms > elapsed && step.intensity < pheromone.ttl_floor) {
|
|
94
|
+
return step.at_ms - elapsed;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return null; // May never evaporate with given steps
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
case "immortal":
|
|
101
|
+
return null; // Never evaporates
|
|
102
|
+
}
|
|
103
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SBP Server - Public API
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export { Blackboard, type BlackboardOptions, type TriggerHandler } from "./blackboard.js";
|
|
6
|
+
export { SbpServer, type ServerOptions } from "./server.js";
|
|
7
|
+
export { type PheromoneStore, MemoryStore, createStore, type StoreType } from "./store.js";
|
|
8
|
+
export * from "./types.js";
|
|
9
|
+
export { computeIntensity, isEvaporated, defaultDecay, timeToEvaporation } from "./decay.js";
|
|
10
|
+
export { evaluateCondition, createSnapshot } from "./conditions.js";
|
|
11
|
+
export { createAuthHook, type AuthOptions } from "./auth.js";
|
|
12
|
+
export { createRateLimitHook, type RateLimitOptions } from "./rate-limiter.js";
|
|
13
|
+
export {
|
|
14
|
+
validateEnvelope,
|
|
15
|
+
validateParams,
|
|
16
|
+
EmitParamsSchema,
|
|
17
|
+
SniffParamsSchema,
|
|
18
|
+
RegisterScentParamsSchema,
|
|
19
|
+
DeregisterScentParamsSchema,
|
|
20
|
+
EvaporateParamsSchema,
|
|
21
|
+
InspectParamsSchema,
|
|
22
|
+
JsonRpcRequestSchema,
|
|
23
|
+
} from "./validation.js";
|
|
24
|
+
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SBP Rate Limiting Middleware
|
|
3
|
+
* Token bucket rate limiter per agent ID
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { FastifyRequest, FastifyReply, HookHandlerDoneFunction } from "fastify";
|
|
7
|
+
|
|
8
|
+
export interface RateLimitOptions {
|
|
9
|
+
/** Maximum requests per window (default: 1000) */
|
|
10
|
+
maxRequests?: number;
|
|
11
|
+
/** Window duration in milliseconds (default: 60000 = 1 minute) */
|
|
12
|
+
windowMs?: number;
|
|
13
|
+
/** Maximum scent registrations per agent (default: 100) */
|
|
14
|
+
maxScentRegistrations?: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface TokenBucket {
|
|
18
|
+
tokens: number;
|
|
19
|
+
lastRefill: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Create a Fastify onRequest hook for rate limiting.
|
|
24
|
+
*
|
|
25
|
+
* Uses a token bucket algorithm per agent ID (from `Sbp-Agent-Id` header).
|
|
26
|
+
* Returns JSON-RPC error code -32004 (RATE_LIMITED) when the limit is exceeded.
|
|
27
|
+
*/
|
|
28
|
+
export function createRateLimitHook(options: RateLimitOptions = {}) {
|
|
29
|
+
const maxRequests = options.maxRequests ?? 1000;
|
|
30
|
+
const windowMs = options.windowMs ?? 60000;
|
|
31
|
+
const buckets = new Map<string, TokenBucket>();
|
|
32
|
+
|
|
33
|
+
// Periodic cleanup of stale buckets (every 5 minutes)
|
|
34
|
+
const cleanupInterval = setInterval(() => {
|
|
35
|
+
const now = Date.now();
|
|
36
|
+
for (const [key, bucket] of buckets.entries()) {
|
|
37
|
+
if (now - bucket.lastRefill > windowMs * 5) {
|
|
38
|
+
buckets.delete(key);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}, 300000);
|
|
42
|
+
|
|
43
|
+
// Allow GC to clean up the interval if the server is stopped
|
|
44
|
+
if (cleanupInterval.unref) {
|
|
45
|
+
cleanupInterval.unref();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return function rateLimitHook(
|
|
49
|
+
request: FastifyRequest,
|
|
50
|
+
reply: FastifyReply,
|
|
51
|
+
done: HookHandlerDoneFunction
|
|
52
|
+
): void {
|
|
53
|
+
// Skip rate limiting for health checks and OPTIONS
|
|
54
|
+
const url = request.url.split("?")[0];
|
|
55
|
+
if (url === "/health" || request.method === "OPTIONS") {
|
|
56
|
+
done();
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Identify the agent
|
|
61
|
+
const agentId = (request.headers["sbp-agent-id"] as string) || request.ip || "anonymous";
|
|
62
|
+
|
|
63
|
+
// Get or create token bucket
|
|
64
|
+
const now = Date.now();
|
|
65
|
+
let bucket = buckets.get(agentId);
|
|
66
|
+
|
|
67
|
+
if (!bucket) {
|
|
68
|
+
bucket = { tokens: maxRequests, lastRefill: now };
|
|
69
|
+
buckets.set(agentId, bucket);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Refill tokens based on elapsed time
|
|
73
|
+
const elapsed = now - bucket.lastRefill;
|
|
74
|
+
const refillRate = maxRequests / windowMs;
|
|
75
|
+
bucket.tokens = Math.min(maxRequests, bucket.tokens + elapsed * refillRate);
|
|
76
|
+
bucket.lastRefill = now;
|
|
77
|
+
|
|
78
|
+
// Check if request is allowed
|
|
79
|
+
if (bucket.tokens < 1) {
|
|
80
|
+
const retryAfterMs = Math.ceil((1 - bucket.tokens) / refillRate);
|
|
81
|
+
reply
|
|
82
|
+
.status(429)
|
|
83
|
+
.header("Retry-After", Math.ceil(retryAfterMs / 1000).toString())
|
|
84
|
+
.send({
|
|
85
|
+
jsonrpc: "2.0",
|
|
86
|
+
id: null,
|
|
87
|
+
error: {
|
|
88
|
+
code: -32004,
|
|
89
|
+
message: "Rate limited: Too many requests",
|
|
90
|
+
data: {
|
|
91
|
+
retry_after_ms: retryAfterMs,
|
|
92
|
+
limit: maxRequests,
|
|
93
|
+
window_ms: windowMs,
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Consume a token
|
|
101
|
+
bucket.tokens -= 1;
|
|
102
|
+
done();
|
|
103
|
+
};
|
|
104
|
+
}
|
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SBP Server Integration Tests
|
|
3
|
+
* Tests HTTP endpoints, validation, auth, and session management
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest";
|
|
7
|
+
import Fastify from "fastify";
|
|
8
|
+
import { SbpServer } from "./server.js";
|
|
9
|
+
|
|
10
|
+
// -- Helpers --
|
|
11
|
+
|
|
12
|
+
function rpc(method: string, params: unknown = {}, id: number | string = 1) {
|
|
13
|
+
return { jsonrpc: "2.0", id, method, params };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// -- Test Suites --
|
|
17
|
+
|
|
18
|
+
describe("SBP Server Integration", () => {
|
|
19
|
+
let server: SbpServer;
|
|
20
|
+
let app: ReturnType<typeof Fastify>;
|
|
21
|
+
|
|
22
|
+
beforeAll(async () => {
|
|
23
|
+
server = new SbpServer({ port: 0, host: "localhost", logging: false });
|
|
24
|
+
// Access internal Fastify app for inject() testing
|
|
25
|
+
app = (server as unknown as { app: ReturnType<typeof Fastify> }).app;
|
|
26
|
+
await app.ready();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
afterAll(async () => {
|
|
30
|
+
await server.stop();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// ========================================================================
|
|
34
|
+
// Health endpoint
|
|
35
|
+
// ========================================================================
|
|
36
|
+
|
|
37
|
+
describe("GET /health", () => {
|
|
38
|
+
it("returns status ok", async () => {
|
|
39
|
+
const res = await app.inject({ method: "GET", url: "/health" });
|
|
40
|
+
expect(res.statusCode).toBe(200);
|
|
41
|
+
const body = res.json();
|
|
42
|
+
expect(body.status).toBe("ok");
|
|
43
|
+
expect(body.version).toBe("0.1.0");
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// ========================================================================
|
|
48
|
+
// JSON-RPC envelope validation
|
|
49
|
+
// ========================================================================
|
|
50
|
+
|
|
51
|
+
describe("JSON-RPC Envelope", () => {
|
|
52
|
+
it("rejects malformed JSON", async () => {
|
|
53
|
+
const res = await app.inject({
|
|
54
|
+
method: "POST",
|
|
55
|
+
url: "/sbp",
|
|
56
|
+
headers: { "Content-Type": "application/json" },
|
|
57
|
+
body: "not json{",
|
|
58
|
+
});
|
|
59
|
+
expect(res.statusCode).toBe(400); // Fastify returns 400 for unparseable body
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("rejects missing jsonrpc field", async () => {
|
|
63
|
+
const res = await app.inject({
|
|
64
|
+
method: "POST",
|
|
65
|
+
url: "/sbp",
|
|
66
|
+
payload: { id: 1, method: "sbp/sniff", params: {} },
|
|
67
|
+
});
|
|
68
|
+
const body = res.json();
|
|
69
|
+
expect(body.error).toBeDefined();
|
|
70
|
+
expect(body.error.code).toBe(-32600);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("rejects unknown method", async () => {
|
|
74
|
+
const res = await app.inject({
|
|
75
|
+
method: "POST",
|
|
76
|
+
url: "/sbp",
|
|
77
|
+
payload: rpc("sbp/unknown"),
|
|
78
|
+
});
|
|
79
|
+
const body = res.json();
|
|
80
|
+
expect(body.error).toBeDefined();
|
|
81
|
+
expect(body.error.code).toBe(-32601);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// ========================================================================
|
|
86
|
+
// sbp/emit
|
|
87
|
+
// ========================================================================
|
|
88
|
+
|
|
89
|
+
describe("sbp/emit", () => {
|
|
90
|
+
it("creates a new pheromone", async () => {
|
|
91
|
+
const res = await app.inject({
|
|
92
|
+
method: "POST",
|
|
93
|
+
url: "/sbp",
|
|
94
|
+
payload: rpc("sbp/emit", {
|
|
95
|
+
trail: "test/integration",
|
|
96
|
+
type: "signal",
|
|
97
|
+
intensity: 0.8,
|
|
98
|
+
}),
|
|
99
|
+
});
|
|
100
|
+
const body = res.json();
|
|
101
|
+
expect(body.result).toBeDefined();
|
|
102
|
+
expect(body.result.action).toBe("created");
|
|
103
|
+
expect(body.result.pheromone_id).toBeTruthy();
|
|
104
|
+
expect(body.result.new_intensity).toBe(0.8);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("validates intensity range", async () => {
|
|
108
|
+
const res = await app.inject({
|
|
109
|
+
method: "POST",
|
|
110
|
+
url: "/sbp",
|
|
111
|
+
payload: rpc("sbp/emit", {
|
|
112
|
+
trail: "test",
|
|
113
|
+
type: "signal",
|
|
114
|
+
intensity: 2.0, // Invalid: > 1
|
|
115
|
+
}),
|
|
116
|
+
});
|
|
117
|
+
const body = res.json();
|
|
118
|
+
expect(body.error).toBeDefined();
|
|
119
|
+
expect(body.error.code).toBe(-32602);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("rejects empty trail", async () => {
|
|
123
|
+
const res = await app.inject({
|
|
124
|
+
method: "POST",
|
|
125
|
+
url: "/sbp",
|
|
126
|
+
payload: rpc("sbp/emit", {
|
|
127
|
+
trail: "",
|
|
128
|
+
type: "signal",
|
|
129
|
+
intensity: 0.5,
|
|
130
|
+
}),
|
|
131
|
+
});
|
|
132
|
+
const body = res.json();
|
|
133
|
+
expect(body.error).toBeDefined();
|
|
134
|
+
expect(body.error.code).toBe(-32602);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("supports merge strategies", async () => {
|
|
138
|
+
// First emit
|
|
139
|
+
await app.inject({
|
|
140
|
+
method: "POST",
|
|
141
|
+
url: "/sbp",
|
|
142
|
+
payload: rpc("sbp/emit", {
|
|
143
|
+
trail: "merge/test",
|
|
144
|
+
type: "alpha",
|
|
145
|
+
intensity: 0.5,
|
|
146
|
+
merge_strategy: "new",
|
|
147
|
+
}),
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// Second emit with replace
|
|
151
|
+
const res = await app.inject({
|
|
152
|
+
method: "POST",
|
|
153
|
+
url: "/sbp",
|
|
154
|
+
payload: rpc("sbp/emit", {
|
|
155
|
+
trail: "merge/test",
|
|
156
|
+
type: "alpha",
|
|
157
|
+
intensity: 0.9,
|
|
158
|
+
merge_strategy: "replace",
|
|
159
|
+
}),
|
|
160
|
+
});
|
|
161
|
+
const body = res.json();
|
|
162
|
+
expect(body.result.action).toBe("replaced");
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// ========================================================================
|
|
167
|
+
// sbp/sniff
|
|
168
|
+
// ========================================================================
|
|
169
|
+
|
|
170
|
+
describe("sbp/sniff", () => {
|
|
171
|
+
beforeEach(async () => {
|
|
172
|
+
// Emit a test pheromone
|
|
173
|
+
await app.inject({
|
|
174
|
+
method: "POST",
|
|
175
|
+
url: "/sbp",
|
|
176
|
+
payload: rpc("sbp/emit", {
|
|
177
|
+
trail: "sniff/test",
|
|
178
|
+
type: "data",
|
|
179
|
+
intensity: 0.7,
|
|
180
|
+
tags: ["important"],
|
|
181
|
+
}),
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("returns pheromones matching trail filter", async () => {
|
|
186
|
+
const res = await app.inject({
|
|
187
|
+
method: "POST",
|
|
188
|
+
url: "/sbp",
|
|
189
|
+
payload: rpc("sbp/sniff", {
|
|
190
|
+
trails: ["sniff/test"],
|
|
191
|
+
}),
|
|
192
|
+
});
|
|
193
|
+
const body = res.json();
|
|
194
|
+
expect(body.result).toBeDefined();
|
|
195
|
+
expect(body.result.pheromones.length).toBeGreaterThan(0);
|
|
196
|
+
expect(body.result.pheromones[0].trail).toBe("sniff/test");
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("filters by min_intensity", async () => {
|
|
200
|
+
const res = await app.inject({
|
|
201
|
+
method: "POST",
|
|
202
|
+
url: "/sbp",
|
|
203
|
+
payload: rpc("sbp/sniff", {
|
|
204
|
+
trails: ["sniff/test"],
|
|
205
|
+
min_intensity: 0.99,
|
|
206
|
+
}),
|
|
207
|
+
});
|
|
208
|
+
const body = res.json();
|
|
209
|
+
expect(body.result.pheromones.length).toBe(0);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("filters by type", async () => {
|
|
213
|
+
const res = await app.inject({
|
|
214
|
+
method: "POST",
|
|
215
|
+
url: "/sbp",
|
|
216
|
+
payload: rpc("sbp/sniff", {
|
|
217
|
+
trails: ["sniff/test"],
|
|
218
|
+
types: ["nonexistent"],
|
|
219
|
+
}),
|
|
220
|
+
});
|
|
221
|
+
const body = res.json();
|
|
222
|
+
expect(body.result.pheromones.length).toBe(0);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("includes aggregates", async () => {
|
|
226
|
+
const res = await app.inject({
|
|
227
|
+
method: "POST",
|
|
228
|
+
url: "/sbp",
|
|
229
|
+
payload: rpc("sbp/sniff", { trails: ["sniff/test"] }),
|
|
230
|
+
});
|
|
231
|
+
const body = res.json();
|
|
232
|
+
expect(body.result.aggregates).toBeDefined();
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
// ========================================================================
|
|
237
|
+
// sbp/register_scent + sbp/deregister_scent
|
|
238
|
+
// ========================================================================
|
|
239
|
+
|
|
240
|
+
describe("sbp/register_scent & sbp/deregister_scent", () => {
|
|
241
|
+
it("registers a scent", async () => {
|
|
242
|
+
const res = await app.inject({
|
|
243
|
+
method: "POST",
|
|
244
|
+
url: "/sbp",
|
|
245
|
+
payload: rpc("sbp/register_scent", {
|
|
246
|
+
scent_id: "test-scent-1",
|
|
247
|
+
condition: {
|
|
248
|
+
type: "threshold",
|
|
249
|
+
trail: "test",
|
|
250
|
+
signal_type: "alert",
|
|
251
|
+
aggregation: "any",
|
|
252
|
+
operator: ">=",
|
|
253
|
+
value: 0.5,
|
|
254
|
+
},
|
|
255
|
+
}),
|
|
256
|
+
});
|
|
257
|
+
const body = res.json();
|
|
258
|
+
expect(body.result.scent_id).toBe("test-scent-1");
|
|
259
|
+
expect(body.result.status).toBe("registered");
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("deregisters a scent", async () => {
|
|
263
|
+
// Register first
|
|
264
|
+
await app.inject({
|
|
265
|
+
method: "POST",
|
|
266
|
+
url: "/sbp",
|
|
267
|
+
payload: rpc("sbp/register_scent", {
|
|
268
|
+
scent_id: "to-deregister",
|
|
269
|
+
condition: {
|
|
270
|
+
type: "threshold",
|
|
271
|
+
trail: "test",
|
|
272
|
+
signal_type: "alert",
|
|
273
|
+
aggregation: "any",
|
|
274
|
+
operator: ">=",
|
|
275
|
+
value: 0.5,
|
|
276
|
+
},
|
|
277
|
+
}),
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
const res = await app.inject({
|
|
281
|
+
method: "POST",
|
|
282
|
+
url: "/sbp",
|
|
283
|
+
payload: rpc("sbp/deregister_scent", { scent_id: "to-deregister" }),
|
|
284
|
+
});
|
|
285
|
+
const body = res.json();
|
|
286
|
+
expect(body.result.status).toBe("deregistered");
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it("returns not_found for unknown scent", async () => {
|
|
290
|
+
const res = await app.inject({
|
|
291
|
+
method: "POST",
|
|
292
|
+
url: "/sbp",
|
|
293
|
+
payload: rpc("sbp/deregister_scent", { scent_id: "doesnt-exist" }),
|
|
294
|
+
});
|
|
295
|
+
const body = res.json();
|
|
296
|
+
expect(body.result.status).toBe("not_found");
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
// ========================================================================
|
|
301
|
+
// sbp/evaporate
|
|
302
|
+
// ========================================================================
|
|
303
|
+
|
|
304
|
+
describe("sbp/evaporate", () => {
|
|
305
|
+
it("evaporates pheromones by trail", async () => {
|
|
306
|
+
// Emit
|
|
307
|
+
await app.inject({
|
|
308
|
+
method: "POST",
|
|
309
|
+
url: "/sbp",
|
|
310
|
+
payload: rpc("sbp/emit", {
|
|
311
|
+
trail: "evap/test",
|
|
312
|
+
type: "temp",
|
|
313
|
+
intensity: 0.5,
|
|
314
|
+
}),
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
// Evaporate
|
|
318
|
+
const res = await app.inject({
|
|
319
|
+
method: "POST",
|
|
320
|
+
url: "/sbp",
|
|
321
|
+
payload: rpc("sbp/evaporate", { trail: "evap/test" }),
|
|
322
|
+
});
|
|
323
|
+
const body = res.json();
|
|
324
|
+
expect(body.result.evaporated_count).toBeGreaterThan(0);
|
|
325
|
+
|
|
326
|
+
// Verify gone
|
|
327
|
+
const sniffRes = await app.inject({
|
|
328
|
+
method: "POST",
|
|
329
|
+
url: "/sbp",
|
|
330
|
+
payload: rpc("sbp/sniff", { trails: ["evap/test"] }),
|
|
331
|
+
});
|
|
332
|
+
expect(sniffRes.json().result.pheromones.length).toBe(0);
|
|
333
|
+
});
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
// ========================================================================
|
|
337
|
+
// sbp/inspect
|
|
338
|
+
// ========================================================================
|
|
339
|
+
|
|
340
|
+
describe("sbp/inspect", () => {
|
|
341
|
+
it("returns trails, scents, and stats", async () => {
|
|
342
|
+
const res = await app.inject({
|
|
343
|
+
method: "POST",
|
|
344
|
+
url: "/sbp",
|
|
345
|
+
payload: rpc("sbp/inspect", {
|
|
346
|
+
include: ["trails", "scents", "stats"],
|
|
347
|
+
}),
|
|
348
|
+
});
|
|
349
|
+
const body = res.json();
|
|
350
|
+
expect(body.result.trails).toBeDefined();
|
|
351
|
+
expect(body.result.scents).toBeDefined();
|
|
352
|
+
expect(body.result.stats).toBeDefined();
|
|
353
|
+
expect(body.result.stats.uptime_ms).toBeGreaterThanOrEqual(0);
|
|
354
|
+
});
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
// ========================================================================
|
|
358
|
+
// Session management
|
|
359
|
+
// ========================================================================
|
|
360
|
+
|
|
361
|
+
describe("Session Management", () => {
|
|
362
|
+
it("assigns session ID when none provided", async () => {
|
|
363
|
+
const res = await app.inject({
|
|
364
|
+
method: "POST",
|
|
365
|
+
url: "/sbp",
|
|
366
|
+
payload: rpc("sbp/sniff"),
|
|
367
|
+
});
|
|
368
|
+
const sessionId = res.headers["sbp-session-id"];
|
|
369
|
+
expect(sessionId).toBeTruthy();
|
|
370
|
+
});
|
|
371
|
+
});
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
// ============================================================================
|
|
375
|
+
// Authentication Tests
|
|
376
|
+
// ============================================================================
|
|
377
|
+
|
|
378
|
+
describe("SBP Server Authentication", () => {
|
|
379
|
+
let server: SbpServer;
|
|
380
|
+
let app: ReturnType<typeof Fastify>;
|
|
381
|
+
|
|
382
|
+
beforeAll(async () => {
|
|
383
|
+
server = new SbpServer({
|
|
384
|
+
port: 0,
|
|
385
|
+
host: "localhost",
|
|
386
|
+
logging: false,
|
|
387
|
+
auth: { apiKeys: ["test-key-123", "test-key-456"], requireAuth: true },
|
|
388
|
+
});
|
|
389
|
+
app = (server as unknown as { app: ReturnType<typeof Fastify> }).app;
|
|
390
|
+
await app.ready();
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
afterAll(async () => {
|
|
394
|
+
await server.stop();
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
it("rejects requests without Authorization header", async () => {
|
|
398
|
+
const res = await app.inject({
|
|
399
|
+
method: "POST",
|
|
400
|
+
url: "/sbp",
|
|
401
|
+
payload: rpc("sbp/sniff"),
|
|
402
|
+
});
|
|
403
|
+
expect(res.statusCode).toBe(401);
|
|
404
|
+
const body = res.json();
|
|
405
|
+
expect(body.error.code).toBe(-32005);
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
it("rejects invalid API key", async () => {
|
|
409
|
+
const res = await app.inject({
|
|
410
|
+
method: "POST",
|
|
411
|
+
url: "/sbp",
|
|
412
|
+
headers: { Authorization: "Bearer wrong-key" },
|
|
413
|
+
payload: rpc("sbp/sniff"),
|
|
414
|
+
});
|
|
415
|
+
expect(res.statusCode).toBe(401);
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
it("accepts valid API key", async () => {
|
|
419
|
+
const res = await app.inject({
|
|
420
|
+
method: "POST",
|
|
421
|
+
url: "/sbp",
|
|
422
|
+
headers: { Authorization: "Bearer test-key-123" },
|
|
423
|
+
payload: rpc("sbp/sniff"),
|
|
424
|
+
});
|
|
425
|
+
expect(res.statusCode).toBe(200);
|
|
426
|
+
expect(res.json().result).toBeDefined();
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
it("allows health check without auth", async () => {
|
|
430
|
+
const res = await app.inject({
|
|
431
|
+
method: "GET",
|
|
432
|
+
url: "/health",
|
|
433
|
+
});
|
|
434
|
+
expect(res.statusCode).toBe(200);
|
|
435
|
+
});
|
|
436
|
+
});
|