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.
@@ -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.3.0' as const;
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: true, success_status: 200 },
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
- export const KNOWN_CAPABILITIES = [
34
- 'coordination.core',
35
- 'auth.bearer',
36
- 'auth.revocation',
37
- 'auth.retry-safe-enrollment',
38
- 'scope.cube-isolation',
39
- 'transport.tls',
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
- function capabilityName(value: unknown, path: readonly (string | number)[]): string {
270
- const decoded = boundedString(value, 1, 64, path);
271
- if (!/^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$/.test(decoded)) {
272
- fail('Capability name contains unsupported characters.', path);
273
- }
274
- return decoded;
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
- export function decodeProtocolInfo(value: unknown): ProtocolInfo {
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', 'package', 'capabilities', 'limits'], [
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
- `Unsupported protocol version "${String(input.protocol_version)}".`,
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
- `Unsupported protocol version "${String(input.protocol_version)}".`,
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
- `Unsupported protocol version "${String(input.protocol_version)}".`,
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']);
@@ -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
  }
@@ -1,36 +1,4 @@
1
- /** Current Borg coordination protocol generation. */
2
- export const PROTOCOL_VERSION = '1' as const;
1
+ /** Current Borg coordination protocol generation. Clean-slate v2. */
2
+ export const PROTOCOL_VERSION = '2' as const;
3
3
 
4
- /** Protocol generations accepted by this package release. */
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;