@cleocode/lafs-protocol 0.1.1 → 1.0.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/README.md +129 -23
- 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 +103 -14
- package/dist/schemas/v1/error-registry.json +19 -1
- package/dist/src/budgetEnforcement.d.ts +84 -0
- package/dist/src/budgetEnforcement.js +328 -0
- package/dist/src/cli.d.ts +14 -0
- package/dist/src/cli.js +14 -0
- package/dist/src/conformance.js +80 -7
- package/dist/src/discovery.d.ts +127 -0
- package/dist/src/discovery.js +304 -0
- package/dist/src/index.d.ts +4 -0
- package/dist/src/index.js +4 -0
- package/dist/src/mcpAdapter.d.ts +28 -0
- package/dist/src/mcpAdapter.js +281 -0
- package/dist/src/tokenEstimator.d.ts +87 -0
- package/dist/src/tokenEstimator.js +238 -0
- package/dist/src/types.d.ts +67 -7
- package/lafs.md +331 -23
- package/package.json +8 -3
- package/schemas/v1/context-ledger.schema.json +70 -0
- package/schemas/v1/discovery.schema.json +132 -0
- package/schemas/v1/envelope.schema.json +103 -14
- package/schemas/v1/error-registry.json +19 -1
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LAFS Budget Enforcement
|
|
3
|
+
*
|
|
4
|
+
* Middleware for enforcing MVI (Minimal Viable Interface) token budgets on LAFS envelopes.
|
|
5
|
+
* Provides budget checking, truncation, and error generation for exceeded budgets.
|
|
6
|
+
*/
|
|
7
|
+
import { TokenEstimator } from "./tokenEstimator.js";
|
|
8
|
+
/**
|
|
9
|
+
* Budget exceeded error code from LAFS error registry
|
|
10
|
+
*/
|
|
11
|
+
const BUDGET_EXCEEDED_CODE = "E_MVI_BUDGET_EXCEEDED";
|
|
12
|
+
/**
|
|
13
|
+
* Default category for budget exceeded errors
|
|
14
|
+
*/
|
|
15
|
+
const BUDGET_ERROR_CATEGORY = "VALIDATION";
|
|
16
|
+
/**
|
|
17
|
+
* Create a budget exceeded error object
|
|
18
|
+
*/
|
|
19
|
+
function createBudgetExceededError(estimated, budget) {
|
|
20
|
+
return {
|
|
21
|
+
code: BUDGET_EXCEEDED_CODE,
|
|
22
|
+
message: `Response exceeds declared MVI budget: estimated ${estimated} tokens, budget ${budget} tokens`,
|
|
23
|
+
category: BUDGET_ERROR_CATEGORY,
|
|
24
|
+
retryable: false,
|
|
25
|
+
retryAfterMs: null,
|
|
26
|
+
details: {
|
|
27
|
+
estimatedTokens: estimated,
|
|
28
|
+
budgetTokens: budget,
|
|
29
|
+
exceededBy: estimated - budget,
|
|
30
|
+
exceededByPercent: Math.round(((estimated - budget) / budget) * 100),
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Truncate a result to fit within budget.
|
|
36
|
+
* Returns the truncated result and whether truncation occurred.
|
|
37
|
+
*/
|
|
38
|
+
function truncateResult(result, targetTokens, estimator) {
|
|
39
|
+
if (result === null) {
|
|
40
|
+
return { result: null, wasTruncated: false };
|
|
41
|
+
}
|
|
42
|
+
const currentEstimate = estimator.estimate(result);
|
|
43
|
+
// If already within budget, no truncation needed
|
|
44
|
+
if (currentEstimate <= targetTokens) {
|
|
45
|
+
return { result, wasTruncated: false };
|
|
46
|
+
}
|
|
47
|
+
// Calculate target size (conservative: assume 10% overhead)
|
|
48
|
+
const targetChars = Math.floor(targetTokens * 4 * 0.9);
|
|
49
|
+
if (Array.isArray(result)) {
|
|
50
|
+
return truncateArray(result, targetChars, targetTokens, estimator);
|
|
51
|
+
}
|
|
52
|
+
return truncateObject(result, targetChars, targetTokens, estimator);
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Truncate an array to fit within budget.
|
|
56
|
+
*/
|
|
57
|
+
function truncateArray(arr, targetChars, targetTokens, estimator) {
|
|
58
|
+
if (arr.length === 0) {
|
|
59
|
+
return { result: arr, wasTruncated: false };
|
|
60
|
+
}
|
|
61
|
+
// Binary search to find how many items fit
|
|
62
|
+
let left = 0;
|
|
63
|
+
let right = arr.length;
|
|
64
|
+
let bestFit = 0;
|
|
65
|
+
while (left <= right) {
|
|
66
|
+
const mid = Math.floor((left + right) / 2);
|
|
67
|
+
const subset = arr.slice(0, mid);
|
|
68
|
+
const estimate = estimator.estimate(subset);
|
|
69
|
+
if (estimate <= targetTokens) {
|
|
70
|
+
bestFit = mid;
|
|
71
|
+
left = mid + 1;
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
right = mid - 1;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
// If we can fit all items, no truncation needed
|
|
78
|
+
if (bestFit >= arr.length) {
|
|
79
|
+
return { result: arr, wasTruncated: false };
|
|
80
|
+
}
|
|
81
|
+
// Create truncated result
|
|
82
|
+
const truncated = arr.slice(0, bestFit);
|
|
83
|
+
// If we couldn't fit any items, return minimal response
|
|
84
|
+
if (bestFit === 0 && arr.length > 0) {
|
|
85
|
+
return {
|
|
86
|
+
result: [{ _truncated: true, reason: "budget_exceeded" }],
|
|
87
|
+
wasTruncated: true
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
// Add truncation indicator to last element if it's an object
|
|
91
|
+
if (bestFit > 0 && typeof truncated[bestFit - 1] === 'object' && truncated[bestFit - 1] !== null) {
|
|
92
|
+
const lastItem = truncated[bestFit - 1];
|
|
93
|
+
truncated[bestFit - 1] = {
|
|
94
|
+
...lastItem,
|
|
95
|
+
_truncated: true,
|
|
96
|
+
remainingItems: arr.length - bestFit,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
return { result: truncated, wasTruncated: true };
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Truncate an object to fit within budget.
|
|
103
|
+
*/
|
|
104
|
+
function truncateObject(obj, targetChars, targetTokens, estimator) {
|
|
105
|
+
const keys = Object.keys(obj);
|
|
106
|
+
if (keys.length === 0) {
|
|
107
|
+
return { result: obj, wasTruncated: false };
|
|
108
|
+
}
|
|
109
|
+
// Try to fit as many top-level properties as possible
|
|
110
|
+
let left = 0;
|
|
111
|
+
let right = keys.length;
|
|
112
|
+
let bestFit = 0;
|
|
113
|
+
while (left <= right) {
|
|
114
|
+
const mid = Math.floor((left + right) / 2);
|
|
115
|
+
const subsetKeys = keys.slice(0, mid);
|
|
116
|
+
const subset = {};
|
|
117
|
+
for (const key of subsetKeys) {
|
|
118
|
+
subset[key] = obj[key];
|
|
119
|
+
}
|
|
120
|
+
const estimate = estimator.estimate(subset);
|
|
121
|
+
if (estimate <= targetTokens) {
|
|
122
|
+
bestFit = mid;
|
|
123
|
+
left = mid + 1;
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
right = mid - 1;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
// If we can fit all properties, no truncation needed
|
|
130
|
+
if (bestFit >= keys.length) {
|
|
131
|
+
return { result: obj, wasTruncated: false };
|
|
132
|
+
}
|
|
133
|
+
// Create truncated result
|
|
134
|
+
const subsetKeys = keys.slice(0, bestFit);
|
|
135
|
+
const truncated = {};
|
|
136
|
+
for (const key of subsetKeys) {
|
|
137
|
+
truncated[key] = obj[key];
|
|
138
|
+
}
|
|
139
|
+
// If we couldn't fit any properties, return minimal response
|
|
140
|
+
if (bestFit === 0) {
|
|
141
|
+
return {
|
|
142
|
+
result: { _truncated: true, reason: "budget_exceeded" },
|
|
143
|
+
wasTruncated: true
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
// Add truncation metadata
|
|
147
|
+
truncated._truncated = true;
|
|
148
|
+
truncated._truncatedFields = keys.slice(bestFit);
|
|
149
|
+
return { result: truncated, wasTruncated: true };
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Apply budget enforcement to an envelope.
|
|
153
|
+
*
|
|
154
|
+
* @param envelope - The LAFS envelope to check
|
|
155
|
+
* @param budget - Maximum allowed tokens
|
|
156
|
+
* @param options - Budget enforcement options
|
|
157
|
+
* @returns Enforce result with potentially modified envelope
|
|
158
|
+
*/
|
|
159
|
+
export function applyBudgetEnforcement(envelope, budget, options = {}) {
|
|
160
|
+
const { truncateOnExceed = false, onBudgetExceeded } = options;
|
|
161
|
+
const estimator = new TokenEstimator();
|
|
162
|
+
// Estimate the result payload
|
|
163
|
+
const estimatedTokens = estimator.estimate(envelope.result);
|
|
164
|
+
// Add estimate to metadata
|
|
165
|
+
const tokenEstimate = {
|
|
166
|
+
estimated: estimatedTokens,
|
|
167
|
+
};
|
|
168
|
+
// Check if within budget
|
|
169
|
+
const withinBudget = estimatedTokens <= budget;
|
|
170
|
+
// If within budget, just add the estimate to metadata
|
|
171
|
+
if (withinBudget) {
|
|
172
|
+
return {
|
|
173
|
+
envelope: {
|
|
174
|
+
...envelope,
|
|
175
|
+
_meta: {
|
|
176
|
+
...envelope._meta,
|
|
177
|
+
_tokenEstimate: tokenEstimate,
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
withinBudget: true,
|
|
181
|
+
estimatedTokens,
|
|
182
|
+
budget,
|
|
183
|
+
truncated: false,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
// Budget exceeded - call callback if provided
|
|
187
|
+
if (onBudgetExceeded) {
|
|
188
|
+
onBudgetExceeded(estimatedTokens, budget);
|
|
189
|
+
}
|
|
190
|
+
// If truncation is enabled, try to truncate
|
|
191
|
+
if (truncateOnExceed) {
|
|
192
|
+
const { result, wasTruncated } = truncateResult(envelope.result, budget, estimator);
|
|
193
|
+
const truncatedEstimate = estimator.estimate(result);
|
|
194
|
+
if (truncatedEstimate <= budget) {
|
|
195
|
+
return {
|
|
196
|
+
envelope: {
|
|
197
|
+
...envelope,
|
|
198
|
+
result,
|
|
199
|
+
_meta: {
|
|
200
|
+
...envelope._meta,
|
|
201
|
+
_tokenEstimate: {
|
|
202
|
+
estimated: truncatedEstimate,
|
|
203
|
+
truncated: true,
|
|
204
|
+
originalEstimate: estimatedTokens,
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
},
|
|
208
|
+
withinBudget: true,
|
|
209
|
+
estimatedTokens: truncatedEstimate,
|
|
210
|
+
budget,
|
|
211
|
+
truncated: true,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
// Return budget exceeded error
|
|
216
|
+
return {
|
|
217
|
+
envelope: {
|
|
218
|
+
...envelope,
|
|
219
|
+
success: false,
|
|
220
|
+
result: null,
|
|
221
|
+
error: createBudgetExceededError(estimatedTokens, budget),
|
|
222
|
+
_meta: {
|
|
223
|
+
...envelope._meta,
|
|
224
|
+
_tokenEstimate: tokenEstimate,
|
|
225
|
+
},
|
|
226
|
+
},
|
|
227
|
+
withinBudget: false,
|
|
228
|
+
estimatedTokens,
|
|
229
|
+
budget,
|
|
230
|
+
truncated: false,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Create a budget enforcement middleware function.
|
|
235
|
+
*
|
|
236
|
+
* @param budget - Maximum allowed tokens for response
|
|
237
|
+
* @param options - Budget enforcement options
|
|
238
|
+
* @returns Middleware function that enforces budget
|
|
239
|
+
*
|
|
240
|
+
* @example
|
|
241
|
+
* ```typescript
|
|
242
|
+
* const middleware = withBudget(1000, { truncateOnExceed: true });
|
|
243
|
+
* const result = await middleware(envelope, async () => nextEnvelope);
|
|
244
|
+
* ```
|
|
245
|
+
*/
|
|
246
|
+
export function withBudget(budget, options = {}) {
|
|
247
|
+
return async (envelope, next) => {
|
|
248
|
+
// Execute next middleware/handler
|
|
249
|
+
const result = await next();
|
|
250
|
+
// Apply budget enforcement to the result
|
|
251
|
+
const enforcement = applyBudgetEnforcement(result, budget, options);
|
|
252
|
+
return enforcement.envelope;
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Check if an envelope has exceeded its budget without modifying it.
|
|
257
|
+
*
|
|
258
|
+
* @param envelope - The LAFS envelope to check
|
|
259
|
+
* @param budget - Maximum allowed tokens
|
|
260
|
+
* @returns Budget check result
|
|
261
|
+
*/
|
|
262
|
+
export function checkBudget(envelope, budget) {
|
|
263
|
+
const estimator = new TokenEstimator();
|
|
264
|
+
const estimated = estimator.estimate(envelope.result);
|
|
265
|
+
return {
|
|
266
|
+
exceeded: estimated > budget,
|
|
267
|
+
estimated,
|
|
268
|
+
remaining: Math.max(0, budget - estimated),
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Synchronous version of withBudget for non-async contexts.
|
|
273
|
+
*
|
|
274
|
+
* @param budget - Maximum allowed tokens for response
|
|
275
|
+
* @param options - Budget enforcement options
|
|
276
|
+
* @returns Middleware function that enforces budget synchronously
|
|
277
|
+
*/
|
|
278
|
+
export function withBudgetSync(budget, options = {}) {
|
|
279
|
+
return (envelope, next) => {
|
|
280
|
+
const result = next();
|
|
281
|
+
const enforcement = applyBudgetEnforcement(result, budget, options);
|
|
282
|
+
return enforcement.envelope;
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Higher-order function that wraps a handler with budget enforcement.
|
|
287
|
+
*
|
|
288
|
+
* @param handler - The handler function to wrap
|
|
289
|
+
* @param budget - Maximum allowed tokens
|
|
290
|
+
* @param options - Budget enforcement options
|
|
291
|
+
* @returns Wrapped handler with budget enforcement
|
|
292
|
+
*
|
|
293
|
+
* @example
|
|
294
|
+
* ```typescript
|
|
295
|
+
* const myHandler = async (request: Request) => ({ success: true, result: { data } });
|
|
296
|
+
* const budgetedHandler = wrapWithBudget(myHandler, 1000, { truncateOnExceed: true });
|
|
297
|
+
* const result = await budgetedHandler(request);
|
|
298
|
+
* ```
|
|
299
|
+
*/
|
|
300
|
+
export function wrapWithBudget(handler, budget, options = {}) {
|
|
301
|
+
return async (...args) => {
|
|
302
|
+
const result = await handler(...args);
|
|
303
|
+
const enforcement = applyBudgetEnforcement(result, budget, options);
|
|
304
|
+
return enforcement.envelope;
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* Compose multiple middleware functions into a single middleware.
|
|
309
|
+
* Middleware is executed in order (left to right).
|
|
310
|
+
*/
|
|
311
|
+
export function composeMiddleware(...middlewares) {
|
|
312
|
+
return async (envelope, next) => {
|
|
313
|
+
let index = 0;
|
|
314
|
+
async function dispatch(i) {
|
|
315
|
+
if (i >= middlewares.length) {
|
|
316
|
+
return next();
|
|
317
|
+
}
|
|
318
|
+
const middleware = middlewares[i];
|
|
319
|
+
if (!middleware) {
|
|
320
|
+
return dispatch(i + 1);
|
|
321
|
+
}
|
|
322
|
+
return middleware(envelope, () => dispatch(i + 1));
|
|
323
|
+
}
|
|
324
|
+
return dispatch(0);
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
export { TokenEstimator };
|
|
328
|
+
export { BUDGET_EXCEEDED_CODE };
|
package/dist/src/cli.d.ts
CHANGED
|
@@ -1,2 +1,16 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* LAFS Conformance CLI — diagnostic/human-readable tool.
|
|
4
|
+
*
|
|
5
|
+
* This CLI is a **diagnostic utility** that validates envelopes and flags
|
|
6
|
+
* against the LAFS schema and conformance checks. It is NOT itself a
|
|
7
|
+
* LAFS-conformant envelope producer. Its output is for human consumption
|
|
8
|
+
* and CI pipelines, not for machine-to-machine chaining.
|
|
9
|
+
*
|
|
10
|
+
* Exemption: The CLI is exempt from LAFS envelope conformance requirements.
|
|
11
|
+
* Its output format is not a LAFS envelope and MUST NOT be validated as one.
|
|
12
|
+
*
|
|
13
|
+
* @task T042
|
|
14
|
+
* @epic T034
|
|
15
|
+
*/
|
|
2
16
|
export {};
|
package/dist/src/cli.js
CHANGED
|
@@ -1,4 +1,18 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* LAFS Conformance CLI — diagnostic/human-readable tool.
|
|
4
|
+
*
|
|
5
|
+
* This CLI is a **diagnostic utility** that validates envelopes and flags
|
|
6
|
+
* against the LAFS schema and conformance checks. It is NOT itself a
|
|
7
|
+
* LAFS-conformant envelope producer. Its output is for human consumption
|
|
8
|
+
* and CI pipelines, not for machine-to-machine chaining.
|
|
9
|
+
*
|
|
10
|
+
* Exemption: The CLI is exempt from LAFS envelope conformance requirements.
|
|
11
|
+
* Its output format is not a LAFS envelope and MUST NOT be validated as one.
|
|
12
|
+
*
|
|
13
|
+
* @task T042
|
|
14
|
+
* @epic T034
|
|
15
|
+
*/
|
|
2
16
|
import { readFile } from "node:fs/promises";
|
|
3
17
|
import { runEnvelopeConformance, runFlagConformance } from "./conformance.js";
|
|
4
18
|
function parseArgs(argv) {
|
package/dist/src/conformance.js
CHANGED
|
@@ -12,17 +12,79 @@ export function runEnvelopeConformance(envelope) {
|
|
|
12
12
|
return { ok: false, checks };
|
|
13
13
|
}
|
|
14
14
|
const typed = envelope;
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
// envelope_invariants: success=true allows error to be null OR omitted;
|
|
16
|
+
// success=false requires error to be a non-null object and result===null.
|
|
17
|
+
const invariant = typed.success
|
|
18
|
+
? typed.error == null // null or undefined (omitted) both valid for success
|
|
19
|
+
: typed.result === null && typed.error != null;
|
|
20
|
+
pushCheck(checks, "envelope_invariants", invariant, invariant
|
|
21
|
+
? undefined
|
|
22
|
+
: typed.success
|
|
23
|
+
? "success=true but error is present and non-null"
|
|
24
|
+
: "success=false requires result===null and error to be a non-null object");
|
|
25
|
+
// error_code_registered: only checked when error is present (error is optional when success=true)
|
|
17
26
|
if (typed.error) {
|
|
18
27
|
const registered = isRegisteredErrorCode(typed.error.code);
|
|
19
28
|
pushCheck(checks, "error_code_registered", registered, registered ? undefined : `unregistered code: ${typed.error.code}`);
|
|
20
29
|
}
|
|
21
30
|
else {
|
|
22
|
-
pushCheck(checks, "error_code_registered", true);
|
|
31
|
+
pushCheck(checks, "error_code_registered", true, "error field absent or null — skipped (optional when success=true)");
|
|
23
32
|
}
|
|
24
|
-
|
|
33
|
+
const validMviLevels = ["minimal", "standard", "full", "custom"];
|
|
34
|
+
pushCheck(checks, "meta_mvi_present", validMviLevels.includes(typed._meta.mvi), validMviLevels.includes(typed._meta.mvi) ? undefined : `invalid mvi level: ${String(typed._meta.mvi)}`);
|
|
25
35
|
pushCheck(checks, "meta_strict_present", typeof typed._meta.strict === "boolean");
|
|
36
|
+
// strict_mode_behavior: when strict=true, the envelope MUST NOT contain
|
|
37
|
+
// explicit null for optional fields that can be omitted (page, error on success).
|
|
38
|
+
if (typed._meta.strict) {
|
|
39
|
+
const obj = envelope;
|
|
40
|
+
const hasExplicitNullError = typed.success && "error" in obj && obj["error"] === null;
|
|
41
|
+
const hasExplicitNullPage = "page" in obj && obj["page"] === null;
|
|
42
|
+
const strictClean = !hasExplicitNullError && !hasExplicitNullPage;
|
|
43
|
+
pushCheck(checks, "strict_mode_behavior", strictClean, strictClean
|
|
44
|
+
? undefined
|
|
45
|
+
: "strict mode: optional fields should be omitted rather than set to null");
|
|
46
|
+
}
|
|
47
|
+
// pagination_mode_consistent: when page is present and is an object, verify
|
|
48
|
+
// that the fields present match the declared pagination mode.
|
|
49
|
+
if (typed.page && typeof typed.page === "object") {
|
|
50
|
+
const page = typed.page;
|
|
51
|
+
const mode = page["mode"];
|
|
52
|
+
let consistent = true;
|
|
53
|
+
let detail;
|
|
54
|
+
if (mode === "cursor") {
|
|
55
|
+
if (page["offset"] !== undefined) {
|
|
56
|
+
consistent = false;
|
|
57
|
+
detail = "cursor mode should not include offset field";
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
else if (mode === "offset") {
|
|
61
|
+
if (page["nextCursor"] !== undefined) {
|
|
62
|
+
consistent = false;
|
|
63
|
+
detail = "offset mode should not include nextCursor field";
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
else if (mode === "none") {
|
|
67
|
+
const extraFields = Object.keys(page).filter((k) => k !== "mode");
|
|
68
|
+
if (extraFields.length > 0) {
|
|
69
|
+
consistent = false;
|
|
70
|
+
detail = `none mode should only have mode field, found: ${extraFields.join(", ")}`;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
pushCheck(checks, "pagination_mode_consistent", consistent, consistent ? undefined : detail);
|
|
74
|
+
}
|
|
75
|
+
// strict_mode_enforced: verify the schema enforces additional-property rules.
|
|
76
|
+
// When strict=true, extra top-level properties must be rejected by validation.
|
|
77
|
+
// When strict=false, extra top-level properties must be allowed.
|
|
78
|
+
{
|
|
79
|
+
const extraPropEnvelope = { ...envelope, _unknown_extra: true };
|
|
80
|
+
const extraResult = validateEnvelope(extraPropEnvelope);
|
|
81
|
+
if (typed._meta.strict) {
|
|
82
|
+
pushCheck(checks, "strict_mode_enforced", !extraResult.valid, extraResult.valid ? "strict=true but additional properties were accepted" : undefined);
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
pushCheck(checks, "strict_mode_enforced", extraResult.valid, !extraResult.valid ? "strict=false but additional properties were rejected" : undefined);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
26
88
|
return { ok: checks.every((check) => check.pass), checks };
|
|
27
89
|
}
|
|
28
90
|
export function runFlagConformance(flags) {
|
|
@@ -30,13 +92,24 @@ export function runFlagConformance(flags) {
|
|
|
30
92
|
try {
|
|
31
93
|
const resolved = resolveOutputFormat(flags);
|
|
32
94
|
pushCheck(checks, "flag_conflict_rejected", !(flags.humanFlag && flags.jsonFlag));
|
|
33
|
-
|
|
95
|
+
// Protocol-default check: when nothing is specified (source === "default"),
|
|
96
|
+
// the protocol requires JSON as the default format.
|
|
97
|
+
const isProtocolDefault = resolved.source === "default";
|
|
98
|
+
pushCheck(checks, "json_protocol_default", !isProtocolDefault || resolved.format === "json", isProtocolDefault && resolved.format !== "json"
|
|
99
|
+
? `protocol default should be json, got ${resolved.format}`
|
|
100
|
+
: undefined);
|
|
101
|
+
// Config-override check: when a project or user default is active,
|
|
102
|
+
// the resolved format must match the config-provided value.
|
|
103
|
+
const hasConfigOverride = resolved.source === "project" || resolved.source === "user";
|
|
104
|
+
const expectedOverride = resolved.source === "project" ? flags.projectDefault : flags.userDefault;
|
|
105
|
+
pushCheck(checks, "config_override_respected", !hasConfigOverride || resolved.format === expectedOverride, hasConfigOverride && resolved.format !== expectedOverride
|
|
106
|
+
? `config override expected ${String(expectedOverride)}, got ${resolved.format}`
|
|
107
|
+
: undefined);
|
|
34
108
|
}
|
|
35
109
|
catch (error) {
|
|
36
110
|
if (error instanceof LAFSFlagError && error.code === "E_FORMAT_CONFLICT") {
|
|
37
111
|
pushCheck(checks, "flag_conflict_rejected", true);
|
|
38
|
-
|
|
39
|
-
return { ok: true, checks };
|
|
112
|
+
return { ok: checks.every((check) => check.pass), checks };
|
|
40
113
|
}
|
|
41
114
|
pushCheck(checks, "flag_resolution", false, error instanceof Error ? error.message : String(error));
|
|
42
115
|
return { ok: false, checks };
|
|
@@ -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;
|