borgmcp-shared 0.2.2 → 0.3.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,16 +2,18 @@ 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.3.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
14
  protocol: { method: 'GET', path: PROTOCOL_INFO_PATH, authenticated: true, 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,
@@ -32,6 +34,7 @@ export const KNOWN_CAPABILITIES = [
32
34
  'coordination.core',
33
35
  'auth.bearer',
34
36
  'auth.revocation',
37
+ 'auth.retry-safe-enrollment',
35
38
  'scope.cube-isolation',
36
39
  'transport.tls',
37
40
  'authority.no-cloud-fallback',
@@ -49,6 +52,7 @@ export type Capability = KnownCapability | (string & {});
49
52
  export const REQUIRED_SECURITY_CAPABILITIES = [
50
53
  'auth.bearer',
51
54
  'auth.revocation',
55
+ 'auth.retry-safe-enrollment',
52
56
  'scope.cube-isolation',
53
57
  'transport.tls',
54
58
  'authority.no-cloud-fallback',
@@ -90,17 +94,49 @@ export interface ProtocolErrorEnvelope {
90
94
  };
91
95
  }
92
96
 
93
- /** Invitation and returned bearer values are opaque secrets, never identifiers. */
97
+ /** All secret values are generated and persisted pending by the client before send. */
94
98
  export interface EnrollmentExchangeRequest {
95
99
  invitation: string;
100
+ retry_key: string;
101
+ client_credential: string;
96
102
  client_name?: string;
97
103
  }
98
104
 
99
- /** The credential is returned once and must not be persisted by server adapters. */
100
- export interface EnrollmentExchangeResponse {
105
+ export const SERVER_CAPABILITIES = ['create_cube'] as const;
106
+ export type ServerCapability = (typeof SERVER_CAPABILITIES)[number];
107
+
108
+ /** Ordinary enrollment creates an ungranted client and never returns a bearer. */
109
+ export interface ClientEnrollmentExchangeResponse {
110
+ purpose: 'client';
111
+ client_id: string;
112
+ server_capabilities: [];
113
+ }
114
+
115
+ /** Owner enrollment grants only the narrow authority to create cubes. */
116
+ export interface OwnerEnrollmentExchangeResponse {
117
+ purpose: 'owner';
101
118
  client_id: string;
102
- credential: string;
103
- credential_expires_at?: string | null;
119
+ server_capabilities: ['create_cube'];
120
+ }
121
+
122
+ export type EnrollmentExchangeResponse =
123
+ | ClientEnrollmentExchangeResponse
124
+ | OwnerEnrollmentExchangeResponse;
125
+
126
+ export const CUBE_TEMPLATES = ['default'] as const;
127
+ export type CubeTemplate = (typeof CUBE_TEMPLATES)[number];
128
+
129
+ export interface CreateCubeRequest {
130
+ retry_key: string;
131
+ name: string;
132
+ template: CubeTemplate;
133
+ }
134
+
135
+ export interface CreateCubeResponse {
136
+ cube_id: string;
137
+ human_seat_role_id: string;
138
+ default_worker_role_id: string;
139
+ access: 'manage';
104
140
  }
105
141
 
106
142
  export interface AckLogRequest {
@@ -424,15 +460,29 @@ export function decodeProtocolErrorEnvelope(value: unknown): ProtocolErrorEnvelo
424
460
 
425
461
  export function decodeEnrollmentExchangeRequest(value: unknown): EnrollmentExchangeRequest {
426
462
  const input = record(value);
427
- exactKeys(input, ['invitation', 'client_name'], ['invitation']);
463
+ exactKeys(
464
+ input,
465
+ ['invitation', 'retry_key', 'client_credential', 'client_name'],
466
+ ['invitation', 'retry_key', 'client_credential'],
467
+ );
428
468
  const invitation = opaqueToken(input.invitation, ['invitation']);
469
+ const retryKey = decodeUuid(input.retry_key, ['retry_key']);
470
+ const clientCredential = decodeEnrollmentClientCredential(
471
+ input.client_credential,
472
+ ['client_credential'],
473
+ );
429
474
  const clientName = input.client_name === undefined
430
475
  ? undefined
431
476
  : boundedString(input.client_name, 1, 120, ['client_name']);
432
477
  if (clientName !== undefined && !/^[A-Za-z0-9][A-Za-z0-9 ._-]*$/.test(clientName)) {
433
478
  fail('Client name contains unsupported characters.', ['client_name']);
434
479
  }
435
- return clientName === undefined ? { invitation } : { invitation, client_name: clientName };
480
+ const request = {
481
+ invitation,
482
+ retry_key: retryKey,
483
+ client_credential: clientCredential,
484
+ };
485
+ return clientName === undefined ? request : { ...request, client_name: clientName };
436
486
  }
437
487
 
438
488
  export function decodeEnrollmentExchangeRequestEnvelope(
@@ -443,21 +493,23 @@ export function decodeEnrollmentExchangeRequestEnvelope(
443
493
 
444
494
  export function decodeEnrollmentExchangeResponse(value: unknown): EnrollmentExchangeResponse {
445
495
  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 };
496
+ if (input.purpose === 'client') {
497
+ exactKeys(input, ['purpose', 'client_id', 'server_capabilities'], ['purpose', 'client_id', 'server_capabilities']);
498
+ decodeExactServerCapabilities(input.server_capabilities, [], ['server_capabilities']);
499
+ return {
500
+ purpose: 'client',
501
+ client_id: decodeUuid(input.client_id, ['client_id']),
502
+ server_capabilities: [],
503
+ };
504
+ }
505
+ if (input.purpose !== 'owner') fail('Invalid enrollment purpose.', ['purpose']);
506
+ exactKeys(input, ['purpose', 'client_id', 'server_capabilities'], ['purpose', 'client_id', 'server_capabilities']);
507
+ decodeExactServerCapabilities(input.server_capabilities, ['create_cube'], ['server_capabilities']);
508
+ return {
509
+ purpose: 'owner',
510
+ client_id: decodeUuid(input.client_id, ['client_id']),
511
+ server_capabilities: ['create_cube'],
512
+ };
461
513
  }
462
514
 
463
515
  export function decodeEnrollmentExchangeResponseEnvelope(
@@ -466,6 +518,58 @@ export function decodeEnrollmentExchangeResponseEnvelope(
466
518
  return decodeProtocolEnvelope(value, decodeEnrollmentExchangeResponse);
467
519
  }
468
520
 
521
+ function decodeExactServerCapabilities(
522
+ value: unknown,
523
+ expected: readonly ServerCapability[],
524
+ path: readonly (string | number)[],
525
+ ): void {
526
+ if (!Array.isArray(value) || value.length !== expected.length ||
527
+ value.some((capability, index) => capability !== expected[index])) {
528
+ fail(`Expected server capabilities [${expected.join(', ')}].`, path);
529
+ }
530
+ }
531
+
532
+ export function decodeCreateCubeRequest(value: unknown): CreateCubeRequest {
533
+ const input = record(value);
534
+ exactKeys(input, ['retry_key', 'name', 'template'], ['retry_key', 'name', 'template']);
535
+ const name = boundedString(input.name, 1, 120, ['name']);
536
+ if (!/^[A-Za-z0-9][A-Za-z0-9 ._-]*$/.test(name)) {
537
+ fail('Cube name contains unsupported characters.', ['name']);
538
+ }
539
+ if (!CUBE_TEMPLATES.includes(input.template as CubeTemplate)) {
540
+ fail('Unsupported cube template.', ['template']);
541
+ }
542
+ return {
543
+ retry_key: decodeUuid(input.retry_key, ['retry_key']),
544
+ name,
545
+ template: input.template as CubeTemplate,
546
+ };
547
+ }
548
+
549
+ export function decodeCreateCubeRequestEnvelope(value: unknown): ProtocolEnvelope<CreateCubeRequest> {
550
+ return decodeProtocolEnvelope(value, decodeCreateCubeRequest);
551
+ }
552
+
553
+ export function decodeCreateCubeResponse(value: unknown): CreateCubeResponse {
554
+ const input = record(value);
555
+ exactKeys(
556
+ input,
557
+ ['cube_id', 'human_seat_role_id', 'default_worker_role_id', 'access'],
558
+ ['cube_id', 'human_seat_role_id', 'default_worker_role_id', 'access'],
559
+ );
560
+ if (input.access !== 'manage') fail('Created cube access must be manage.', ['access']);
561
+ return {
562
+ cube_id: decodeUuid(input.cube_id, ['cube_id']),
563
+ human_seat_role_id: decodeUuid(input.human_seat_role_id, ['human_seat_role_id']),
564
+ default_worker_role_id: decodeUuid(input.default_worker_role_id, ['default_worker_role_id']),
565
+ access: 'manage',
566
+ };
567
+ }
568
+
569
+ export function decodeCreateCubeResponseEnvelope(value: unknown): ProtocolEnvelope<CreateCubeResponse> {
570
+ return decodeProtocolEnvelope(value, decodeCreateCubeResponse);
571
+ }
572
+
469
573
  export function decodeAppendLogRequest(value: unknown): import('./types.js').AppendLogRequest {
470
574
  const input = record(value);
471
575
  exactKeys(
@@ -577,6 +681,17 @@ export function decodeUuid(value: unknown, path: readonly (string | number)[] =
577
681
  return id.toLowerCase();
578
682
  }
579
683
 
684
+ export function decodeEnrollmentClientCredential(
685
+ value: unknown,
686
+ path: readonly (string | number)[] = [],
687
+ ): string {
688
+ const credential = boundedString(value, 43, 43, path);
689
+ if (!/^[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$/.test(credential)) {
690
+ fail('Expected an unpadded base64url encoding of exactly 256 bits.', path);
691
+ }
692
+ return credential;
693
+ }
694
+
580
695
  export function decodeOpaqueIdentifier(
581
696
  value: unknown,
582
697
  path: readonly (string | number)[] = [],
@@ -586,6 +701,7 @@ export function decodeOpaqueIdentifier(
586
701
 
587
702
  export function redactProtocolDiagnostic(value: string): string {
588
703
  return value
704
+ .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
705
  .replace(/[\u0000-\u001f\u007f-\u009f]/g, (character) =>
590
706
  `\\u${character.charCodeAt(0).toString(16).padStart(4, '0')}`
591
707
  )
@@ -18,10 +18,15 @@ export interface CompatibilityEntry {
18
18
  * a documented migration path.
19
19
  */
20
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
+ },
21
26
  {
22
27
  packageRange: '>=0.2.0 <0.3.0',
23
28
  protocolVersions: SUPPORTED_PROTOCOL_VERSIONS,
24
- notes: 'Versioned envelope, codecs, and adapter conformance for Borg MCP servers.',
29
+ notes: 'Legacy server-generated enrollment credential response.',
25
30
  },
26
31
  ];
27
32