borgmcp-shared 0.2.2 → 0.4.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.
@@ -2,22 +2,23 @@ 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.2.2' as const;
5
+ export const SHARED_PACKAGE_VERSION = '0.4.0' as const;
6
6
 
7
7
  export const HEALTH_PATH = '/healthz' as const;
8
8
  export const PROTOCOL_INFO_PATH = '/api/protocol' as const;
9
9
  export const ENROLLMENT_EXCHANGE_PATH = '/api/enrollment/exchange' as const;
10
+ export const CUBES_PATH = '/api/cubes' as const;
10
11
 
11
12
  export const PROTOCOL_HTTP_CONTRACT = {
12
13
  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
+ protocol: { method: 'GET', path: PROTOCOL_INFO_PATH, authenticated: false, success_status: 200 },
14
15
  enrollment: { method: 'POST', path: ENROLLMENT_EXCHANGE_PATH, authenticated: 'invitation', success_status: 201 },
16
+ cubes: { method: 'POST', path: CUBES_PATH, authenticated: true, success_status: 201 },
15
17
  auth_missing_status: 401,
16
18
  auth_invalid_status: 401,
17
19
  cursor_expired_status: 410,
18
20
  content_too_large_status: 413,
19
21
  unsupported_protocol_status: 426,
20
- unsupported_capability_status: 501,
21
22
  redirect_policy: 'error',
22
23
  } as const;
23
24
 
@@ -28,47 +29,14 @@ export const PROTOCOL_LIMIT_CEILINGS = {
28
29
  max_replay_page_size: 1000,
29
30
  } as const;
