borgmcp-shared 0.2.2
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/CONTRIBUTING.md +40 -0
- package/LICENSE +201 -0
- package/NOTICE +2 -0
- package/README.md +159 -0
- package/SECURITY.md +25 -0
- package/dist/conformance/adapter.d.ts +112 -0
- package/dist/conformance/adapter.d.ts.map +1 -0
- package/dist/conformance/adapter.js +417 -0
- package/dist/conformance/adapter.js.map +1 -0
- package/dist/conformance/index.d.ts +15 -0
- package/dist/conformance/index.d.ts.map +1 -0
- package/dist/conformance/index.js +49 -0
- package/dist/conformance/index.js.map +1 -0
- package/dist/domain/index.d.ts +5 -0
- package/dist/domain/index.d.ts.map +1 -0
- package/dist/domain/index.js +4 -0
- package/dist/domain/index.js.map +1 -0
- package/dist/drone-address.d.ts +3 -0
- package/dist/drone-address.d.ts.map +1 -0
- package/dist/drone-address.js +7 -0
- package/dist/drone-address.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -0
- package/dist/log-stream-hwm.d.ts +6 -0
- package/dist/log-stream-hwm.d.ts.map +1 -0
- package/dist/log-stream-hwm.js +12 -0
- package/dist/log-stream-hwm.js.map +1 -0
- package/dist/protocol/contract.d.ts +127 -0
- package/dist/protocol/contract.d.ts.map +1 -0
- package/dist/protocol/contract.js +434 -0
- package/dist/protocol/contract.js.map +1 -0
- package/dist/protocol/coordination.d.ts +38 -0
- package/dist/protocol/coordination.d.ts.map +1 -0
- package/dist/protocol/coordination.js +176 -0
- package/dist/protocol/coordination.js.map +1 -0
- package/dist/protocol/errors.d.ts +33 -0
- package/dist/protocol/errors.d.ts.map +1 -0
- package/dist/protocol/errors.js +26 -0
- package/dist/protocol/errors.js.map +1 -0
- package/dist/protocol/index.d.ts +8 -0
- package/dist/protocol/index.d.ts.map +1 -0
- package/dist/protocol/index.js +7 -0
- package/dist/protocol/index.js.map +1 -0
- package/dist/protocol/sse.d.ts +44 -0
- package/dist/protocol/sse.d.ts.map +1 -0
- package/dist/protocol/sse.js +246 -0
- package/dist/protocol/sse.js.map +1 -0
- package/dist/protocol/types.d.ts +174 -0
- package/dist/protocol/types.d.ts.map +1 -0
- package/dist/protocol/types.js +2 -0
- package/dist/protocol/types.js.map +1 -0
- package/dist/protocol/version.d.ts +11 -0
- package/dist/protocol/version.d.ts.map +1 -0
- package/dist/protocol/version.js +14 -0
- package/dist/protocol/version.js.map +1 -0
- package/dist/role-section.d.ts +24 -0
- package/dist/role-section.d.ts.map +1 -0
- package/dist/role-section.js +114 -0
- package/dist/role-section.js.map +1 -0
- package/dist/templates.d.ts +44 -0
- package/dist/templates.d.ts.map +1 -0
- package/dist/templates.js +771 -0
- package/dist/templates.js.map +1 -0
- package/docs/compatibility.md +38 -0
- package/docs/releasing.md +220 -0
- package/package.json +89 -0
- package/src/conformance/adapter.ts +717 -0
- package/src/conformance/index.ts +68 -0
- package/src/domain/index.ts +9 -0
- package/src/drone-address.ts +25 -0
- package/src/index.ts +4 -0
- package/src/log-stream-hwm.ts +16 -0
- package/src/protocol/contract.ts +608 -0
- package/src/protocol/coordination.ts +249 -0
- package/src/protocol/errors.ts +36 -0
- package/src/protocol/index.ts +7 -0
- package/src/protocol/sse.ts +295 -0
- package/src/protocol/types.ts +201 -0
- package/src/protocol/version.ts +31 -0
- package/src/role-section.ts +270 -0
- package/src/templates.ts +953 -0
|
@@ -0,0 +1,608 @@
|
|
|
1
|
+
import { ErrorCode } from './errors.js';
|
|
2
|
+
import { PROTOCOL_VERSION, type ProtocolVersion } from './version.js';
|
|
3
|
+
|
|
4
|
+
export const SHARED_PACKAGE_NAME = 'borgmcp-shared' as const;
|
|
5
|
+
export const SHARED_PACKAGE_VERSION = '0.2.2' as const;
|
|
6
|
+
|
|
7
|
+
export const HEALTH_PATH = '/healthz' as const;
|
|
8
|
+
export const PROTOCOL_INFO_PATH = '/api/protocol' as const;
|
|
9
|
+
export const ENROLLMENT_EXCHANGE_PATH = '/api/enrollment/exchange' as const;
|
|
10
|
+
|
|
11
|
+
export const PROTOCOL_HTTP_CONTRACT = {
|
|
12
|
+
health: { method: 'GET', path: HEALTH_PATH, authenticated: false, success_status: 204, bodyless: true },
|
|
13
|
+
protocol: { method: 'GET', path: PROTOCOL_INFO_PATH, authenticated: true, success_status: 200 },
|
|
14
|
+
enrollment: { method: 'POST', path: ENROLLMENT_EXCHANGE_PATH, authenticated: 'invitation', success_status: 201 },
|
|
15
|
+
auth_missing_status: 401,
|
|
16
|
+
auth_invalid_status: 401,
|
|
17
|
+
cursor_expired_status: 410,
|
|
18
|
+
content_too_large_status: 413,
|
|
19
|
+
unsupported_protocol_status: 426,
|
|
20
|
+
unsupported_capability_status: 501,
|
|
21
|
+
redirect_policy: 'error',
|
|
22
|
+
} as const;
|
|
23
|
+
|
|
24
|
+
export const PROTOCOL_LIMIT_CEILINGS = {
|
|
25
|
+
max_request_bytes: 10 * 1024 * 1024,
|
|
26
|
+
max_log_message_bytes: 1024 * 1024,
|
|
27
|
+
max_read_page_size: 500,
|
|
28
|
+
max_replay_page_size: 1000,
|
|
29
|
+
} as const;
|
|
30
|
+
|
|
31
|
+
export const KNOWN_CAPABILITIES = [
|
|
32
|
+
'coordination.core',
|
|
33
|
+
'auth.bearer',
|
|
34
|
+
'auth.revocation',
|
|
35
|
+
'scope.cube-isolation',
|
|
36
|
+
'transport.tls',
|
|
37
|
+
'authority.no-cloud-fallback',
|
|
38
|
+
'log.cursor',
|
|
39
|
+
'stream.sse',
|
|
40
|
+
'stream.replay',
|
|
41
|
+
'acks',
|
|
42
|
+
'claims',
|
|
43
|
+
'decisions',
|
|
44
|
+
] as const;
|
|
45
|
+
|
|
46
|
+
export type KnownCapability = (typeof KNOWN_CAPABILITIES)[number];
|
|
47
|
+
export type Capability = KnownCapability | (string & {});
|
|
48
|
+
|
|
49
|
+
export const REQUIRED_SECURITY_CAPABILITIES = [
|
|
50
|
+
'auth.bearer',
|
|
51
|
+
'auth.revocation',
|
|
52
|
+
'scope.cube-isolation',
|
|
53
|
+
'transport.tls',
|
|
54
|
+
'authority.no-cloud-fallback',
|
|
55
|
+
] as const satisfies readonly Capability[];
|
|
56
|
+
|
|
57
|
+
export interface ProtocolLimits {
|
|
58
|
+
max_request_bytes: number;
|
|
59
|
+
max_log_message_bytes: number;
|
|
60
|
+
max_read_page_size: number;
|
|
61
|
+
max_replay_page_size: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface ProtocolInfo {
|
|
65
|
+
protocol_version: ProtocolVersion;
|
|
66
|
+
package: {
|
|
67
|
+
name: typeof SHARED_PACKAGE_NAME;
|
|
68
|
+
version: string;
|
|
69
|
+
};
|
|
70
|
+
capabilities: Capability[];
|
|
71
|
+
limits: ProtocolLimits;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface ProtocolEnvelope<T> {
|
|
75
|
+
protocol_version: ProtocolVersion;
|
|
76
|
+
request_id: string;
|
|
77
|
+
payload: T;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface ProtocolErrorEnvelope {
|
|
81
|
+
protocol_version: ProtocolVersion;
|
|
82
|
+
request_id?: string;
|
|
83
|
+
error: {
|
|
84
|
+
code: ErrorCode;
|
|
85
|
+
message: string;
|
|
86
|
+
details?: string;
|
|
87
|
+
retry_after?: number;
|
|
88
|
+
required_capability?: string;
|
|
89
|
+
supported_versions?: readonly string[];
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Invitation and returned bearer values are opaque secrets, never identifiers. */
|
|
94
|
+
export interface EnrollmentExchangeRequest {
|
|
95
|
+
invitation: string;
|
|
96
|
+
client_name?: string;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The credential is returned once and must not be persisted by server adapters. */
|
|
100
|
+
export interface EnrollmentExchangeResponse {
|
|
101
|
+
client_id: string;
|
|
102
|
+
credential: string;
|
|
103
|
+
credential_expires_at?: string | null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface AckLogRequest {
|
|
107
|
+
entry_id: string;
|
|
108
|
+
kind: 'ack' | 'claim';
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export type RemoveDecisionRequest =
|
|
112
|
+
| { topic: string }
|
|
113
|
+
| { decision_id: string };
|
|
114
|
+
|
|
115
|
+
export interface LogCursor {
|
|
116
|
+
id: string;
|
|
117
|
+
created_at: string;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export class ProtocolContractError extends Error {
|
|
121
|
+
constructor(
|
|
122
|
+
message: string,
|
|
123
|
+
public readonly code: ErrorCode = ErrorCode.INVALID_INPUT,
|
|
124
|
+
public readonly path: readonly (string | number)[] = [],
|
|
125
|
+
) {
|
|
126
|
+
super(message);
|
|
127
|
+
this.name = 'ProtocolContractError';
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function fail(message: string, path: readonly (string | number)[] = []): never {
|
|
132
|
+
throw new ProtocolContractError(message, ErrorCode.INVALID_INPUT, path);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function record(value: unknown, path: readonly (string | number)[] = []): Record<string, unknown> {
|
|
136
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
137
|
+
fail('Expected an object.', path);
|
|
138
|
+
}
|
|
139
|
+
return value as Record<string, unknown>;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function exactKeys(
|
|
143
|
+
value: Record<string, unknown>,
|
|
144
|
+
allowed: readonly string[],
|
|
145
|
+
required: readonly string[],
|
|
146
|
+
path: readonly (string | number)[] = [],
|
|
147
|
+
): void {
|
|
148
|
+
for (const key of Object.keys(value)) {
|
|
149
|
+
if (!allowed.includes(key)) fail(`Unknown field "${key}".`, [...path, key]);
|
|
150
|
+
}
|
|
151
|
+
for (const key of required) {
|
|
152
|
+
if (!Object.prototype.hasOwnProperty.call(value, key)) {
|
|
153
|
+
fail(`Missing field "${key}".`, [...path, key]);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function boundedString(
|
|
159
|
+
value: unknown,
|
|
160
|
+
min: number,
|
|
161
|
+
max: number,
|
|
162
|
+
path: readonly (string | number)[],
|
|
163
|
+
): string {
|
|
164
|
+
if (typeof value !== 'string') fail('Expected a string.', path);
|
|
165
|
+
const bytes = utf8ByteLength(value);
|
|
166
|
+
if (bytes < min || bytes > max) {
|
|
167
|
+
fail(`Expected a string between ${min} and ${max} UTF-8 bytes.`, path);
|
|
168
|
+
}
|
|
169
|
+
return value;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function utf8ByteLength(value: string): number {
|
|
173
|
+
let bytes = 0;
|
|
174
|
+
for (let index = 0; index < value.length; index++) {
|
|
175
|
+
const code = value.charCodeAt(index);
|
|
176
|
+
if (code <= 0x7f) bytes += 1;
|
|
177
|
+
else if (code <= 0x7ff) bytes += 2;
|
|
178
|
+
else if (code >= 0xd800 && code <= 0xdbff && index + 1 < value.length &&
|
|
179
|
+
value.charCodeAt(index + 1) >= 0xdc00 && value.charCodeAt(index + 1) <= 0xdfff) {
|
|
180
|
+
bytes += 4;
|
|
181
|
+
index++;
|
|
182
|
+
} else bytes += 3;
|
|
183
|
+
}
|
|
184
|
+
return bytes;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function isSemanticVersion(value: string): boolean {
|
|
188
|
+
const match = value.match(
|
|
189
|
+
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/,
|
|
190
|
+
);
|
|
191
|
+
if (!match) return false;
|
|
192
|
+
const prerelease = match[4];
|
|
193
|
+
return prerelease === undefined || prerelease.split('.').every((identifier) =>
|
|
194
|
+
!/^\d+$/.test(identifier) || identifier === '0' || !identifier.startsWith('0')
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function boundedPositiveInteger(
|
|
199
|
+
value: unknown,
|
|
200
|
+
maximum: number,
|
|
201
|
+
path: readonly (string | number)[],
|
|
202
|
+
): number {
|
|
203
|
+
if (!Number.isSafeInteger(value) || (value as number) <= 0 || (value as number) > maximum) {
|
|
204
|
+
fail(`Expected a positive safe integer no greater than ${maximum}.`, path);
|
|
205
|
+
}
|
|
206
|
+
return value as number;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function opaqueIdentifier(value: unknown, path: readonly (string | number)[]): string {
|
|
210
|
+
const identifier = boundedString(value, 1, 128, path);
|
|
211
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._:-]*$/.test(identifier)) {
|
|
212
|
+
fail('Expected a URL-safe opaque identifier.', path);
|
|
213
|
+
}
|
|
214
|
+
return identifier;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function opaqueToken(value: unknown, path: readonly (string | number)[]): string {
|
|
218
|
+
const token = boundedString(value, 43, 1024, path);
|
|
219
|
+
if (!/^[A-Za-z0-9_-]+$/.test(token)) {
|
|
220
|
+
fail('Expected an unpadded base64url token.', path);
|
|
221
|
+
}
|
|
222
|
+
return token;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function decodeRequestId(value: unknown, path: readonly (string | number)[]): string {
|
|
226
|
+
const decoded = boundedString(value, 8, 128, path);
|
|
227
|
+
if (!/^[A-Za-z0-9._-]+$/.test(decoded)) {
|
|
228
|
+
fail('Request id contains unsupported characters.', path);
|
|
229
|
+
}
|
|
230
|
+
return decoded;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function capabilityName(value: unknown, path: readonly (string | number)[]): string {
|
|
234
|
+
const decoded = boundedString(value, 1, 64, path);
|
|
235
|
+
if (!/^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$/.test(decoded)) {
|
|
236
|
+
fail('Capability name contains unsupported characters.', path);
|
|
237
|
+
}
|
|
238
|
+
return decoded;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export function decodeProtocolInfo(value: unknown): ProtocolInfo {
|
|
242
|
+
const input = record(value);
|
|
243
|
+
exactKeys(input, ['protocol_version', 'package', 'capabilities', 'limits'], [
|
|
244
|
+
'protocol_version',
|
|
245
|
+
'package',
|
|
246
|
+
'capabilities',
|
|
247
|
+
'limits',
|
|
248
|
+
]);
|
|
249
|
+
if (input.protocol_version !== PROTOCOL_VERSION) {
|
|
250
|
+
throw new ProtocolContractError(
|
|
251
|
+
`Unsupported protocol version "${String(input.protocol_version)}".`,
|
|
252
|
+
ErrorCode.UNSUPPORTED_PROTOCOL_VERSION,
|
|
253
|
+
['protocol_version'],
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const packageInfo = record(input.package, ['package']);
|
|
258
|
+
exactKeys(packageInfo, ['name', 'version'], ['name', 'version'], ['package']);
|
|
259
|
+
if (packageInfo.name !== SHARED_PACKAGE_NAME) {
|
|
260
|
+
fail(`Expected package name "${SHARED_PACKAGE_NAME}".`, ['package', 'name']);
|
|
261
|
+
}
|
|
262
|
+
const packageVersion = boundedString(packageInfo.version, 5, 64, ['package', 'version']);
|
|
263
|
+
if (!isSemanticVersion(packageVersion)) {
|
|
264
|
+
fail('Expected a semantic package version.', ['package', 'version']);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (!Array.isArray(input.capabilities)) fail('Expected an array.', ['capabilities']);
|
|
268
|
+
const capabilities = input.capabilities.map((capability, index) => {
|
|
269
|
+
return capabilityName(capability, ['capabilities', index]) as Capability;
|
|
270
|
+
});
|
|
271
|
+
if (new Set(capabilities).size !== capabilities.length) {
|
|
272
|
+
fail('Capabilities must be unique.', ['capabilities']);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const limits = record(input.limits, ['limits']);
|
|
276
|
+
exactKeys(
|
|
277
|
+
limits,
|
|
278
|
+
[
|
|
279
|
+
'max_request_bytes',
|
|
280
|
+
'max_log_message_bytes',
|
|
281
|
+
'max_read_page_size',
|
|
282
|
+
'max_replay_page_size',
|
|
283
|
+
],
|
|
284
|
+
[
|
|
285
|
+
'max_request_bytes',
|
|
286
|
+
'max_log_message_bytes',
|
|
287
|
+
'max_read_page_size',
|
|
288
|
+
'max_replay_page_size',
|
|
289
|
+
],
|
|
290
|
+
['limits'],
|
|
291
|
+
);
|
|
292
|
+
|
|
293
|
+
return {
|
|
294
|
+
protocol_version: PROTOCOL_VERSION,
|
|
295
|
+
package: { name: SHARED_PACKAGE_NAME, version: packageVersion },
|
|
296
|
+
capabilities,
|
|
297
|
+
limits: {
|
|
298
|
+
max_request_bytes: boundedPositiveInteger(limits.max_request_bytes, PROTOCOL_LIMIT_CEILINGS.max_request_bytes, ['limits', 'max_request_bytes']),
|
|
299
|
+
max_log_message_bytes: boundedPositiveInteger(limits.max_log_message_bytes, PROTOCOL_LIMIT_CEILINGS.max_log_message_bytes, ['limits', 'max_log_message_bytes']),
|
|
300
|
+
max_read_page_size: boundedPositiveInteger(limits.max_read_page_size, PROTOCOL_LIMIT_CEILINGS.max_read_page_size, ['limits', 'max_read_page_size']),
|
|
301
|
+
max_replay_page_size: boundedPositiveInteger(limits.max_replay_page_size, PROTOCOL_LIMIT_CEILINGS.max_replay_page_size, ['limits', 'max_replay_page_size']),
|
|
302
|
+
},
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export function negotiateProtocol(
|
|
307
|
+
value: unknown,
|
|
308
|
+
requiredCapabilities: readonly Capability[] = [],
|
|
309
|
+
): ProtocolInfo {
|
|
310
|
+
const info = decodeProtocolInfo(value);
|
|
311
|
+
const required = [...REQUIRED_SECURITY_CAPABILITIES, ...requiredCapabilities];
|
|
312
|
+
for (const capability of new Set(required)) {
|
|
313
|
+
if (!info.capabilities.includes(capability)) {
|
|
314
|
+
throw new ProtocolContractError(
|
|
315
|
+
`Required capability "${capability}" is unavailable.`,
|
|
316
|
+
ErrorCode.UNSUPPORTED_CAPABILITY,
|
|
317
|
+
['capabilities'],
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
return info;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export function createProtocolEnvelope<T>(requestId: string, payload: T): ProtocolEnvelope<T> {
|
|
325
|
+
return {
|
|
326
|
+
protocol_version: PROTOCOL_VERSION,
|
|
327
|
+
request_id: decodeRequestId(requestId, ['request_id']),
|
|
328
|
+
payload,
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export function decodeProtocolEnvelope<T>(
|
|
333
|
+
value: unknown,
|
|
334
|
+
decodePayload: (payload: unknown) => T,
|
|
335
|
+
): ProtocolEnvelope<T> {
|
|
336
|
+
const input = record(value);
|
|
337
|
+
exactKeys(input, ['protocol_version', 'request_id', 'payload'], [
|
|
338
|
+
'protocol_version',
|
|
339
|
+
'request_id',
|
|
340
|
+
'payload',
|
|
341
|
+
]);
|
|
342
|
+
if (input.protocol_version !== PROTOCOL_VERSION) {
|
|
343
|
+
throw new ProtocolContractError(
|
|
344
|
+
`Unsupported protocol version "${String(input.protocol_version)}".`,
|
|
345
|
+
ErrorCode.UNSUPPORTED_PROTOCOL_VERSION,
|
|
346
|
+
['protocol_version'],
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
const decodedRequestId = decodeRequestId(input.request_id, ['request_id']);
|
|
350
|
+
return {
|
|
351
|
+
protocol_version: PROTOCOL_VERSION,
|
|
352
|
+
request_id: decodedRequestId,
|
|
353
|
+
payload: decodePayload(input.payload),
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
export function decodeProtocolInfoEnvelope(value: unknown): ProtocolEnvelope<ProtocolInfo> {
|
|
358
|
+
return decodeProtocolEnvelope(value, decodeProtocolInfo);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
export function decodeProtocolErrorEnvelope(value: unknown): ProtocolErrorEnvelope {
|
|
362
|
+
const input = record(value);
|
|
363
|
+
exactKeys(input, ['protocol_version', 'request_id', 'error'], ['protocol_version', 'error']);
|
|
364
|
+
if (input.protocol_version !== PROTOCOL_VERSION) {
|
|
365
|
+
throw new ProtocolContractError(
|
|
366
|
+
`Unsupported protocol version "${String(input.protocol_version)}".`,
|
|
367
|
+
ErrorCode.UNSUPPORTED_PROTOCOL_VERSION,
|
|
368
|
+
['protocol_version'],
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
const error = record(input.error, ['error']);
|
|
372
|
+
exactKeys(
|
|
373
|
+
error,
|
|
374
|
+
[
|
|
375
|
+
'code',
|
|
376
|
+
'message',
|
|
377
|
+
'details',
|
|
378
|
+
'retry_after',
|
|
379
|
+
'required_capability',
|
|
380
|
+
'supported_versions',
|
|
381
|
+
],
|
|
382
|
+
['code', 'message'],
|
|
383
|
+
['error'],
|
|
384
|
+
);
|
|
385
|
+
if (typeof error.code !== 'string' || !Object.values(ErrorCode).includes(error.code as ErrorCode)) {
|
|
386
|
+
fail('Unknown protocol error code.', ['error', 'code']);
|
|
387
|
+
}
|
|
388
|
+
const decodedError: ProtocolErrorEnvelope['error'] = {
|
|
389
|
+
code: error.code as ErrorCode,
|
|
390
|
+
message: redactProtocolDiagnostic(
|
|
391
|
+
boundedString(error.message, 1, 512, ['error', 'message']),
|
|
392
|
+
),
|
|
393
|
+
};
|
|
394
|
+
if (error.details !== undefined) {
|
|
395
|
+
decodedError.details = redactProtocolDiagnostic(
|
|
396
|
+
boundedString(error.details, 1, 2048, ['error', 'details']),
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
if (error.retry_after !== undefined) {
|
|
400
|
+
decodedError.retry_after = boundedPositiveInteger(error.retry_after, 86_400, ['error', 'retry_after']);
|
|
401
|
+
}
|
|
402
|
+
if (error.required_capability !== undefined) {
|
|
403
|
+
decodedError.required_capability = capabilityName(
|
|
404
|
+
error.required_capability,
|
|
405
|
+
['error', 'required_capability'],
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
if (error.supported_versions !== undefined) {
|
|
409
|
+
if (!Array.isArray(error.supported_versions) || error.supported_versions.length === 0 ||
|
|
410
|
+
error.supported_versions.length > 16 ||
|
|
411
|
+
!error.supported_versions.every((version) => version === PROTOCOL_VERSION) ||
|
|
412
|
+
new Set(error.supported_versions).size !== error.supported_versions.length) {
|
|
413
|
+
fail('Invalid supported protocol versions.', ['error', 'supported_versions']);
|
|
414
|
+
}
|
|
415
|
+
decodedError.supported_versions = [...error.supported_versions] as ProtocolVersion[];
|
|
416
|
+
}
|
|
417
|
+
const decodedRequestId = input.request_id === undefined
|
|
418
|
+
? undefined
|
|
419
|
+
: decodeRequestId(input.request_id, ['request_id']);
|
|
420
|
+
return decodedRequestId === undefined
|
|
421
|
+
? { protocol_version: PROTOCOL_VERSION, error: decodedError }
|
|
422
|
+
: { protocol_version: PROTOCOL_VERSION, request_id: decodedRequestId, error: decodedError };
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
export function decodeEnrollmentExchangeRequest(value: unknown): EnrollmentExchangeRequest {
|
|
426
|
+
const input = record(value);
|
|
427
|
+
exactKeys(input, ['invitation', 'client_name'], ['invitation']);
|
|
428
|
+
const invitation = opaqueToken(input.invitation, ['invitation']);
|
|
429
|
+
const clientName = input.client_name === undefined
|
|
430
|
+
? undefined
|
|
431
|
+
: boundedString(input.client_name, 1, 120, ['client_name']);
|
|
432
|
+
if (clientName !== undefined && !/^[A-Za-z0-9][A-Za-z0-9 ._-]*$/.test(clientName)) {
|
|
433
|
+
fail('Client name contains unsupported characters.', ['client_name']);
|
|
434
|
+
}
|
|
435
|
+
return clientName === undefined ? { invitation } : { invitation, client_name: clientName };
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
export function decodeEnrollmentExchangeRequestEnvelope(
|
|
439
|
+
value: unknown,
|
|
440
|
+
): ProtocolEnvelope<EnrollmentExchangeRequest> {
|
|
441
|
+
return decodeProtocolEnvelope(value, decodeEnrollmentExchangeRequest);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
export function decodeEnrollmentExchangeResponse(value: unknown): EnrollmentExchangeResponse {
|
|
445
|
+
const input = record(value);
|
|
446
|
+
exactKeys(
|
|
447
|
+
input,
|
|
448
|
+
['client_id', 'credential', 'credential_expires_at'],
|
|
449
|
+
['client_id', 'credential'],
|
|
450
|
+
);
|
|
451
|
+
const clientId = opaqueIdentifier(input.client_id, ['client_id']);
|
|
452
|
+
const credential = opaqueToken(input.credential, ['credential']);
|
|
453
|
+
let expiresAt: string | null | undefined;
|
|
454
|
+
if (input.credential_expires_at === null) expiresAt = null;
|
|
455
|
+
else if (input.credential_expires_at !== undefined) {
|
|
456
|
+
expiresAt = decodeCanonicalTimestamp(input.credential_expires_at, ['credential_expires_at']);
|
|
457
|
+
}
|
|
458
|
+
return expiresAt === undefined
|
|
459
|
+
? { client_id: clientId, credential }
|
|
460
|
+
: { client_id: clientId, credential, credential_expires_at: expiresAt };
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
export function decodeEnrollmentExchangeResponseEnvelope(
|
|
464
|
+
value: unknown,
|
|
465
|
+
): ProtocolEnvelope<EnrollmentExchangeResponse> {
|
|
466
|
+
return decodeProtocolEnvelope(value, decodeEnrollmentExchangeResponse);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
export function decodeAppendLogRequest(value: unknown): import('./types.js').AppendLogRequest {
|
|
470
|
+
const input = record(value);
|
|
471
|
+
exactKeys(
|
|
472
|
+
input,
|
|
473
|
+
['message', 'visibility', 'recipientDroneIds', 'class', 'to'],
|
|
474
|
+
['message'],
|
|
475
|
+
);
|
|
476
|
+
const output: import('./types.js').AppendLogRequest = {
|
|
477
|
+
message: boundedString(input.message, 1, 10_240, ['message']),
|
|
478
|
+
};
|
|
479
|
+
if (input.visibility !== undefined) {
|
|
480
|
+
if (input.visibility !== 'broadcast' && input.visibility !== 'direct') {
|
|
481
|
+
fail('Invalid log visibility.', ['visibility']);
|
|
482
|
+
}
|
|
483
|
+
output.visibility = input.visibility;
|
|
484
|
+
}
|
|
485
|
+
if (input.recipientDroneIds !== undefined) {
|
|
486
|
+
if (!Array.isArray(input.recipientDroneIds) || input.recipientDroneIds.length === 0 || input.recipientDroneIds.length > 100) {
|
|
487
|
+
fail('Expected recipientDroneIds to contain 1-100 UUIDs.', ['recipientDroneIds']);
|
|
488
|
+
}
|
|
489
|
+
output.recipientDroneIds = input.recipientDroneIds.map((id, index) =>
|
|
490
|
+
decodeUuid(id, ['recipientDroneIds', index])
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
if (input.class !== undefined) {
|
|
494
|
+
output.class = boundedString(input.class, 1, 64, ['class']);
|
|
495
|
+
}
|
|
496
|
+
if (input.to !== undefined) {
|
|
497
|
+
output.to = decodeStringArray(input.to, 'to', 100, 120);
|
|
498
|
+
}
|
|
499
|
+
return output;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function decodeStringArray(value: unknown, field: string, maxItems: number, maxLength: number): string[] {
|
|
503
|
+
if (!Array.isArray(value) || value.length === 0 || value.length > maxItems) {
|
|
504
|
+
fail(`Expected ${field} to contain 1-${maxItems} values.`, [field]);
|
|
505
|
+
}
|
|
506
|
+
const decoded = value.map((item, index) => boundedString(item, 1, maxLength, [field, index]));
|
|
507
|
+
if (new Set(decoded).size !== decoded.length) fail(`${field} values must be unique.`, [field]);
|
|
508
|
+
return decoded;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
export function decodeAckLogRequest(value: unknown): AckLogRequest {
|
|
512
|
+
const input = record(value);
|
|
513
|
+
exactKeys(input, ['entry_id', 'kind'], ['entry_id']);
|
|
514
|
+
const kind = input.kind ?? 'ack';
|
|
515
|
+
if (kind !== 'ack' && kind !== 'claim') fail('Invalid ack kind.', ['kind']);
|
|
516
|
+
return {
|
|
517
|
+
entry_id: decodeUuid(input.entry_id, ['entry_id']),
|
|
518
|
+
kind,
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
export function decodeRecordDecisionRequest(
|
|
523
|
+
value: unknown,
|
|
524
|
+
): import('./types.js').RecordDecisionRequest {
|
|
525
|
+
const input = record(value);
|
|
526
|
+
exactKeys(input, ['topic', 'decision', 'rationale'], ['topic', 'decision']);
|
|
527
|
+
const output: import('./types.js').RecordDecisionRequest = {
|
|
528
|
+
topic: boundedString(input.topic, 1, 120, ['topic']),
|
|
529
|
+
decision: boundedString(input.decision, 1, 2000, ['decision']),
|
|
530
|
+
};
|
|
531
|
+
if (input.rationale !== undefined) {
|
|
532
|
+
output.rationale = boundedString(input.rationale, 1, 2000, ['rationale']);
|
|
533
|
+
}
|
|
534
|
+
return output;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
export function decodeRemoveDecisionRequest(value: unknown): RemoveDecisionRequest {
|
|
538
|
+
const input = record(value);
|
|
539
|
+
exactKeys(input, ['topic', 'decision_id'], []);
|
|
540
|
+
const hasTopic = input.topic !== undefined;
|
|
541
|
+
const hasId = input.decision_id !== undefined;
|
|
542
|
+
if (hasTopic === hasId) fail('Exactly one decision selector is required.');
|
|
543
|
+
return hasTopic
|
|
544
|
+
? { topic: boundedString(input.topic, 1, 120, ['topic']) }
|
|
545
|
+
: { decision_id: decodeUuid(input.decision_id, ['decision_id']) };
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
export function decodeCanonicalTimestamp(
|
|
549
|
+
value: unknown,
|
|
550
|
+
path: readonly (string | number)[] = [],
|
|
551
|
+
): string {
|
|
552
|
+
const timestamp = boundedString(value, 24, 24, path);
|
|
553
|
+
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(timestamp)) {
|
|
554
|
+
fail('Expected a canonical UTC timestamp with millisecond precision.', path);
|
|
555
|
+
}
|
|
556
|
+
const parsed = Date.parse(timestamp);
|
|
557
|
+
if (!Number.isFinite(parsed) || new Date(parsed).toISOString() !== timestamp) {
|
|
558
|
+
fail('Expected a valid canonical UTC timestamp.', path);
|
|
559
|
+
}
|
|
560
|
+
return timestamp;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
export function decodeLogCursor(value: unknown, path: readonly (string | number)[] = []): LogCursor {
|
|
564
|
+
const input = record(value, path);
|
|
565
|
+
exactKeys(input, ['id', 'created_at'], ['id', 'created_at'], path);
|
|
566
|
+
return {
|
|
567
|
+
id: decodeUuid(input.id, [...path, 'id']),
|
|
568
|
+
created_at: decodeCanonicalTimestamp(input.created_at, [...path, 'created_at']),
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
export function decodeUuid(value: unknown, path: readonly (string | number)[] = []): string {
|
|
573
|
+
const id = boundedString(value, 36, 36, path);
|
|
574
|
+
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(id)) {
|
|
575
|
+
fail('Expected a canonical UUID.', path);
|
|
576
|
+
}
|
|
577
|
+
return id.toLowerCase();
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
export function decodeOpaqueIdentifier(
|
|
581
|
+
value: unknown,
|
|
582
|
+
path: readonly (string | number)[] = [],
|
|
583
|
+
): string {
|
|
584
|
+
return opaqueIdentifier(value, path);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
export function redactProtocolDiagnostic(value: string): string {
|
|
588
|
+
return value
|
|
589
|
+
.replace(/[\u0000-\u001f\u007f-\u009f]/g, (character) =>
|
|
590
|
+
`\\u${character.charCodeAt(0).toString(16).padStart(4, '0')}`
|
|
591
|
+
)
|
|
592
|
+
.replace(/\bBearer\s+[A-Za-z0-9_-]{20,}/gi, 'Bearer <REDACTED>')
|
|
593
|
+
.replace(/[A-Za-z0-9_-]{43,}/g, '<REDACTED>');
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
export function compareLogCursor(a: LogCursor, b: LogCursor): -1 | 0 | 1 {
|
|
597
|
+
const left = decodeLogCursor(a);
|
|
598
|
+
const right = decodeLogCursor(b);
|
|
599
|
+
if (left.created_at !== right.created_at) return left.created_at < right.created_at ? -1 : 1;
|
|
600
|
+
if (left.id === right.id) return 0;
|
|
601
|
+
return left.id < right.id ? -1 : 1;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
export function maxLogCursor(a: LogCursor | null, b: LogCursor | null): LogCursor | null {
|
|
605
|
+
if (a === null) return b === null ? null : decodeLogCursor(b);
|
|
606
|
+
if (b === null) return decodeLogCursor(a);
|
|
607
|
+
return compareLogCursor(a, b) >= 0 ? decodeLogCursor(a) : decodeLogCursor(b);
|
|
608
|
+
}
|