borgmcp-shared 0.3.0 → 0.4.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/README.md +46 -26
- package/SECURITY.md +6 -5
- package/dist/conformance/adapter.d.ts +62 -11
- package/dist/conformance/adapter.d.ts.map +1 -1
- package/dist/conformance/adapter.js +273 -55
- package/dist/conformance/adapter.js.map +1 -1
- package/dist/protocol/contract.d.ts +56 -25
- package/dist/protocol/contract.d.ts.map +1 -1
- package/dist/protocol/contract.js +147 -116
- package/dist/protocol/contract.js.map +1 -1
- package/dist/protocol/coordination.d.ts +25 -0
- package/dist/protocol/coordination.d.ts.map +1 -1
- package/dist/protocol/coordination.js +44 -0
- package/dist/protocol/coordination.js.map +1 -1
- package/dist/protocol/errors.d.ts +2 -4
- package/dist/protocol/errors.d.ts.map +1 -1
- package/dist/protocol/errors.js +1 -1
- package/dist/protocol/errors.js.map +1 -1
- package/dist/protocol/version.d.ts +2 -10
- package/dist/protocol/version.d.ts.map +1 -1
- package/dist/protocol/version.js +1 -18
- package/dist/protocol/version.js.map +1 -1
- package/dist/templates.d.ts +1 -1
- package/dist/templates.d.ts.map +1 -1
- package/dist/templates.js +9 -11
- package/dist/templates.js.map +1 -1
- package/docs/compatibility.md +38 -50
- package/docs/enrollment.md +27 -17
- package/docs/releasing.md +61 -33
- package/package.json +2 -2
- package/src/conformance/adapter.ts +618 -82
- package/src/protocol/contract.ts +250 -160
- package/src/protocol/coordination.ts +82 -0
- package/src/protocol/errors.ts +7 -3
- package/src/protocol/version.ts +3 -35
- package/src/templates.ts +9 -11
package/src/protocol/contract.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { ErrorCode } from './errors.js';
|
|
|
2
2
|
import { PROTOCOL_VERSION, type ProtocolVersion } from './version.js';
|
|
3
3
|
|
|
4
4
|
export const SHARED_PACKAGE_NAME = 'borgmcp-shared' as const;
|
|
5
|
-
export const SHARED_PACKAGE_VERSION = '0.
|
|
5
|
+
export const SHARED_PACKAGE_VERSION = '0.4.2' as const;
|
|
6
6
|
|
|
7
7
|
export const HEALTH_PATH = '/healthz' as const;
|
|
8
8
|
export const PROTOCOL_INFO_PATH = '/api/protocol' as const;
|
|
@@ -11,15 +11,27 @@ export const CUBES_PATH = '/api/cubes' as const;
|
|
|
11
11
|
|
|
12
12
|
export const PROTOCOL_HTTP_CONTRACT = {
|
|
13
13
|
health: { method: 'GET', path: HEALTH_PATH, authenticated: false, success_status: 204, bodyless: true },
|
|
14
|
-
protocol: { method: 'GET', path: PROTOCOL_INFO_PATH, authenticated:
|
|
14
|
+
protocol: { method: 'GET', path: PROTOCOL_INFO_PATH, authenticated: false, success_status: 200 },
|
|
15
15
|
enrollment: { method: 'POST', path: ENROLLMENT_EXCHANGE_PATH, authenticated: 'invitation', success_status: 201 },
|
|
16
16
|
cubes: { method: 'POST', path: CUBES_PATH, authenticated: true, success_status: 201 },
|
|
17
|
+
drone_reassign: {
|
|
18
|
+
method: 'PATCH',
|
|
19
|
+
path: '/api/cubes/:cubeId/drones/:droneId',
|
|
20
|
+
authenticated: true,
|
|
21
|
+
success_status: 200,
|
|
22
|
+
},
|
|
23
|
+
drone_evict: {
|
|
24
|
+
method: 'DELETE',
|
|
25
|
+
path: '/api/cubes/:cubeId/drones/:droneId',
|
|
26
|
+
authenticated: true,
|
|
27
|
+
success_status: 200,
|
|
28
|
+
},
|
|
17
29
|
auth_missing_status: 401,
|
|
18
30
|
auth_invalid_status: 401,
|
|
19
31
|
cursor_expired_status: 410,
|
|
32
|
+
drone_evicted_status: 410,
|
|
20
33
|
content_too_large_status: 413,
|
|
21
34
|
unsupported_protocol_status: 426,
|
|
22
|
-
unsupported_capability_status: 501,
|
|
23
35
|
redirect_policy: 'error',
|
|
24
36
|
} as const;
|
|
25
37
|
|
|
@@ -30,49 +42,14 @@ export const PROTOCOL_LIMIT_CEILINGS = {
|
|
|
30
42
|
max_replay_page_size: 1000,
|
|
31
43
|
} as const;
|
|
32
44
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
'authority.no-cloud-fallback',
|
|
41
|
-
'log.cursor',
|
|
42
|
-
'stream.sse',
|
|
43
|
-
'stream.replay',
|
|
44
|
-
'acks',
|
|
45
|
-
'claims',
|
|
46
|
-
'decisions',
|
|
47
|
-
] as const;
|
|
48
|
-
|
|
49
|
-
export type KnownCapability = (typeof KNOWN_CAPABILITIES)[number];
|
|
50
|
-
export type Capability = KnownCapability | (string & {});
|
|
51
|
-
|
|
52
|
-
export const REQUIRED_SECURITY_CAPABILITIES = [
|
|
53
|
-
'auth.bearer',
|
|
54
|
-
'auth.revocation',
|
|
55
|
-
'auth.retry-safe-enrollment',
|
|
56
|
-
'scope.cube-isolation',
|
|
57
|
-
'transport.tls',
|
|
58
|
-
'authority.no-cloud-fallback',
|
|
59
|
-
] as const satisfies readonly Capability[];
|
|
60
|
-
|
|
61
|
-
export interface ProtocolLimits {
|
|
62
|
-
max_request_bytes: number;
|
|
63
|
-
max_log_message_bytes: number;
|
|
64
|
-
max_read_page_size: number;
|
|
65
|
-
max_replay_page_size: number;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
export interface ProtocolInfo {
|
|
45
|
+
/**
|
|
46
|
+
* The credential-free protocol-tag preflight body. It carries ONLY the exact
|
|
47
|
+
* protocol tag — no package version, limits, server identity, or other
|
|
48
|
+
* fingerprint surface — so a client can verify pinned TLS and the exact tag
|
|
49
|
+
* before it creates or sends any credential.
|
|
50
|
+
*/
|
|
51
|
+
export interface ProtocolTagPreflight {
|
|
69
52
|
protocol_version: ProtocolVersion;
|
|
70
|
-
package: {
|
|
71
|
-
name: typeof SHARED_PACKAGE_NAME;
|
|
72
|
-
version: string;
|
|
73
|
-
};
|
|
74
|
-
capabilities: Capability[];
|
|
75
|
-
limits: ProtocolLimits;
|
|
76
53
|
}
|
|
77
54
|
|
|
78
55
|
export interface ProtocolEnvelope<T> {
|
|
@@ -89,8 +66,6 @@ export interface ProtocolErrorEnvelope {
|
|
|
89
66
|
message: string;
|
|
90
67
|
details?: string;
|
|
91
68
|
retry_after?: number;
|
|
92
|
-
required_capability?: string;
|
|
93
|
-
supported_versions?: readonly string[];
|
|
94
69
|
};
|
|
95
70
|
}
|
|
96
71
|
|
|
@@ -220,17 +195,6 @@ export function utf8ByteLength(value: string): number {
|
|
|
220
195
|
return bytes;
|
|
221
196
|
}
|
|
222
197
|
|
|
223
|
-
function isSemanticVersion(value: string): boolean {
|
|
224
|
-
const match = value.match(
|
|
225
|
-
/^(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-]+)*))?$/,
|
|
226
|
-
);
|
|
227
|
-
if (!match) return false;
|
|
228
|
-
const prerelease = match[4];
|
|
229
|
-
return prerelease === undefined || prerelease.split('.').every((identifier) =>
|
|
230
|
-
!/^\d+$/.test(identifier) || identifier === '0' || !identifier.startsWith('0')
|
|
231
|
-
);
|
|
232
|
-
}
|
|
233
|
-
|
|
234
198
|
function boundedPositiveInteger(
|
|
235
199
|
value: unknown,
|
|
236
200
|
maximum: number,
|
|
@@ -266,95 +230,32 @@ function decodeRequestId(value: unknown, path: readonly (string | number)[]): st
|
|
|
266
230
|
return decoded;
|
|
267
231
|
}
|
|
268
232
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
return
|
|
233
|
+
/**
|
|
234
|
+
* Emit the credential-free protocol-tag preflight body. Servers return exactly
|
|
235
|
+
* this — the tag and nothing else — from the unauthenticated `GET /api/protocol`.
|
|
236
|
+
*/
|
|
237
|
+
export function createProtocolTagPreflight(): ProtocolTagPreflight {
|
|
238
|
+
return { protocol_version: PROTOCOL_VERSION };
|
|
275
239
|
}
|
|
276
240
|
|
|
277
|
-
|
|
241
|
+
/**
|
|
242
|
+
* Decode the credential-free, mutation-free protocol-tag preflight. The body must
|
|
243
|
+
* be exactly `{ protocol_version }` and carry the exact expected tag; any other
|
|
244
|
+
* tag, an extra field, or a non-object fails closed before any credential is
|
|
245
|
+
* created or sent. This is the sole acceptance authority — there is no
|
|
246
|
+
* negotiation, capability list, or package/limit surface to inspect.
|
|
247
|
+
*/
|
|
248
|
+
export function decodeProtocolTagPreflight(value: unknown): ProtocolTagPreflight {
|
|
278
249
|
const input = record(value);
|
|
279
|
-
exactKeys(input, ['protocol_version', '
|
|
280
|
-
'protocol_version',
|
|
281
|
-
'package',
|
|
282
|
-
'capabilities',
|
|
283
|
-
'limits',
|
|
284
|
-
]);
|
|
250
|
+
exactKeys(input, ['protocol_version'], ['protocol_version']);
|
|
285
251
|
if (input.protocol_version !== PROTOCOL_VERSION) {
|
|
286
252
|
throw new ProtocolContractError(
|
|
287
|
-
|
|
253
|
+
'Unsupported protocol version.',
|
|
288
254
|
ErrorCode.UNSUPPORTED_PROTOCOL_VERSION,
|
|
289
255
|
['protocol_version'],
|
|
290
256
|
);
|
|
291
257
|
}
|
|
292
|
-
|
|
293
|
-
const packageInfo = record(input.package, ['package']);
|
|
294
|
-
exactKeys(packageInfo, ['name', 'version'], ['name', 'version'], ['package']);
|
|
295
|
-
if (packageInfo.name !== SHARED_PACKAGE_NAME) {
|
|
296
|
-
fail(`Expected package name "${SHARED_PACKAGE_NAME}".`, ['package', 'name']);
|
|
297
|
-
}
|
|
298
|
-
const packageVersion = boundedString(packageInfo.version, 5, 64, ['package', 'version']);
|
|
299
|
-
if (!isSemanticVersion(packageVersion)) {
|
|
300
|
-
fail('Expected a semantic package version.', ['package', 'version']);
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
if (!Array.isArray(input.capabilities)) fail('Expected an array.', ['capabilities']);
|
|
304
|
-
const capabilities = input.capabilities.map((capability, index) => {
|
|
305
|
-
return capabilityName(capability, ['capabilities', index]) as Capability;
|
|
306
|
-
});
|
|
307
|
-
if (new Set(capabilities).size !== capabilities.length) {
|
|
308
|
-
fail('Capabilities must be unique.', ['capabilities']);
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
const limits = record(input.limits, ['limits']);
|
|
312
|
-
exactKeys(
|
|
313
|
-
limits,
|
|
314
|
-
[
|
|
315
|
-
'max_request_bytes',
|
|
316
|
-
'max_log_message_bytes',
|
|
317
|
-
'max_read_page_size',
|
|
318
|
-
'max_replay_page_size',
|
|
319
|
-
],
|
|
320
|
-
[
|
|
321
|
-
'max_request_bytes',
|
|
322
|
-
'max_log_message_bytes',
|
|
323
|
-
'max_read_page_size',
|
|
324
|
-
'max_replay_page_size',
|
|
325
|
-
],
|
|
326
|
-
['limits'],
|
|
327
|
-
);
|
|
328
|
-
|
|
329
|
-
return {
|
|
330
|
-
protocol_version: PROTOCOL_VERSION,
|
|
331
|
-
package: { name: SHARED_PACKAGE_NAME, version: packageVersion },
|
|
332
|
-
capabilities,
|
|
333
|
-
limits: {
|
|
334
|
-
max_request_bytes: boundedPositiveInteger(limits.max_request_bytes, PROTOCOL_LIMIT_CEILINGS.max_request_bytes, ['limits', 'max_request_bytes']),
|
|
335
|
-
max_log_message_bytes: boundedPositiveInteger(limits.max_log_message_bytes, PROTOCOL_LIMIT_CEILINGS.max_log_message_bytes, ['limits', 'max_log_message_bytes']),
|
|
336
|
-
max_read_page_size: boundedPositiveInteger(limits.max_read_page_size, PROTOCOL_LIMIT_CEILINGS.max_read_page_size, ['limits', 'max_read_page_size']),
|
|
337
|
-
max_replay_page_size: boundedPositiveInteger(limits.max_replay_page_size, PROTOCOL_LIMIT_CEILINGS.max_replay_page_size, ['limits', 'max_replay_page_size']),
|
|
338
|
-
},
|
|
339
|
-
};
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
export function negotiateProtocol(
|
|
343
|
-
value: unknown,
|
|
344
|
-
requiredCapabilities: readonly Capability[] = [],
|
|
345
|
-
): ProtocolInfo {
|
|
346
|
-
const info = decodeProtocolInfo(value);
|
|
347
|
-
const required = [...REQUIRED_SECURITY_CAPABILITIES, ...requiredCapabilities];
|
|
348
|
-
for (const capability of new Set(required)) {
|
|
349
|
-
if (!info.capabilities.includes(capability)) {
|
|
350
|
-
throw new ProtocolContractError(
|
|
351
|
-
`Required capability "${capability}" is unavailable.`,
|
|
352
|
-
ErrorCode.UNSUPPORTED_CAPABILITY,
|
|
353
|
-
['capabilities'],
|
|
354
|
-
);
|
|
355
|
-
}
|
|
356
|
-
}
|
|
357
|
-
return info;
|
|
258
|
+
return { protocol_version: PROTOCOL_VERSION };
|
|
358
259
|
}
|
|
359
260
|
|
|
360
261
|
export function createProtocolEnvelope<T>(requestId: string, payload: T): ProtocolEnvelope<T> {
|
|
@@ -377,7 +278,7 @@ export function decodeProtocolEnvelope<T>(
|
|
|
377
278
|
]);
|
|
378
279
|
if (input.protocol_version !== PROTOCOL_VERSION) {
|
|
379
280
|
throw new ProtocolContractError(
|
|
380
|
-
|
|
281
|
+
'Unsupported protocol version.',
|
|
381
282
|
ErrorCode.UNSUPPORTED_PROTOCOL_VERSION,
|
|
382
283
|
['protocol_version'],
|
|
383
284
|
);
|
|
@@ -390,16 +291,13 @@ export function decodeProtocolEnvelope<T>(
|
|
|
390
291
|
};
|
|
391
292
|
}
|
|
392
293
|
|
|
393
|
-
export function decodeProtocolInfoEnvelope(value: unknown): ProtocolEnvelope<ProtocolInfo> {
|
|
394
|
-
return decodeProtocolEnvelope(value, decodeProtocolInfo);
|
|
395
|
-
}
|
|
396
294
|
|
|
397
295
|
export function decodeProtocolErrorEnvelope(value: unknown): ProtocolErrorEnvelope {
|
|
398
296
|
const input = record(value);
|
|
399
297
|
exactKeys(input, ['protocol_version', 'request_id', 'error'], ['protocol_version', 'error']);
|
|
400
298
|
if (input.protocol_version !== PROTOCOL_VERSION) {
|
|
401
299
|
throw new ProtocolContractError(
|
|
402
|
-
|
|
300
|
+
'Unsupported protocol version.',
|
|
403
301
|
ErrorCode.UNSUPPORTED_PROTOCOL_VERSION,
|
|
404
302
|
['protocol_version'],
|
|
405
303
|
);
|
|
@@ -412,8 +310,6 @@ export function decodeProtocolErrorEnvelope(value: unknown): ProtocolErrorEnvelo
|
|
|
412
310
|
'message',
|
|
413
311
|
'details',
|
|
414
312
|
'retry_after',
|
|
415
|
-
'required_capability',
|
|
416
|
-
'supported_versions',
|
|
417
313
|
],
|
|
418
314
|
['code', 'message'],
|
|
419
315
|
['error'],
|
|
@@ -435,21 +331,6 @@ export function decodeProtocolErrorEnvelope(value: unknown): ProtocolErrorEnvelo
|
|
|
435
331
|
if (error.retry_after !== undefined) {
|
|
436
332
|
decodedError.retry_after = boundedPositiveInteger(error.retry_after, 86_400, ['error', 'retry_after']);
|
|
437
333
|
}
|
|
438
|
-
if (error.required_capability !== undefined) {
|
|
439
|
-
decodedError.required_capability = capabilityName(
|
|
440
|
-
error.required_capability,
|
|
441
|
-
['error', 'required_capability'],
|
|
442
|
-
);
|
|
443
|
-
}
|
|
444
|
-
if (error.supported_versions !== undefined) {
|
|
445
|
-
if (!Array.isArray(error.supported_versions) || error.supported_versions.length === 0 ||
|
|
446
|
-
error.supported_versions.length > 16 ||
|
|
447
|
-
!error.supported_versions.every((version) => version === PROTOCOL_VERSION) ||
|
|
448
|
-
new Set(error.supported_versions).size !== error.supported_versions.length) {
|
|
449
|
-
fail('Invalid supported protocol versions.', ['error', 'supported_versions']);
|
|
450
|
-
}
|
|
451
|
-
decodedError.supported_versions = [...error.supported_versions] as ProtocolVersion[];
|
|
452
|
-
}
|
|
453
334
|
const decodedRequestId = input.request_id === undefined
|
|
454
335
|
? undefined
|
|
455
336
|
: decodeRequestId(input.request_id, ['request_id']);
|
|
@@ -722,3 +603,212 @@ export function maxLogCursor(a: LogCursor | null, b: LogCursor | null): LogCurso
|
|
|
722
603
|
if (b === null) return decodeLogCursor(a);
|
|
723
604
|
return compareLogCursor(a, b) >= 0 ? decodeLogCursor(a) : decodeLogCursor(b);
|
|
724
605
|
}
|
|
606
|
+
|
|
607
|
+
// ── v2 clean-slate wire types ──────────────────────────────────────────────
|
|
608
|
+
|
|
609
|
+
export const ATTACH_PATH = '/api/client/attach' as const;
|
|
610
|
+
|
|
611
|
+
export interface AttachRequest {
|
|
612
|
+
cube_id: string;
|
|
613
|
+
role_id: string;
|
|
614
|
+
session_credential: string;
|
|
615
|
+
prior_drone_id?: string;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
export interface AttachCube {
|
|
619
|
+
id: string;
|
|
620
|
+
name: string;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
export type AttachRoleClass = 'queen' | 'worker';
|
|
624
|
+
|
|
625
|
+
export interface AttachRole {
|
|
626
|
+
id: string;
|
|
627
|
+
name: string;
|
|
628
|
+
role_class?: AttachRoleClass;
|
|
629
|
+
is_human_seat?: boolean;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
export interface AttachDrone {
|
|
633
|
+
id: string;
|
|
634
|
+
label: string;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
export interface AttachSession {
|
|
638
|
+
id: string;
|
|
639
|
+
expires_at: string;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
export interface AttachResponse {
|
|
643
|
+
result: 'created' | 'reused';
|
|
644
|
+
cube: AttachCube;
|
|
645
|
+
role: AttachRole;
|
|
646
|
+
drone: AttachDrone;
|
|
647
|
+
session: AttachSession;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function decodeAttachCube(value: unknown, path: readonly (string | number)[]): AttachCube {
|
|
651
|
+
const input = record(value, path);
|
|
652
|
+
exactKeys(input, ['id', 'name'], ['id', 'name'], path);
|
|
653
|
+
return {
|
|
654
|
+
id: decodeUuid(input.id, [...path, 'id']),
|
|
655
|
+
name: boundedString(input.name, 1, 128, [...path, 'name']),
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function decodeAttachRole(value: unknown, path: readonly (string | number)[]): AttachRole {
|
|
660
|
+
const input = record(value, path);
|
|
661
|
+
exactKeys(input, ['id', 'name', 'role_class', 'is_human_seat'], ['id', 'name'], path);
|
|
662
|
+
const result: AttachRole = {
|
|
663
|
+
id: decodeUuid(input.id, [...path, 'id']),
|
|
664
|
+
name: boundedString(input.name, 1, 128, [...path, 'name']),
|
|
665
|
+
};
|
|
666
|
+
if (input.role_class !== undefined) {
|
|
667
|
+
if (input.role_class !== 'queen' && input.role_class !== 'worker') {
|
|
668
|
+
fail('Expected role_class "queen" or "worker".', [...path, 'role_class']);
|
|
669
|
+
}
|
|
670
|
+
result.role_class = input.role_class;
|
|
671
|
+
}
|
|
672
|
+
if (input.is_human_seat !== undefined) {
|
|
673
|
+
if (typeof input.is_human_seat !== 'boolean') {
|
|
674
|
+
fail('Expected a boolean.', [...path, 'is_human_seat']);
|
|
675
|
+
}
|
|
676
|
+
result.is_human_seat = input.is_human_seat;
|
|
677
|
+
}
|
|
678
|
+
return result;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function decodeAttachDrone(value: unknown, path: readonly (string | number)[]): AttachDrone {
|
|
682
|
+
const input = record(value, path);
|
|
683
|
+
exactKeys(input, ['id', 'label'], ['id', 'label'], path);
|
|
684
|
+
return {
|
|
685
|
+
id: decodeUuid(input.id, [...path, 'id']),
|
|
686
|
+
label: boundedString(input.label, 1, 128, [...path, 'label']),
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
function decodeAttachSession(value: unknown, path: readonly (string | number)[]): AttachSession {
|
|
691
|
+
const input = record(value, path);
|
|
692
|
+
exactKeys(input, ['id', 'expires_at'], ['id', 'expires_at'], path);
|
|
693
|
+
return {
|
|
694
|
+
id: decodeUuid(input.id, [...path, 'id']),
|
|
695
|
+
expires_at: decodeCanonicalTimestamp(input.expires_at, [...path, 'expires_at']),
|
|
696
|
+
};
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* Decode a v2 attach request. Strict: exact keys, bounded sizes,
|
|
701
|
+
* session_credential is token-safe and never echoed in errors.
|
|
702
|
+
*/
|
|
703
|
+
export function decodeAttachRequest(value: unknown): AttachRequest {
|
|
704
|
+
const input = record(value);
|
|
705
|
+
exactKeys(input, ['cube_id', 'role_id', 'session_credential', 'prior_drone_id'], [
|
|
706
|
+
'cube_id',
|
|
707
|
+
'role_id',
|
|
708
|
+
'session_credential',
|
|
709
|
+
]);
|
|
710
|
+
const result: AttachRequest = {
|
|
711
|
+
cube_id: decodeUuid(input.cube_id, ['cube_id']),
|
|
712
|
+
role_id: decodeUuid(input.role_id, ['role_id']),
|
|
713
|
+
session_credential: opaqueToken(input.session_credential, ['session_credential']),
|
|
714
|
+
};
|
|
715
|
+
if (input.prior_drone_id !== undefined) {
|
|
716
|
+
result.prior_drone_id = decodeUuid(input.prior_drone_id, ['prior_drone_id']);
|
|
717
|
+
}
|
|
718
|
+
return result;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/**
|
|
722
|
+
* Create a v2 attach request envelope. Stamps the canonical protocol version.
|
|
723
|
+
*/
|
|
724
|
+
export function createAttachRequestEnvelope(
|
|
725
|
+
requestId: string,
|
|
726
|
+
payload: AttachRequest,
|
|
727
|
+
): ProtocolEnvelope<AttachRequest> {
|
|
728
|
+
return {
|
|
729
|
+
protocol_version: PROTOCOL_VERSION,
|
|
730
|
+
request_id: decodeRequestId(requestId, ['request_id']),
|
|
731
|
+
payload,
|
|
732
|
+
};
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/**
|
|
736
|
+
* Decode a v2 attach request envelope. Verifies protocol_version === PROTOCOL_VERSION
|
|
737
|
+
* BEFORE decoding the payload — a wrong tag never invokes the payload decoder
|
|
738
|
+
* and never exposes or returns the supplied session_credential.
|
|
739
|
+
* Uses a static token-safe diagnostic; does not interpolate attacker-controlled text.
|
|
740
|
+
*/
|
|
741
|
+
export function decodeAttachRequestEnvelope(
|
|
742
|
+
value: unknown,
|
|
743
|
+
): ProtocolEnvelope<AttachRequest> {
|
|
744
|
+
const input = record(value);
|
|
745
|
+
exactKeys(input, ['protocol_version', 'request_id', 'payload'], [
|
|
746
|
+
'protocol_version',
|
|
747
|
+
'request_id',
|
|
748
|
+
'payload',
|
|
749
|
+
]);
|
|
750
|
+
if (input.protocol_version !== PROTOCOL_VERSION) {
|
|
751
|
+
throw new ProtocolContractError(
|
|
752
|
+
'Unsupported protocol version.',
|
|
753
|
+
ErrorCode.UNSUPPORTED_PROTOCOL_VERSION,
|
|
754
|
+
['protocol_version'],
|
|
755
|
+
);
|
|
756
|
+
}
|
|
757
|
+
const decodedRequestId = decodeRequestId(input.request_id, ['request_id']);
|
|
758
|
+
return {
|
|
759
|
+
protocol_version: PROTOCOL_VERSION,
|
|
760
|
+
request_id: decodedRequestId,
|
|
761
|
+
payload: decodeAttachRequest(input.payload),
|
|
762
|
+
};
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
/**
|
|
766
|
+
* Decode a v2 attach response. Strict: exact keys, result discriminant,
|
|
767
|
+
* expires_at required non-null finite ISO-8601.
|
|
768
|
+
*/
|
|
769
|
+
export function decodeAttachResponse(value: unknown): AttachResponse {
|
|
770
|
+
const input = record(value);
|
|
771
|
+
exactKeys(input, ['result', 'cube', 'role', 'drone', 'session'], [
|
|
772
|
+
'result',
|
|
773
|
+
'cube',
|
|
774
|
+
'role',
|
|
775
|
+
'drone',
|
|
776
|
+
'session',
|
|
777
|
+
]);
|
|
778
|
+
if (input.result !== 'created' && input.result !== 'reused') {
|
|
779
|
+
fail('Expected result "created" or "reused".', ['result']);
|
|
780
|
+
}
|
|
781
|
+
return {
|
|
782
|
+
result: input.result,
|
|
783
|
+
cube: decodeAttachCube(input.cube, ['cube']),
|
|
784
|
+
role: decodeAttachRole(input.role, ['role']),
|
|
785
|
+
drone: decodeAttachDrone(input.drone, ['drone']),
|
|
786
|
+
session: decodeAttachSession(input.session, ['session']),
|
|
787
|
+
};
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
/**
|
|
791
|
+
* Decode a v2 attach response wrapped in a ProtocolEnvelope.
|
|
792
|
+
* Verifies protocol_version === PROTOCOL_VERSION before decoding payload.
|
|
793
|
+
*/
|
|
794
|
+
export function decodeAttachResponseEnvelope(value: unknown): ProtocolEnvelope<AttachResponse> {
|
|
795
|
+
const input = record(value);
|
|
796
|
+
exactKeys(input, ['protocol_version', 'request_id', 'payload'], [
|
|
797
|
+
'protocol_version',
|
|
798
|
+
'request_id',
|
|
799
|
+
'payload',
|
|
800
|
+
]);
|
|
801
|
+
if (input.protocol_version !== PROTOCOL_VERSION) {
|
|
802
|
+
throw new ProtocolContractError(
|
|
803
|
+
'Unsupported protocol version.',
|
|
804
|
+
ErrorCode.UNSUPPORTED_PROTOCOL_VERSION,
|
|
805
|
+
['protocol_version'],
|
|
806
|
+
);
|
|
807
|
+
}
|
|
808
|
+
const decodedRequestId = decodeRequestId(input.request_id, ['request_id']);
|
|
809
|
+
return {
|
|
810
|
+
protocol_version: PROTOCOL_VERSION,
|
|
811
|
+
request_id: decodedRequestId,
|
|
812
|
+
payload: decodeAttachResponse(input.payload),
|
|
813
|
+
};
|
|
814
|
+
}
|
|
@@ -38,6 +38,28 @@ export interface ReadLogResult {
|
|
|
38
38
|
claims: ClaimRecord[];
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
export interface ReassignDroneRequest {
|
|
42
|
+
role_id: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface ManagedDrone {
|
|
46
|
+
id: string;
|
|
47
|
+
cube_id: string;
|
|
48
|
+
role_id: string;
|
|
49
|
+
label: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface ReassignDroneResult {
|
|
53
|
+
drone: ManagedDrone;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type EvictDroneRequest = Record<string, never>;
|
|
57
|
+
|
|
58
|
+
export interface EvictDroneResult {
|
|
59
|
+
drone_id: string;
|
|
60
|
+
evicted: true;
|
|
61
|
+
}
|
|
62
|
+
|
|
41
63
|
function object(value: unknown): Record<string, unknown> {
|
|
42
64
|
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
43
65
|
throw new ProtocolContractError('Expected a coordination object.');
|
|
@@ -84,6 +106,66 @@ function positiveInteger(value: unknown, name: string, maximum: number): number
|
|
|
84
106
|
return decoded;
|
|
85
107
|
}
|
|
86
108
|
|
|
109
|
+
function decodeManagedDrone(value: unknown): ManagedDrone {
|
|
110
|
+
const input = object(value);
|
|
111
|
+
exact(input, ['id', 'cube_id', 'role_id', 'label'], ['id', 'cube_id', 'role_id', 'label']);
|
|
112
|
+
return {
|
|
113
|
+
id: decodeUuid(input.id, ['id']),
|
|
114
|
+
cube_id: decodeUuid(input.cube_id, ['cube_id']),
|
|
115
|
+
role_id: decodeUuid(input.role_id, ['role_id']),
|
|
116
|
+
label: boundedString(input.label, 'label', 120),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function decodeReassignDroneRequest(value: unknown): ReassignDroneRequest {
|
|
121
|
+
const input = object(value);
|
|
122
|
+
exact(input, ['role_id'], ['role_id']);
|
|
123
|
+
return { role_id: decodeUuid(input.role_id, ['role_id']) };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function decodeReassignDroneRequestEnvelope(
|
|
127
|
+
value: unknown,
|
|
128
|
+
): ProtocolEnvelope<ReassignDroneRequest> {
|
|
129
|
+
return decodeProtocolEnvelope(value, decodeReassignDroneRequest);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function decodeReassignDroneResult(value: unknown): ReassignDroneResult {
|
|
133
|
+
const input = object(value);
|
|
134
|
+
exact(input, ['drone'], ['drone']);
|
|
135
|
+
return { drone: decodeManagedDrone(input.drone) };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function decodeReassignDroneResultEnvelope(
|
|
139
|
+
value: unknown,
|
|
140
|
+
): ProtocolEnvelope<ReassignDroneResult> {
|
|
141
|
+
return decodeProtocolEnvelope(value, decodeReassignDroneResult);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function decodeEvictDroneRequest(value: unknown): EvictDroneRequest {
|
|
145
|
+
const input = object(value);
|
|
146
|
+
exact(input, [], []);
|
|
147
|
+
return {};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function decodeEvictDroneRequestEnvelope(
|
|
151
|
+
value: unknown,
|
|
152
|
+
): ProtocolEnvelope<EvictDroneRequest> {
|
|
153
|
+
return decodeProtocolEnvelope(value, decodeEvictDroneRequest);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function decodeEvictDroneResult(value: unknown): EvictDroneResult {
|
|
157
|
+
const input = object(value);
|
|
158
|
+
exact(input, ['drone_id', 'evicted'], ['drone_id', 'evicted']);
|
|
159
|
+
if (input.evicted !== true) throw new ProtocolContractError('Invalid drone eviction result.');
|
|
160
|
+
return { drone_id: decodeUuid(input.drone_id, ['drone_id']), evicted: true };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function decodeEvictDroneResultEnvelope(
|
|
164
|
+
value: unknown,
|
|
165
|
+
): ProtocolEnvelope<EvictDroneResult> {
|
|
166
|
+
return decodeProtocolEnvelope(value, decodeEvictDroneResult);
|
|
167
|
+
}
|
|
168
|
+
|
|
87
169
|
export function decodeReadLogRequest(value: unknown): ReadLogRequest {
|
|
88
170
|
const input = object(value);
|
|
89
171
|
exact(input, ['cursor', 'limit'], ['cursor']);
|
package/src/protocol/errors.ts
CHANGED
|
@@ -18,10 +18,16 @@ export enum ErrorCode {
|
|
|
18
18
|
DRONE_EVICTED = 'DRONE_EVICTED',
|
|
19
19
|
DRONE_FROZEN = 'DRONE_FROZEN',
|
|
20
20
|
UNSUPPORTED_PROTOCOL_VERSION = 'UNSUPPORTED_PROTOCOL_VERSION',
|
|
21
|
-
UNSUPPORTED_CAPABILITY = 'UNSUPPORTED_CAPABILITY',
|
|
22
21
|
CURSOR_INVALID = 'CURSOR_INVALID',
|
|
23
22
|
CURSOR_EXPIRED = 'CURSOR_EXPIRED',
|
|
24
23
|
SESSION_REVOKED = 'SESSION_REVOKED',
|
|
24
|
+
/**
|
|
25
|
+
* The presented session bearer does not match the seat it targets: a fresh or
|
|
26
|
+
* non-matching bearer against an already-bound active seat. Distinct from
|
|
27
|
+
* SESSION_REVOKED (a formerly valid credential that was explicitly revoked or
|
|
28
|
+
* expired). Carried by the server's typed 401 takeover rejection.
|
|
29
|
+
*/
|
|
30
|
+
SESSION_REJECTED = 'SESSION_REJECTED',
|
|
25
31
|
}
|
|
26
32
|
|
|
27
33
|
/** @deprecated Wire failures use the versioned ProtocolErrorEnvelope. */
|
|
@@ -31,6 +37,4 @@ export interface ErrorResponse {
|
|
|
31
37
|
details?: string;
|
|
32
38
|
/** Number of seconds a rate-limited caller should wait. */
|
|
33
39
|
retryAfter?: number;
|
|
34
|
-
requiredCapability?: string;
|
|
35
|
-
supportedVersions?: readonly string[];
|
|
36
40
|
}
|
package/src/protocol/version.ts
CHANGED
|
@@ -1,36 +1,4 @@
|
|
|
1
|
-
/** Current Borg coordination protocol generation. */
|
|
2
|
-
export const PROTOCOL_VERSION = '
|
|
1
|
+
/** Current Borg coordination protocol generation. Clean-slate v2. */
|
|
2
|
+
export const PROTOCOL_VERSION = '2' as const;
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
export const SUPPORTED_PROTOCOL_VERSIONS = [PROTOCOL_VERSION] as const;
|
|
6
|
-
|
|
7
|
-
export type ProtocolVersion = (typeof SUPPORTED_PROTOCOL_VERSIONS)[number];
|
|
8
|
-
|
|
9
|
-
export interface CompatibilityEntry {
|
|
10
|
-
packageRange: string;
|
|
11
|
-
protocolVersions: readonly ProtocolVersion[];
|
|
12
|
-
notes: string;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* Compatibility table for published package releases. Pre-1.0 package
|
|
17
|
-
* releases may add contracts, but do not change an existing wire shape without
|
|
18
|
-
* a documented migration path.
|
|
19
|
-
*/
|
|
20
|
-
export const COMPATIBILITY_MATRIX: readonly CompatibilityEntry[] = [
|
|
21
|
-
{
|
|
22
|
-
packageRange: '>=0.3.0 <0.4.0',
|
|
23
|
-
protocolVersions: SUPPORTED_PROTOCOL_VERSIONS,
|
|
24
|
-
notes: 'Retry-safe owner enrollment and idempotent multi-cube creation.',
|
|
25
|
-
},
|
|
26
|
-
{
|
|
27
|
-
packageRange: '>=0.2.0 <0.3.0',
|
|
28
|
-
protocolVersions: SUPPORTED_PROTOCOL_VERSIONS,
|
|
29
|
-
notes: 'Legacy server-generated enrollment credential response.',
|
|
30
|
-
},
|
|
31
|
-
];
|
|
32
|
-
|
|
33
|
-
export function isProtocolVersionSupported(value: unknown): value is ProtocolVersion {
|
|
34
|
-
return typeof value === 'string' &&
|
|
35
|
-
(SUPPORTED_PROTOCOL_VERSIONS as readonly string[]).includes(value);
|
|
36
|
-
}
|
|
4
|
+
export type ProtocolVersion = typeof PROTOCOL_VERSION;
|