30
31
 
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 {
32
+ /**
33
+ * The credential-free protocol-tag preflight body. It carries ONLY the exact
34
+ * protocol tag — no package version, limits, server identity, or other
35
+ * fingerprint surface — so a client can verify pinned TLS and the exact tag
36
+ * before it creates or sends any credential.
37
+ */
38
+ export interface ProtocolTagPreflight {
65
39
  protocol_version: ProtocolVersion;
66
- package: {
67
- name: typeof SHARED_PACKAGE_NAME;
68
- version: string;
69
- };
70
- capabilities: Capability[];
71
- limits: ProtocolLimits;
72
40
  }
73
41
 
74
42
  export interface ProtocolEnvelope<T> {
@@ -85,22 +53,52 @@ export interface ProtocolErrorEnvelope {
85
53
  message: string;
86
54
  details?: string;
87
55
  retry_after?: number;
88
- required_capability?: string;
89
- supported_versions?: readonly string[];
90
56
  };
91
57
  }
92
58
 
93
- /** Invitation and returned bearer values are opaque secrets, never identifiers. */
59
+ /** All secret values are generated and persisted pending by the client before send. */
94
60
  export interface EnrollmentExchangeRequest {
95
61
  invitation: string;
62
+ retry_key: string;
63
+ client_credential: string;
96
64
  client_name?: string;
97
65
  }
98
66
 
99
- /** The credential is returned once and must not be persisted by server adapters. */
100
- export interface EnrollmentExchangeResponse {
67
+ export const SERVER_CAPABILITIES = ['create_cube'] as const;
68
+ export type ServerCapability = (typeof SERVER_CAPABILITIES)[number];
69
+
70
+ /** Ordinary enrollment creates an ungranted client and never returns a bearer. */
71
+ export interface ClientEnrollmentExchangeResponse {
72
+ purpose: 'client';
73
+ client_id: string;
74
+ server_capabilities: [];
75
+ }
76
+
77
+ /** Owner enrollment grants only the narrow authority to create cubes. */
78
+ export interface OwnerEnrollmentExchangeResponse {
79
+ purpose: 'owner';
101
80
  client_id: string;
102
- credential: string;
103
- credential_expires_at?: string | null;
81
+ server_capabilities: ['create_cube'];
82
+ }
83
+
84
+ export type EnrollmentExchangeResponse =
85
+ | ClientEnrollmentExchangeResponse
86
+ | OwnerEnrollmentExchangeResponse;
87
+
88
+ export const CUBE_TEMPLATES = ['default'] as const;
89
+ export type CubeTemplate = (typeof CUBE_TEMPLATES)[number];
90
+
91
+ export interface CreateCubeRequest {
92
+ retry_key: string;
93
+ name: string;
94
+ template: CubeTemplate;
95
+ }
96
+
97
+ export interface CreateCubeResponse {
98
+ cube_id: string;
99
+ human_seat_role_id: string;
100
+ default_worker_role_id: string;
101
+ access: 'manage';
104
102
  }
105
103
 
106
104
  export interface AckLogRequest {
@@ -184,17 +182,6 @@ export function utf8ByteLength(value: string): number {
184
182
  return bytes;
185
183
  }
186
184
 
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
185
  function boundedPositiveInteger(
199
186
  value: unknown,
200
187
  maximum: number,
@@ -230,95 +217,32 @@ function decodeRequestId(value: unknown, path: readonly (string | number)[]): st
230
217
  return decoded;
231
218
  }
232
219
 
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;
220
+ /**
221
+ * Emit the credential-free protocol-tag preflight body. Servers return exactly
222
+ * this — the tag and nothing else — from the unauthenticated `GET /api/protocol`.
223
+ */
224
+ export function createProtocolTagPreflight(): ProtocolTagPreflight {
225
+ return { protocol_version: PROTOCOL_VERSION };
239
226
  }
240
227
 
241
- export function decodeProtocolInfo(value: unknown): ProtocolInfo {
228
+ /**
229
+ * Decode the credential-free, mutation-free protocol-tag preflight. The body must
230
+ * be exactly `{ protocol_version }` and carry the exact expected tag; any other
231
+ * tag, an extra field, or a non-object fails closed before any credential is
232
+ * created or sent. This is the sole acceptance authority — there is no
233
+ * negotiation, capability list, or package/limit surface to inspect.
234
+ */
235
+ export function decodeProtocolTagPreflight(value: unknown): ProtocolTagPreflight {
242
236
  const input = record(value);
243
- exactKeys(input, ['protocol_version', 'package', 'capabilities', 'limits'], [
244
- 'protocol_version',
245
- 'package',
246
- 'capabilities',
247
- 'limits',
248
- ]);
237
+ exactKeys(input, ['protocol_version'], ['protocol_version']);
249
238
  if (input.protocol_version !== PROTOCOL_VERSION) {
250
239
  throw new ProtocolContractError(
251
- `Unsupported protocol version "${String(input.protocol_version)}".`,
240
+ 'Unsupported protocol version.',
252
241
  ErrorCode.UNSUPPORTED_PROTOCOL_VERSION,
253
242
  ['protocol_version'],
254
243
  );
255
244
  }
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;
245
+ return { protocol_version: PROTOCOL_VERSION };
322
246
  }
323
247
 
324
248
  export function createProtocolEnvelope<T>(requestId: string, payload: T): ProtocolEnvelope<T> {
@@ -341,7 +265,7 @@ export function decodeProtocolEnvelope<T>(
341
265
  ]);
342
266
  if (input.protocol_version !== PROTOCOL_VERSION) {
343
267
  throw new ProtocolContractError(
344
- `Unsupported protocol version "${String(input.protocol_version)}".`,
268
+ 'Unsupported protocol version.',
345
269
  ErrorCode.UNSUPPORTED_PROTOCOL_VERSION,
346
270
  ['protocol_version'],
347
271
  );
@@ -354,16 +278,13 @@ export function decodeProtocolEnvelope<T>(
354
278
  };
355
279
  }
356
280
 
357
- export function decodeProtocolInfoEnvelope(value: unknown): ProtocolEnvelope<ProtocolInfo> {
358
- return decodeProtocolEnvelope(value, decodeProtocolInfo);
359
- }
360
281
 
361
282
  export function decodeProtocolErrorEnvelope(value: unknown): ProtocolErrorEnvelope {
362
283
  const input = record(value);
363
284
  exactKeys(input, ['protocol_version', 'request_id', 'error'], ['protocol_version', 'error']);
364
285
  if (input.protocol_version !== PROTOCOL_VERSION) {
365
286
  throw new ProtocolContractError(
366
- `Unsupported protocol version "${String(input.protocol_version)}".`,
287
+ 'Unsupported protocol version.',
367
288
  ErrorCode.UNSUPPORTED_PROTOCOL_VERSION,
368
289
  ['protocol_version'],
369
290
  );
@@ -376,8 +297,6 @@ export function decodeProtocolErrorEnvelope(value: unknown): ProtocolErrorEnvelo
376
297
  'message',
377
298
  'details',
378
299
  'retry_after',
379
- 'required_capability',
380
- 'supported_versions',
381
300
  ],
382
301
  ['code', 'message'],
383
302
  ['error'],
@@ -399,21 +318,6 @@ export function decodeProtocolErrorEnvelope(value: unknown): ProtocolErrorEnvelo
399
318
  if (error.retry_after !== undefined) {
400
319
  decodedError.retry_after = boundedPositiveInteger(error.retry_after, 86_400, ['error', 'retry_after']);
401
320
  }
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
321
  const decodedRequestId = input.request_id === undefined
418
322
  ? undefined
419
323
  : decodeRequestId(input.request_id, ['request_id']);
@@ -424,15 +328,29 @@ export function decodeProtocolErrorEnvelope(value: unknown): ProtocolErrorEnvelo
424
328
 
425
329
  export function decodeEnrollmentExchangeRequest(value: unknown): EnrollmentExchangeRequest {
426
330
  const input = record(value);
427
- exactKeys(input, ['invitation', 'client_name'], ['invitation']);
331
+ exactKeys(
332
+ input,
333
+ ['invitation', 'retry_key', 'client_credential', 'client_name'],
334
+ ['invitation', 'retry_key', 'client_credential'],
335
+ );
428
336
  const invitation = opaqueToken(input.invitation, ['invitation']);
337
+ const retryKey = decodeUuid(input.retry_key, ['retry_key']);
338
+ const clientCredential = decodeEnrollmentClientCredential(
339
+ input.client_credential,
340
+ ['client_credential'],
341
+ );
429
342
  const clientName = input.client_name === undefined
430
343
  ? undefined
431
344
  : boundedString(input.client_name, 1, 120, ['client_name']);
432
345
  if (clientName !== undefined && !/^[A-Za-z0-9][A-Za-z0-9 ._-]*$/.test(clientName)) {
433
346
  fail('Client name contains unsupported characters.', ['client_name']);
434
347
  }
435
- return clientName === undefined ? { invitation } : { invitation, client_name: clientName };
348
+ const request = {
349
+ invitation,
350
+ retry_key: retryKey,
351
+ client_credential: clientCredential,
352
+ };
353
+ return clientName === undefined ? request : { ...request, client_name: clientName };
436
354
  }
437
355
 
438
356
  export function decodeEnrollmentExchangeRequestEnvelope(
@@ -443,21 +361,23 @@ export function decodeEnrollmentExchangeRequestEnvelope(
443
361
 
444
362
  export function decodeEnrollmentExchangeResponse(value: unknown): EnrollmentExchangeResponse {
445
363
  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 };
364
+ if (input.purpose === 'client') {
365
+ exactKeys(input, ['purpose', 'client_id', 'server_capabilities'], ['purpose', 'client_id', 'server_capabilities']);
366
+ decodeExactServerCapabilities(input.server_capabilities, [], ['server_capabilities']);
367
+ return {
368
+ purpose: 'client',
369
+ client_id: decodeUuid(input.client_id, ['client_id']),
370
+ server_capabilities: [],
371
+ };
372
+ }
373
+ if (input.purpose !== 'owner') fail('Invalid enrollment purpose.', ['purpose']);
374
+ exactKeys(input, ['purpose', 'client_id', 'server_capabilities'], ['purpose', 'client_id', 'server_capabilities']);
375
+ decodeExactServerCapabilities(input.server_capabilities, ['create_cube'], ['server_capabilities']);
376
+ return {
377
+ purpose: 'owner',
378
+ client_id: decodeUuid(input.client_id, ['client_id']),
379
+ server_capabilities: ['create_cube'],
380
+ };
461
381
  }
462
382
 
463
383
  export function decodeEnrollmentExchangeResponseEnvelope(
@@ -466,6 +386,58 @@ export function decodeEnrollmentExchangeResponseEnvelope(
466
386
  return decodeProtocolEnvelope(value, decodeEnrollmentExchangeResponse);
467
387
  }
468
388
 
389
+ function decodeExactServerCapabilities(
390
+ value: unknown,
391
+ expected: readonly ServerCapability[],
392
+ path: readonly (string | number)[],
393
+ ): void {
394
+ if (!Array.isArray(value) || value.length !== expected.length ||
395
+ value.some((capability, index) => capability !== expected[index])) {
396
+ fail(`Expected server capabilities [${expected.join(', ')}].`, path);
397
+ }
398
+ }
399
+
400
+ export function decodeCreateCubeRequest(value: unknown): CreateCubeRequest {
401
+ const input = record(value);
402
+ exactKeys(input, ['retry_key', 'name', 'template'], ['retry_key', 'name', 'template']);
403
+ const name = boundedString(input.name, 1, 120, ['name']);
404
+ if (!/^[A-Za-z0-9][A-Za-z0-9 ._-]*$/.test(name)) {
405
+ fail('Cube name contains unsupported characters.', ['name']);
406
+ }
407
+ if (!CUBE_TEMPLATES.includes(input.template as CubeTemplate)) {
408
+ fail('Unsupported cube template.', ['template']);
409
+ }
410
+ return {
411
+ retry_key: decodeUuid(input.retry_key, ['retry_key']),
412
+ name,
413
+ template: input.template as CubeTemplate,
414
+ };
415
+ }
416
+
417
+ export function decodeCreateCubeRequestEnvelope(value: unknown): ProtocolEnvelope<CreateCubeRequest> {
418
+ return decodeProtocolEnvelope(value, decodeCreateCubeRequest);
419
+ }
420
+
421
+ export function decodeCreateCubeResponse(value: unknown): CreateCubeResponse {
422
+ const input = record(value);
423
+ exactKeys(
424
+ input,
425
+ ['cube_id', 'human_seat_role_id', 'default_worker_role_id', 'access'],
426
+ ['cube_id', 'human_seat_role_id', 'default_worker_role_id', 'access'],
427
+ );
428
+ if (input.access !== 'manage') fail('Created cube access must be manage.', ['access']);
429
+ return {
430
+ cube_id: decodeUuid(input.cube_id, ['cube_id']),
431
+ human_seat_role_id: decodeUuid(input.human_seat_role_id, ['human_seat_role_id']),
432
+ default_worker_role_id: decodeUuid(input.default_worker_role_id, ['default_worker_role_id']),
433
+ access: 'manage',
434
+ };
435
+ }
436
+
437
+ export function decodeCreateCubeResponseEnvelope(value: unknown): ProtocolEnvelope<CreateCubeResponse> {
438
+ return decodeProtocolEnvelope(value, decodeCreateCubeResponse);
439
+ }
440
+
469
441
  export function decodeAppendLogRequest(value: unknown): import('./types.js').AppendLogRequest {
470
442
  const input = record(value);
471
443
  exactKeys(
@@ -577,6 +549,17 @@ export function decodeUuid(value: unknown, path: readonly (string | number)[] =
577
549
  return id.toLowerCase();
578
550
  }
579
551
 
552
+ export function decodeEnrollmentClientCredential(
553
+ value: unknown,
554
+ path: readonly (string | number)[] = [],
555
+ ): string {
556
+ const credential = boundedString(value, 43, 43, path);
557
+ if (!/^[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$/.test(credential)) {
558
+ fail('Expected an unpadded base64url encoding of exactly 256 bits.', path);
559
+ }
560
+ return credential;
561
+ }
562
+
580
563
  export function decodeOpaqueIdentifier(
581
564
  value: unknown,
582
565
  path: readonly (string | number)[] = [],
@@ -586,6 +569,7 @@ export function decodeOpaqueIdentifier(
586
569
 
587
570
  export function redactProtocolDiagnostic(value: string): string {
588
571
  return value
572
+ .replace(/(\bretry[_-]?key\b["']?\s*(?:=|:)\s*["']?)[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/gi, '$1<REDACTED>')
589
573
  .replace(/[\u0000-\u001f\u007f-\u009f]/g, (character) =>
590
574
  `\\u${character.charCodeAt(0).toString(16).padStart(4, '0')}`
591
575
  )
@@ -606,3 +590,212 @@ export function maxLogCursor(a: LogCursor | null, b: LogCursor | null): LogCurso
606
590
  if (b === null) return decodeLogCursor(a);
607
591
  return compareLogCursor(a, b) >= 0 ? decodeLogCursor(a) : decodeLogCursor(b);
608
592
  }
593
+
594
+ // ── v2 clean-slate wire types ──────────────────────────────────────────────
595
+
596
+ export const ATTACH_PATH = '/api/client/attach' as const;
597
+
598
+ export interface AttachRequest {
599
+ cube_id: string;
600
+ role_id: string;
601
+ session_credential: string;
602
+ prior_drone_id?: string;
603
+ }
604
+
605
+ export interface AttachCube {
606
+ id: string;
607
+ name: string;
608
+ }
609
+
610
+ export type AttachRoleClass = 'queen' | 'worker';
611
+
612
+ export interface AttachRole {
613
+ id: string;
614
+ name: string;
615
+ role_class?: AttachRoleClass;
616
+ is_human_seat?: boolean;
617
+ }
618
+
619
+ export interface AttachDrone {
620
+ id: string;
621
+ label: string;
622
+ }
623
+
624
+ export interface AttachSession {
625
+ id: string;
626
+ expires_at: string;
627
+ }
628
+
629
+ export interface AttachResponse {
630
+ result: 'created' | 'reused';
631
+ cube: AttachCube;
632
+ role: AttachRole;
633
+ drone: AttachDrone;
634
+ session: AttachSession;
635
+ }
636
+
637
+ function decodeAttachCube(value: unknown, path: readonly (string | number)[]): AttachCube {
638
+ const input = record(value, path);
639
+ exactKeys(input, ['id', 'name'], ['id', 'name'], path);
640
+ return {
641
+ id: decodeUuid(input.id, [...path, 'id']),
642
+ name: boundedString(input.name, 1, 128, [...path, 'name']),
643
+ };
644
+ }
645
+
646
+ function decodeAttachRole(value: unknown, path: readonly (string | number)[]): AttachRole {
647
+ const input = record(value, path);
648
+ exactKeys(input, ['id', 'name', 'role_class', 'is_human_seat'], ['id', 'name'], path);
649
+ const result: AttachRole = {
650
+ id: decodeUuid(input.id, [...path, 'id']),
651
+ name: boundedString(input.name, 1, 128, [...path, 'name']),
652
+ };
653
+ if (input.role_class !== undefined) {
654
+ if (input.role_class !== 'queen' && input.role_class !== 'worker') {
655
+ fail('Expected role_class "queen" or "worker".', [...path, 'role_class']);
656
+ }
657
+ result.role_class = input.role_class;
658
+ }
659
+ if (input.is_human_seat !== undefined) {
660
+ if (typeof input.is_human_seat !== 'boolean') {
661
+ fail('Expected a boolean.', [...path, 'is_human_seat']);
662
+ }
663
+ result.is_human_seat = input.is_human_seat;
664
+ }
665
+ return result;
666
+ }
667
+
668
+ function decodeAttachDrone(value: unknown, path: readonly (string | number)[]): AttachDrone {
669
+ const input = record(value, path);
670
+ exactKeys(input, ['id', 'label'], ['id', 'label'], path);
671
+ return {
672
+ id: decodeUuid(input.id, [...path, 'id']),
673
+ label: boundedString(input.label, 1, 128, [...path, 'label']),
674
+ };
675
+ }
676
+
677
+ function decodeAttachSession(value: unknown, path: readonly (string | number)[]): AttachSession {
678
+ const input = record(value, path);
679
+ exactKeys(input, ['id', 'expires_at'], ['id', 'expires_at'], path);
680
+ return {
681
+ id: decodeUuid(input.id, [...path, 'id']),
682
+ expires_at: decodeCanonicalTimestamp(input.expires_at, [...path, 'expires_at']),
683
+ };
684
+ }
685
+
686
+ /**
687
+ * Decode a v2 attach request. Strict: exact keys, bounded sizes,
688
+ * session_credential is token-safe and never echoed in errors.
689
+ */
690
+ export function decodeAttachRequest(value: unknown): AttachRequest {
691
+ const input = record(value);
692
+ exactKeys(input, ['cube_id', 'role_id', 'session_credential', 'prior_drone_id'], [
693
+ 'cube_id',
694
+ 'role_id',
695
+ 'session_credential',
696
+ ]);
697
+ const result: AttachRequest = {
698
+ cube_id: decodeUuid(input.cube_id, ['cube_id']),
699
+ role_id: decodeUuid(input.role_id, ['role_id']),
700
+ session_credential: opaqueToken(input.session_credential, ['session_credential']),
701
+ };
702
+ if (input.prior_drone_id !== undefined) {
703
+ result.prior_drone_id = decodeUuid(input.prior_drone_id, ['prior_drone_id']);
704
+ }
705
+ return result;
706
+ }
707
+
708
+ /**
709
+ * Create a v2 attach request envelope. Stamps the canonical protocol version.
710
+ */
711
+ export function createAttachRequestEnvelope(
712
+ requestId: string,
713
+ payload: AttachRequest,
714
+ ): ProtocolEnvelope<AttachRequest> {
715
+ return {
716
+ protocol_version: PROTOCOL_VERSION,
717
+ request_id: decodeRequestId(requestId, ['request_id']),
718
+ payload,
719
+ };
720
+ }
721
+
722
+ /**
723
+ * Decode a v2 attach request envelope. Verifies protocol_version === PROTOCOL_VERSION
724
+ * BEFORE decoding the payload — a wrong tag never invokes the payload decoder
725
+ * and never exposes or returns the supplied session_credential.
726
+ * Uses a static token-safe diagnostic; does not interpolate attacker-controlled text.
727
+ */
728
+ export function decodeAttachRequestEnvelope(
729
+ value: unknown,
730
+ ): ProtocolEnvelope<AttachRequest> {
731
+ const input = record(value);
732
+ exactKeys(input, ['protocol_version', 'request_id', 'payload'], [
733
+ 'protocol_version',
734
+ 'request_id',
735
+ 'payload',
736
+ ]);
737
+ if (input.protocol_version !== PROTOCOL_VERSION) {
738
+ throw new ProtocolContractError(
739
+ 'Unsupported protocol version.',
740
+ ErrorCode.UNSUPPORTED_PROTOCOL_VERSION,
741
+ ['protocol_version'],
742
+ );
743
+ }
744
+ const decodedRequestId = decodeRequestId(input.request_id, ['request_id']);
745
+ return {
746
+ protocol_version: PROTOCOL_VERSION,
747
+ request_id: decodedRequestId,
748
+ payload: decodeAttachRequest(input.payload),
749
+ };
750
+ }
751
+
752
+ /**
753
+ * Decode a v2 attach response. Strict: exact keys, result discriminant,
754
+ * expires_at required non-null finite ISO-8601.
755
+ */
756
+ export function decodeAttachResponse(value: unknown): AttachResponse {
757
+ const input = record(value);
758
+ exactKeys(input, ['result', 'cube', 'role', 'drone', 'session'], [
759
+ 'result',
760
+ 'cube',
761
+ 'role',
762
+ 'drone',
763
+ 'session',
764
+ ]);
765
+ if (input.result !== 'created' && input.result !== 'reused') {
766
+ fail('Expected result "created" or "reused".', ['result']);
767
+ }
768
+ return {
769
+ result: input.result,
770
+ cube: decodeAttachCube(input.cube, ['cube']),
771
+ role: decodeAttachRole(input.role, ['role']),
772
+ drone: decodeAttachDrone(input.drone, ['drone']),
773
+ session: decodeAttachSession(input.session, ['session']),
774
+ };
775
+ }
776
+
777
+ /**
778
+ * Decode a v2 attach response wrapped in a ProtocolEnvelope.
779
+ * Verifies protocol_version === PROTOCOL_VERSION before decoding payload.
780
+ */
781
+ export function decodeAttachResponseEnvelope(value: unknown): ProtocolEnvelope<AttachResponse> {
782
+ const input = record(value);
783
+ exactKeys(input, ['protocol_version', 'request_id', 'payload'], [
784
+ 'protocol_version',
785
+ 'request_id',
786
+ 'payload',
787
+ ]);
788
+ if (input.protocol_version !== PROTOCOL_VERSION) {
789
+ throw new ProtocolContractError(
790
+ 'Unsupported protocol version.',
791
+ ErrorCode.UNSUPPORTED_PROTOCOL_VERSION,
792
+ ['protocol_version'],
793
+ );
794
+ }
795
+ const decodedRequestId = decodeRequestId(input.request_id, ['request_id']);
796
+ return {
797
+ protocol_version: PROTOCOL_VERSION,
798
+ request_id: decodedRequestId,
799
+ payload: decodeAttachResponse(input.payload),
800
+ };
801
+ }