@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.
Files changed (63) hide show
  1. package/benchmarks/bench.ts +272 -0
  2. package/dist/auth.d.ts +20 -0
  3. package/dist/auth.d.ts.map +1 -0
  4. package/dist/auth.js +69 -0
  5. package/dist/auth.js.map +1 -0
  6. package/dist/blackboard.d.ts +84 -0
  7. package/dist/blackboard.d.ts.map +1 -0
  8. package/dist/blackboard.js +502 -0
  9. package/dist/blackboard.js.map +1 -0
  10. package/dist/cli.d.ts +7 -0
  11. package/dist/cli.d.ts.map +1 -0
  12. package/dist/cli.js +102 -0
  13. package/dist/cli.js.map +1 -0
  14. package/dist/conditions.d.ts +27 -0
  15. package/dist/conditions.d.ts.map +1 -0
  16. package/dist/conditions.js +240 -0
  17. package/dist/conditions.js.map +1 -0
  18. package/dist/decay.d.ts +21 -0
  19. package/dist/decay.d.ts.map +1 -0
  20. package/dist/decay.js +88 -0
  21. package/dist/decay.js.map +1 -0
  22. package/dist/index.d.ts +13 -0
  23. package/dist/index.d.ts.map +1 -0
  24. package/dist/index.js +13 -0
  25. package/dist/index.js.map +1 -0
  26. package/dist/rate-limiter.d.ts +21 -0
  27. package/dist/rate-limiter.d.ts.map +1 -0
  28. package/dist/rate-limiter.js +75 -0
  29. package/dist/rate-limiter.js.map +1 -0
  30. package/dist/server.d.ts +63 -0
  31. package/dist/server.d.ts.map +1 -0
  32. package/dist/server.js +401 -0
  33. package/dist/server.js.map +1 -0
  34. package/dist/store.d.ts +54 -0
  35. package/dist/store.d.ts.map +1 -0
  36. package/dist/store.js +55 -0
  37. package/dist/store.js.map +1 -0
  38. package/dist/types.d.ts +247 -0
  39. package/dist/types.d.ts.map +1 -0
  40. package/dist/types.js +26 -0
  41. package/dist/types.js.map +1 -0
  42. package/dist/validation.d.ts +296 -0
  43. package/dist/validation.d.ts.map +1 -0
  44. package/dist/validation.js +205 -0
  45. package/dist/validation.js.map +1 -0
  46. package/eslint.config.js +26 -0
  47. package/package.json +66 -0
  48. package/src/auth.ts +89 -0
  49. package/src/blackboard.test.ts +287 -0
  50. package/src/blackboard.ts +651 -0
  51. package/src/cli.ts +116 -0
  52. package/src/conditions.ts +305 -0
  53. package/src/conformance.test.ts +686 -0
  54. package/src/decay.ts +103 -0
  55. package/src/index.ts +24 -0
  56. package/src/rate-limiter.ts +104 -0
  57. package/src/server.integration.test.ts +436 -0
  58. package/src/server.ts +500 -0
  59. package/src/store.ts +108 -0
  60. package/src/types.ts +314 -0
  61. package/src/validation.ts +251 -0
  62. package/tsconfig.eslint.json +5 -0
  63. 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
+ });