@cleocode/lafs-protocol 0.5.0 → 1.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/LICENSE +0 -0
- package/README.md +7 -3
- package/dist/examples/discovery-server.d.ts +8 -0
- package/dist/examples/discovery-server.js +216 -0
- package/dist/examples/mcp-lafs-client.d.ts +10 -0
- package/dist/examples/mcp-lafs-client.js +427 -0
- package/dist/examples/mcp-lafs-server.d.ts +10 -0
- package/dist/examples/mcp-lafs-server.js +358 -0
- package/dist/schemas/v1/envelope.schema.json +0 -0
- package/dist/schemas/v1/error-registry.json +0 -0
- package/dist/src/a2a/bridge.d.ts +129 -0
- package/dist/src/a2a/bridge.js +173 -0
- package/dist/src/a2a/index.d.ts +36 -0
- package/dist/src/a2a/index.js +36 -0
- package/dist/src/budgetEnforcement.d.ts +84 -0
- package/dist/src/budgetEnforcement.js +328 -0
- package/dist/src/circuit-breaker/index.d.ts +121 -0
- package/dist/src/circuit-breaker/index.js +249 -0
- package/dist/src/cli.d.ts +0 -0
- package/dist/src/cli.js +0 -0
- package/dist/src/conformance.d.ts +0 -0
- package/dist/src/conformance.js +0 -0
- package/dist/src/discovery.d.ts +127 -0
- package/dist/src/discovery.js +304 -0
- package/dist/src/errorRegistry.d.ts +0 -0
- package/dist/src/errorRegistry.js +0 -0
- package/dist/src/flagSemantics.d.ts +0 -0
- package/dist/src/flagSemantics.js +0 -0
- package/dist/src/health/index.d.ts +105 -0
- package/dist/src/health/index.js +211 -0
- package/dist/src/index.d.ts +8 -0
- package/dist/src/index.js +10 -0
- package/dist/src/mcpAdapter.d.ts +28 -0
- package/dist/src/mcpAdapter.js +281 -0
- package/dist/src/shutdown/index.d.ts +69 -0
- package/dist/src/shutdown/index.js +160 -0
- package/dist/src/tokenEstimator.d.ts +87 -0
- package/dist/src/tokenEstimator.js +238 -0
- package/dist/src/types.d.ts +25 -0
- package/dist/src/types.js +0 -0
- package/dist/src/validateEnvelope.d.ts +0 -0
- package/dist/src/validateEnvelope.js +0 -0
- package/lafs.md +167 -0
- package/package.json +10 -4
- package/schemas/v1/context-ledger.schema.json +0 -0
- package/schemas/v1/discovery.schema.json +132 -0
- package/schemas/v1/envelope.schema.json +0 -0
- package/schemas/v1/error-registry.json +0 -0
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LAFS Circuit Breaker Module
|
|
3
|
+
*
|
|
4
|
+
* Provides circuit breaker pattern for resilient service calls
|
|
5
|
+
*/
|
|
6
|
+
export class CircuitBreakerError extends Error {
|
|
7
|
+
constructor(message) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.name = 'CircuitBreakerError';
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Circuit breaker for protecting against cascading failures
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* ```typescript
|
|
17
|
+
* import { CircuitBreaker } from '@cleocode/lafs-protocol/circuit-breaker';
|
|
18
|
+
*
|
|
19
|
+
* const breaker = new CircuitBreaker({
|
|
20
|
+
* name: 'external-api',
|
|
21
|
+
* failureThreshold: 5,
|
|
22
|
+
* resetTimeout: 30000
|
|
23
|
+
* });
|
|
24
|
+
*
|
|
25
|
+
* try {
|
|
26
|
+
* const result = await breaker.execute(async () => {
|
|
27
|
+
* return await externalApi.call();
|
|
28
|
+
* });
|
|
29
|
+
* } catch (error) {
|
|
30
|
+
* if (error instanceof CircuitBreakerError) {
|
|
31
|
+
* console.log('Circuit breaker is open');
|
|
32
|
+
* }
|
|
33
|
+
* }
|
|
34
|
+
* ```
|
|
35
|
+
*/
|
|
36
|
+
export class CircuitBreaker {
|
|
37
|
+
config;
|
|
38
|
+
state = 'CLOSED';
|
|
39
|
+
failures = 0;
|
|
40
|
+
successes = 0;
|
|
41
|
+
lastFailureTime;
|
|
42
|
+
consecutiveSuccesses = 0;
|
|
43
|
+
totalCalls = 0;
|
|
44
|
+
halfOpenCalls = 0;
|
|
45
|
+
resetTimer;
|
|
46
|
+
constructor(config) {
|
|
47
|
+
this.config = config;
|
|
48
|
+
this.config = {
|
|
49
|
+
failureThreshold: 5,
|
|
50
|
+
resetTimeout: 30000,
|
|
51
|
+
halfOpenMaxCalls: 3,
|
|
52
|
+
successThreshold: 2,
|
|
53
|
+
...config
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Execute a function with circuit breaker protection
|
|
58
|
+
*/
|
|
59
|
+
async execute(fn) {
|
|
60
|
+
this.totalCalls++;
|
|
61
|
+
if (this.state === 'OPEN') {
|
|
62
|
+
if (this.shouldAttemptReset()) {
|
|
63
|
+
this.transitionTo('HALF_OPEN');
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
throw new CircuitBreakerError(`Circuit breaker '${this.config.name}' is OPEN`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (this.state === 'HALF_OPEN') {
|
|
70
|
+
if (this.halfOpenCalls >= (this.config.halfOpenMaxCalls || 3)) {
|
|
71
|
+
throw new CircuitBreakerError(`Circuit breaker '${this.config.name}' is HALF_OPEN (max calls reached)`);
|
|
72
|
+
}
|
|
73
|
+
this.halfOpenCalls++;
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
const result = await fn();
|
|
77
|
+
this.onSuccess();
|
|
78
|
+
return result;
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
this.onFailure();
|
|
82
|
+
throw error;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Get current circuit breaker state
|
|
87
|
+
*/
|
|
88
|
+
getState() {
|
|
89
|
+
return this.state;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Get circuit breaker metrics
|
|
93
|
+
*/
|
|
94
|
+
getMetrics() {
|
|
95
|
+
return {
|
|
96
|
+
state: this.state,
|
|
97
|
+
failures: this.failures,
|
|
98
|
+
successes: this.successes,
|
|
99
|
+
lastFailureTime: this.lastFailureTime,
|
|
100
|
+
consecutiveSuccesses: this.consecutiveSuccesses,
|
|
101
|
+
totalCalls: this.totalCalls
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Manually open the circuit breaker
|
|
106
|
+
*/
|
|
107
|
+
forceOpen() {
|
|
108
|
+
this.transitionTo('OPEN');
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Manually close the circuit breaker
|
|
112
|
+
*/
|
|
113
|
+
forceClose() {
|
|
114
|
+
this.transitionTo('CLOSED');
|
|
115
|
+
this.reset();
|
|
116
|
+
}
|
|
117
|
+
onSuccess() {
|
|
118
|
+
this.successes++;
|
|
119
|
+
this.consecutiveSuccesses++;
|
|
120
|
+
if (this.state === 'HALF_OPEN') {
|
|
121
|
+
if (this.consecutiveSuccesses >= (this.config.successThreshold || 2)) {
|
|
122
|
+
this.transitionTo('CLOSED');
|
|
123
|
+
this.reset();
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
onFailure() {
|
|
128
|
+
this.failures++;
|
|
129
|
+
this.consecutiveSuccesses = 0;
|
|
130
|
+
this.lastFailureTime = new Date();
|
|
131
|
+
if (this.state === 'HALF_OPEN') {
|
|
132
|
+
this.transitionTo('OPEN');
|
|
133
|
+
this.scheduleReset();
|
|
134
|
+
}
|
|
135
|
+
else if (this.state === 'CLOSED') {
|
|
136
|
+
if (this.failures >= (this.config.failureThreshold || 5)) {
|
|
137
|
+
this.transitionTo('OPEN');
|
|
138
|
+
this.scheduleReset();
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
transitionTo(newState) {
|
|
143
|
+
console.log(`Circuit breaker '${this.config.name}': ${this.state} -> ${newState}`);
|
|
144
|
+
this.state = newState;
|
|
145
|
+
if (newState === 'HALF_OPEN') {
|
|
146
|
+
this.halfOpenCalls = 0;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
shouldAttemptReset() {
|
|
150
|
+
if (!this.lastFailureTime)
|
|
151
|
+
return true;
|
|
152
|
+
const elapsed = Date.now() - this.lastFailureTime.getTime();
|
|
153
|
+
return elapsed >= (this.config.resetTimeout || 30000);
|
|
154
|
+
}
|
|
155
|
+
scheduleReset() {
|
|
156
|
+
if (this.resetTimer) {
|
|
157
|
+
clearTimeout(this.resetTimer);
|
|
158
|
+
}
|
|
159
|
+
this.resetTimer = setTimeout(() => {
|
|
160
|
+
if (this.state === 'OPEN') {
|
|
161
|
+
this.transitionTo('HALF_OPEN');
|
|
162
|
+
}
|
|
163
|
+
}, this.config.resetTimeout || 30000);
|
|
164
|
+
}
|
|
165
|
+
reset() {
|
|
166
|
+
this.failures = 0;
|
|
167
|
+
this.consecutiveSuccesses = 0;
|
|
168
|
+
this.halfOpenCalls = 0;
|
|
169
|
+
if (this.resetTimer) {
|
|
170
|
+
clearTimeout(this.resetTimer);
|
|
171
|
+
this.resetTimer = undefined;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Circuit breaker registry for managing multiple breakers
|
|
177
|
+
*
|
|
178
|
+
* @example
|
|
179
|
+
* ```typescript
|
|
180
|
+
* const registry = new CircuitBreakerRegistry();
|
|
181
|
+
*
|
|
182
|
+
* registry.add('payment-api', {
|
|
183
|
+
* failureThreshold: 3,
|
|
184
|
+
* resetTimeout: 60000
|
|
185
|
+
* });
|
|
186
|
+
*
|
|
187
|
+
* const paymentBreaker = registry.get('payment-api');
|
|
188
|
+
* ```
|
|
189
|
+
*/
|
|
190
|
+
export class CircuitBreakerRegistry {
|
|
191
|
+
breakers = new Map();
|
|
192
|
+
add(name, config) {
|
|
193
|
+
const breaker = new CircuitBreaker({ ...config, name });
|
|
194
|
+
this.breakers.set(name, breaker);
|
|
195
|
+
return breaker;
|
|
196
|
+
}
|
|
197
|
+
get(name) {
|
|
198
|
+
return this.breakers.get(name);
|
|
199
|
+
}
|
|
200
|
+
getOrCreate(name, config) {
|
|
201
|
+
let breaker = this.breakers.get(name);
|
|
202
|
+
if (!breaker) {
|
|
203
|
+
breaker = this.add(name, config);
|
|
204
|
+
}
|
|
205
|
+
return breaker;
|
|
206
|
+
}
|
|
207
|
+
getAllMetrics() {
|
|
208
|
+
const metrics = {};
|
|
209
|
+
this.breakers.forEach((breaker, name) => {
|
|
210
|
+
metrics[name] = breaker.getMetrics();
|
|
211
|
+
});
|
|
212
|
+
return metrics;
|
|
213
|
+
}
|
|
214
|
+
resetAll() {
|
|
215
|
+
this.breakers.forEach(breaker => breaker.forceClose());
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Create a circuit breaker middleware for Express
|
|
220
|
+
*
|
|
221
|
+
* @example
|
|
222
|
+
* ```typescript
|
|
223
|
+
* app.use('/external-api', circuitBreakerMiddleware({
|
|
224
|
+
* name: 'external-api',
|
|
225
|
+
* failureThreshold: 5
|
|
226
|
+
* }));
|
|
227
|
+
* ```
|
|
228
|
+
*/
|
|
229
|
+
export function circuitBreakerMiddleware(config) {
|
|
230
|
+
const breaker = new CircuitBreaker(config);
|
|
231
|
+
return async (req, res, next) => {
|
|
232
|
+
try {
|
|
233
|
+
await breaker.execute(async () => {
|
|
234
|
+
next();
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
catch (error) {
|
|
238
|
+
if (error instanceof CircuitBreakerError) {
|
|
239
|
+
res.status(503).json({
|
|
240
|
+
error: 'Service temporarily unavailable',
|
|
241
|
+
reason: 'Circuit breaker is open'
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
else {
|
|
245
|
+
throw error;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
};
|
|
249
|
+
}
|
package/dist/src/cli.d.ts
CHANGED
|
File without changes
|
package/dist/src/cli.js
CHANGED
|
File without changes
|
|
File without changes
|
package/dist/src/conformance.js
CHANGED
|
File without changes
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LAFS Agent Discovery - Express/Fastify Middleware
|
|
3
|
+
* Serves discovery document at /.well-known/lafs.json
|
|
4
|
+
*/
|
|
5
|
+
import type { RequestHandler } from "express";
|
|
6
|
+
/**
|
|
7
|
+
* Capability definition for service advertisement
|
|
8
|
+
*/
|
|
9
|
+
export interface Capability {
|
|
10
|
+
name: string;
|
|
11
|
+
version: string;
|
|
12
|
+
description?: string;
|
|
13
|
+
operations: string[];
|
|
14
|
+
optional?: boolean;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Service configuration for discovery document
|
|
18
|
+
*/
|
|
19
|
+
export interface ServiceConfig {
|
|
20
|
+
name: string;
|
|
21
|
+
version: string;
|
|
22
|
+
description?: string;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Endpoint configuration for discovery document
|
|
26
|
+
*/
|
|
27
|
+
export interface EndpointConfig {
|
|
28
|
+
envelope: string;
|
|
29
|
+
context?: string;
|
|
30
|
+
discovery: string;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Complete discovery document served at /.well-known/lafs.json
|
|
34
|
+
*/
|
|
35
|
+
export interface DiscoveryDocument {
|
|
36
|
+
$schema: string;
|
|
37
|
+
lafs_version: string;
|
|
38
|
+
service: ServiceConfig;
|
|
39
|
+
capabilities: Capability[];
|
|
40
|
+
endpoints: EndpointConfig;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Configuration for the discovery middleware
|
|
44
|
+
*/
|
|
45
|
+
export interface DiscoveryConfig {
|
|
46
|
+
/** Service information */
|
|
47
|
+
service: ServiceConfig;
|
|
48
|
+
/** List of capabilities this service provides */
|
|
49
|
+
capabilities: Capability[];
|
|
50
|
+
/** Endpoint URLs - can be relative paths or absolute URLs */
|
|
51
|
+
endpoints: {
|
|
52
|
+
/** URL for envelope submission endpoint */
|
|
53
|
+
envelope: string;
|
|
54
|
+
/** Optional URL for context ledger endpoint */
|
|
55
|
+
context?: string;
|
|
56
|
+
/** URL for this discovery document (usually auto-detected) */
|
|
57
|
+
discovery?: string;
|
|
58
|
+
};
|
|
59
|
+
/** Cache duration in seconds (default: 3600) */
|
|
60
|
+
cacheMaxAge?: number;
|
|
61
|
+
/** LAFS protocol version (default: "1.0.0") */
|
|
62
|
+
lafsVersion?: string;
|
|
63
|
+
/** Schema URL override */
|
|
64
|
+
schemaUrl?: string;
|
|
65
|
+
/** Base URL for constructing absolute URLs */
|
|
66
|
+
baseUrl?: string;
|
|
67
|
+
/** Optional custom headers to include in response */
|
|
68
|
+
headers?: Record<string, string>;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Discovery middleware options
|
|
72
|
+
*/
|
|
73
|
+
export interface DiscoveryMiddlewareOptions {
|
|
74
|
+
/** Path to serve discovery document (default: /.well-known/lafs.json) */
|
|
75
|
+
path?: string;
|
|
76
|
+
/** Enable HEAD requests (default: true) */
|
|
77
|
+
enableHead?: boolean;
|
|
78
|
+
/** Enable ETag caching (default: true) */
|
|
79
|
+
enableEtag?: boolean;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Create Express middleware for serving LAFS discovery document
|
|
83
|
+
*
|
|
84
|
+
* @param config - Discovery configuration
|
|
85
|
+
* @param options - Middleware options
|
|
86
|
+
* @returns Express RequestHandler
|
|
87
|
+
*
|
|
88
|
+
* @example
|
|
89
|
+
* ```typescript
|
|
90
|
+
* import express from "express";
|
|
91
|
+
* import { discoveryMiddleware } from "./discovery.js";
|
|
92
|
+
*
|
|
93
|
+
* const app = express();
|
|
94
|
+
*
|
|
95
|
+
* app.use(discoveryMiddleware({
|
|
96
|
+
* service: {
|
|
97
|
+
* name: "my-lafs-service",
|
|
98
|
+
* version: "1.0.0",
|
|
99
|
+
* description: "A LAFS-compliant API service"
|
|
100
|
+
* },
|
|
101
|
+
* capabilities: [
|
|
102
|
+
* {
|
|
103
|
+
* name: "envelope-processor",
|
|
104
|
+
* version: "1.0.0",
|
|
105
|
+
* operations: ["process", "validate"],
|
|
106
|
+
* description: "Process LAFS envelopes"
|
|
107
|
+
* }
|
|
108
|
+
* ],
|
|
109
|
+
* endpoints: {
|
|
110
|
+
* envelope: "/api/v1/envelope",
|
|
111
|
+
* context: "/api/v1/context"
|
|
112
|
+
* }
|
|
113
|
+
* }));
|
|
114
|
+
* ```
|
|
115
|
+
*/
|
|
116
|
+
export declare function discoveryMiddleware(config: DiscoveryConfig, options?: DiscoveryMiddlewareOptions): RequestHandler;
|
|
117
|
+
/**
|
|
118
|
+
* Fastify plugin for LAFS discovery (for Fastify users)
|
|
119
|
+
*
|
|
120
|
+
* @param fastify - Fastify instance
|
|
121
|
+
* @param options - Plugin options
|
|
122
|
+
*/
|
|
123
|
+
export declare function discoveryFastifyPlugin(fastify: unknown, options: {
|
|
124
|
+
config: DiscoveryConfig;
|
|
125
|
+
path?: string;
|
|
126
|
+
}): Promise<void>;
|
|
127
|
+
export default discoveryMiddleware;
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LAFS Agent Discovery - Express/Fastify Middleware
|
|
3
|
+
* Serves discovery document at /.well-known/lafs.json
|
|
4
|
+
*/
|
|
5
|
+
import { createRequire } from "node:module";
|
|
6
|
+
import { createHash } from "crypto";
|
|
7
|
+
import { readFileSync } from "fs";
|
|
8
|
+
import { fileURLToPath } from "url";
|
|
9
|
+
import { dirname, join } from "path";
|
|
10
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
11
|
+
const __dirname = dirname(__filename);
|
|
12
|
+
// Handle ESM/CommonJS interop for AJV
|
|
13
|
+
const require = createRequire(import.meta.url);
|
|
14
|
+
const AjvModule = require("ajv");
|
|
15
|
+
const AddFormatsModule = require("ajv-formats");
|
|
16
|
+
const AjvCtor = (typeof AjvModule === "function" ? AjvModule : AjvModule.default);
|
|
17
|
+
const addFormats = (typeof AddFormatsModule === "function" ? AddFormatsModule : AddFormatsModule.default);
|
|
18
|
+
let ajvInstance = null;
|
|
19
|
+
let validateDiscovery = null;
|
|
20
|
+
/**
|
|
21
|
+
* Initialize AJV validator for discovery documents
|
|
22
|
+
*/
|
|
23
|
+
function initValidator() {
|
|
24
|
+
if (ajvInstance && validateDiscovery)
|
|
25
|
+
return;
|
|
26
|
+
ajvInstance = new AjvCtor({ strict: true, allErrors: true });
|
|
27
|
+
addFormats(ajvInstance);
|
|
28
|
+
try {
|
|
29
|
+
// Try to load schema from schemas directory
|
|
30
|
+
const schemaPath = join(__dirname, "..", "..", "schemas", "v1", "discovery.schema.json");
|
|
31
|
+
const schema = JSON.parse(readFileSync(schemaPath, "utf-8"));
|
|
32
|
+
validateDiscovery = ajvInstance.compile(schema);
|
|
33
|
+
}
|
|
34
|
+
catch (e) {
|
|
35
|
+
// Fallback to inline schema if file not found
|
|
36
|
+
const fallbackSchema = {
|
|
37
|
+
$schema: "http://json-schema.org/draft-07/schema#",
|
|
38
|
+
type: "object",
|
|
39
|
+
required: ["$schema", "lafs_version", "service", "capabilities", "endpoints"],
|
|
40
|
+
properties: {
|
|
41
|
+
$schema: { type: "string", format: "uri" },
|
|
42
|
+
lafs_version: { type: "string", pattern: "^\\d+\\.\\d+\\.\\d+$" },
|
|
43
|
+
service: {
|
|
44
|
+
type: "object",
|
|
45
|
+
required: ["name", "version"],
|
|
46
|
+
properties: {
|
|
47
|
+
name: { type: "string", minLength: 1 },
|
|
48
|
+
version: { type: "string", pattern: "^\\d+\\.\\d+\\.\\d+$" },
|
|
49
|
+
description: { type: "string" }
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
capabilities: {
|
|
53
|
+
type: "array",
|
|
54
|
+
items: {
|
|
55
|
+
type: "object",
|
|
56
|
+
required: ["name", "version", "operations"],
|
|
57
|
+
properties: {
|
|
58
|
+
name: { type: "string", minLength: 1 },
|
|
59
|
+
version: { type: "string", pattern: "^\\d+\\.\\d+\\.\\d+$" },
|
|
60
|
+
description: { type: "string" },
|
|
61
|
+
operations: { type: "array", items: { type: "string" } },
|
|
62
|
+
optional: { type: "boolean" }
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
endpoints: {
|
|
67
|
+
type: "object",
|
|
68
|
+
required: ["envelope", "discovery"],
|
|
69
|
+
properties: {
|
|
70
|
+
envelope: { type: "string", minLength: 1 },
|
|
71
|
+
context: { type: "string", minLength: 1 },
|
|
72
|
+
discovery: { type: "string", minLength: 1 }
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
validateDiscovery = ajvInstance.compile(fallbackSchema);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Build absolute URL from base and path
|
|
82
|
+
*/
|
|
83
|
+
function buildUrl(base, path, req) {
|
|
84
|
+
// If path is already absolute, return it
|
|
85
|
+
if (path.startsWith("http://") || path.startsWith("https://")) {
|
|
86
|
+
return path;
|
|
87
|
+
}
|
|
88
|
+
// If base is provided, use it
|
|
89
|
+
if (base) {
|
|
90
|
+
const separator = base.endsWith("/") || path.startsWith("/") ? "" : "/";
|
|
91
|
+
return `${base}${separator}${path}`;
|
|
92
|
+
}
|
|
93
|
+
// Otherwise try to construct from request
|
|
94
|
+
if (req) {
|
|
95
|
+
const protocol = req.headers["x-forwarded-proto"] || req.protocol || "http";
|
|
96
|
+
const host = req.headers.host || "localhost";
|
|
97
|
+
const separator = path.startsWith("/") ? "" : "/";
|
|
98
|
+
return `${protocol}://${host}${separator}${path}`;
|
|
99
|
+
}
|
|
100
|
+
// Fallback to relative path
|
|
101
|
+
return path.startsWith("/") ? path : `/${path}`;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Generate ETag from document content
|
|
105
|
+
*/
|
|
106
|
+
function generateETag(content) {
|
|
107
|
+
return `"${createHash("sha256").update(content).digest("hex").slice(0, 32)}"`;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Build discovery document from configuration
|
|
111
|
+
*/
|
|
112
|
+
function buildDiscoveryDocument(config, req) {
|
|
113
|
+
const schemaUrl = config.schemaUrl || "https://lafs.dev/schemas/v1/discovery.schema.json";
|
|
114
|
+
const lafsVersion = config.lafsVersion || "1.0.0";
|
|
115
|
+
return {
|
|
116
|
+
$schema: schemaUrl,
|
|
117
|
+
lafs_version: lafsVersion,
|
|
118
|
+
service: config.service,
|
|
119
|
+
capabilities: config.capabilities,
|
|
120
|
+
endpoints: {
|
|
121
|
+
envelope: buildUrl(config.baseUrl, config.endpoints.envelope, req),
|
|
122
|
+
context: config.endpoints.context
|
|
123
|
+
? buildUrl(config.baseUrl, config.endpoints.context, req)
|
|
124
|
+
: undefined,
|
|
125
|
+
discovery: config.endpoints.discovery
|
|
126
|
+
? buildUrl(config.baseUrl, config.endpoints.discovery, req)
|
|
127
|
+
: buildUrl(config.baseUrl, "/.well-known/lafs.json", req)
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Validate discovery document against schema
|
|
133
|
+
*/
|
|
134
|
+
function validateDocument(doc) {
|
|
135
|
+
initValidator();
|
|
136
|
+
if (!validateDiscovery) {
|
|
137
|
+
throw new Error("Discovery document validator not initialized");
|
|
138
|
+
}
|
|
139
|
+
const valid = validateDiscovery(doc);
|
|
140
|
+
if (!valid) {
|
|
141
|
+
const errors = validateDiscovery.errors;
|
|
142
|
+
const errorMessages = errors?.map((e) => `${e.instancePath || "root"}: ${e.message}`).join("; ");
|
|
143
|
+
throw new Error(`Discovery document validation failed: ${errorMessages}`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Create Express middleware for serving LAFS discovery document
|
|
148
|
+
*
|
|
149
|
+
* @param config - Discovery configuration
|
|
150
|
+
* @param options - Middleware options
|
|
151
|
+
* @returns Express RequestHandler
|
|
152
|
+
*
|
|
153
|
+
* @example
|
|
154
|
+
* ```typescript
|
|
155
|
+
* import express from "express";
|
|
156
|
+
* import { discoveryMiddleware } from "./discovery.js";
|
|
157
|
+
*
|
|
158
|
+
* const app = express();
|
|
159
|
+
*
|
|
160
|
+
* app.use(discoveryMiddleware({
|
|
161
|
+
* service: {
|
|
162
|
+
* name: "my-lafs-service",
|
|
163
|
+
* version: "1.0.0",
|
|
164
|
+
* description: "A LAFS-compliant API service"
|
|
165
|
+
* },
|
|
166
|
+
* capabilities: [
|
|
167
|
+
* {
|
|
168
|
+
* name: "envelope-processor",
|
|
169
|
+
* version: "1.0.0",
|
|
170
|
+
* operations: ["process", "validate"],
|
|
171
|
+
* description: "Process LAFS envelopes"
|
|
172
|
+
* }
|
|
173
|
+
* ],
|
|
174
|
+
* endpoints: {
|
|
175
|
+
* envelope: "/api/v1/envelope",
|
|
176
|
+
* context: "/api/v1/context"
|
|
177
|
+
* }
|
|
178
|
+
* }));
|
|
179
|
+
* ```
|
|
180
|
+
*/
|
|
181
|
+
export function discoveryMiddleware(config, options = {}) {
|
|
182
|
+
const path = options.path || "/.well-known/lafs.json";
|
|
183
|
+
const enableHead = options.enableHead !== false;
|
|
184
|
+
const enableEtag = options.enableEtag !== false;
|
|
185
|
+
const cacheMaxAge = config.cacheMaxAge || 3600;
|
|
186
|
+
// Validate configuration
|
|
187
|
+
if (!config.service?.name || !config.service?.version) {
|
|
188
|
+
throw new Error("Discovery config requires service.name and service.version");
|
|
189
|
+
}
|
|
190
|
+
if (!Array.isArray(config.capabilities)) {
|
|
191
|
+
throw new Error("Discovery config requires capabilities array");
|
|
192
|
+
}
|
|
193
|
+
if (!config.endpoints?.envelope) {
|
|
194
|
+
throw new Error("Discovery config requires endpoints.envelope");
|
|
195
|
+
}
|
|
196
|
+
return function discoveryHandler(req, res, next) {
|
|
197
|
+
// Only handle requests to the discovery path
|
|
198
|
+
if (req.path !== path) {
|
|
199
|
+
next();
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
// Handle HEAD requests
|
|
203
|
+
if (req.method === "HEAD") {
|
|
204
|
+
if (!enableHead) {
|
|
205
|
+
res.status(405).json({
|
|
206
|
+
error: "Method Not Allowed",
|
|
207
|
+
message: "HEAD requests are disabled for this endpoint"
|
|
208
|
+
});
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
// For HEAD, we still need to build the document to get the ETag
|
|
212
|
+
const doc = buildDiscoveryDocument(config, req);
|
|
213
|
+
const json = JSON.stringify(doc);
|
|
214
|
+
// Generate stable ETag from config hash (not request-dependent document)
|
|
215
|
+
const configHash = generateETag(JSON.stringify({
|
|
216
|
+
schemaUrl: config.schemaUrl,
|
|
217
|
+
lafsVersion: config.lafsVersion,
|
|
218
|
+
service: config.service,
|
|
219
|
+
capabilities: config.capabilities,
|
|
220
|
+
endpoints: config.endpoints,
|
|
221
|
+
cacheMaxAge: config.cacheMaxAge
|
|
222
|
+
}));
|
|
223
|
+
const etag = enableEtag ? configHash : undefined;
|
|
224
|
+
res.set({
|
|
225
|
+
"Content-Type": "application/json",
|
|
226
|
+
"Cache-Control": `public, max-age=${cacheMaxAge}`,
|
|
227
|
+
...(etag && { "ETag": etag }),
|
|
228
|
+
"Content-Length": Buffer.byteLength(json)
|
|
229
|
+
});
|
|
230
|
+
res.status(200).end();
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
// Only handle GET requests
|
|
234
|
+
if (req.method !== "GET") {
|
|
235
|
+
res.status(405).json({
|
|
236
|
+
error: "Method Not Allowed",
|
|
237
|
+
message: `Method ${req.method} not allowed. Use GET or HEAD.`
|
|
238
|
+
});
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
try {
|
|
242
|
+
// Build discovery document
|
|
243
|
+
const doc = buildDiscoveryDocument(config, req);
|
|
244
|
+
// Validate against schema
|
|
245
|
+
validateDocument(doc);
|
|
246
|
+
// Serialize document
|
|
247
|
+
const json = JSON.stringify(doc);
|
|
248
|
+
// Generate ETag from config hash (stable) rather than request-dependent document
|
|
249
|
+
// This ensures ETag is consistent across requests even when URLs are constructed from request
|
|
250
|
+
const configHash = generateETag(JSON.stringify({
|
|
251
|
+
schemaUrl: config.schemaUrl,
|
|
252
|
+
lafsVersion: config.lafsVersion,
|
|
253
|
+
service: config.service,
|
|
254
|
+
capabilities: config.capabilities,
|
|
255
|
+
endpoints: config.endpoints,
|
|
256
|
+
cacheMaxAge: config.cacheMaxAge
|
|
257
|
+
}));
|
|
258
|
+
const etag = enableEtag ? configHash : undefined;
|
|
259
|
+
// Check If-None-Match for conditional request
|
|
260
|
+
if (enableEtag && req.headers["if-none-match"] === etag) {
|
|
261
|
+
res.status(304).end();
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
// Set response headers
|
|
265
|
+
const headers = {
|
|
266
|
+
"Content-Type": "application/json",
|
|
267
|
+
"Cache-Control": `public, max-age=${cacheMaxAge}`,
|
|
268
|
+
...config.headers
|
|
269
|
+
};
|
|
270
|
+
if (etag) {
|
|
271
|
+
headers["ETag"] = etag;
|
|
272
|
+
}
|
|
273
|
+
res.set(headers);
|
|
274
|
+
res.status(200).send(json);
|
|
275
|
+
}
|
|
276
|
+
catch (error) {
|
|
277
|
+
next(error);
|
|
278
|
+
}
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* Fastify plugin for LAFS discovery (for Fastify users)
|
|
283
|
+
*
|
|
284
|
+
* @param fastify - Fastify instance
|
|
285
|
+
* @param options - Plugin options
|
|
286
|
+
*/
|
|
287
|
+
export async function discoveryFastifyPlugin(fastify, options) {
|
|
288
|
+
const path = options.path || "/.well-known/lafs.json";
|
|
289
|
+
const config = options.config;
|
|
290
|
+
const cacheMaxAge = config.cacheMaxAge || 3600;
|
|
291
|
+
const handler = async (request, reply) => {
|
|
292
|
+
const doc = buildDiscoveryDocument(config, request.raw);
|
|
293
|
+
validateDocument(doc);
|
|
294
|
+
const json = JSON.stringify(doc);
|
|
295
|
+
const etag = generateETag(json);
|
|
296
|
+
reply.header("Content-Type", "application/json");
|
|
297
|
+
reply.header("Cache-Control", `public, max-age=${cacheMaxAge}`);
|
|
298
|
+
reply.header("ETag", etag);
|
|
299
|
+
return doc;
|
|
300
|
+
};
|
|
301
|
+
// Note: Actual route registration depends on Fastify's API
|
|
302
|
+
// This is a type-safe signature for the plugin
|
|
303
|
+
}
|
|
304
|
+
export default discoveryMiddleware;
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|