borgmcp-shared 0.6.2 → 0.6.4

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.
@@ -1,5 +1,11 @@
1
1
  import type { BroadcastHwm } from '../log-stream-hwm.js';
2
- import type { AttachResponse, EnrollmentExchangeRequest } from '../protocol/contract.js';
2
+ import type {
3
+ AttachResponse,
4
+ AssociateRepositoryCubeRequest,
5
+ CreateCubeRequest,
6
+ EnrollmentExchangeRequest,
7
+ ResolveRepositoryCubeRequest,
8
+ } from '../protocol/contract.js';
3
9
 
4
10
  export * from './adapter.js';
5
11
 
@@ -167,6 +173,270 @@ export const ENROLLMENT_RETRY_CONFORMANCE: readonly EnrollmentRetryConformanceVe
167
173
  },
168
174
  ];
169
175
 
176
+ export interface CreateCubeRetryConformanceVector {
177
+ name: string;
178
+ initial: CreateCubeRequest;
179
+ retry: CreateCubeRequest;
180
+ expected:
181
+ | { outcome: 'resolved_response'; status: 201 }
182
+ | { outcome: 'retry_tuple_mismatch'; status: 409; error: 'INVALID_INPUT' };
183
+ }
184
+
185
+ const CREATE_CUBE_RETRY_KEY = '00000000-0000-4000-8000-000000000121';
186
+ const CREATE_CUBE_INITIAL: CreateCubeRequest = {
187
+ retry_key: CREATE_CUBE_RETRY_KEY,
188
+ name: 'Repository One',
189
+ working_repo_name: 'repository-one',
190
+ repository: { kind: 'origin', value: 'https://github.com/Byte-Ventures/repository-one' },
191
+ template: 'default',
192
+ };
193
+
194
+ /** Stateful vectors: name, repository identity, and template bind the retry key. */
195
+ export const CREATE_CUBE_RETRY_CONFORMANCE: readonly CreateCubeRetryConformanceVector[] = [
196
+ {
197
+ name: 'exact retry resolves the authoritative response',
198
+ initial: CREATE_CUBE_INITIAL,
199
+ retry: CREATE_CUBE_INITIAL,
200
+ expected: { outcome: 'resolved_response', status: 201 },
201
+ },
202
+ {
203
+ name: 'changed repository display metadata resolves stored authoritative display',
204
+ initial: CREATE_CUBE_INITIAL,
205
+ retry: { ...CREATE_CUBE_INITIAL, working_repo_name: 'repository-one-renamed' },
206
+ expected: { outcome: 'resolved_response', status: 201 },
207
+ },
208
+ {
209
+ name: 'cube-name mismatch is rejected',
210
+ initial: CREATE_CUBE_INITIAL,
211
+ retry: { ...CREATE_CUBE_INITIAL, name: 'Repository One Renamed' },
212
+ expected: { outcome: 'retry_tuple_mismatch', status: 409, error: 'INVALID_INPUT' },
213
+ },
214
+ {
215
+ name: 'template mismatch is rejected',
216
+ initial: CREATE_CUBE_INITIAL,
217
+ retry: { ...CREATE_CUBE_INITIAL, template: 'software-dev' },
218
+ expected: { outcome: 'retry_tuple_mismatch', status: 409, error: 'INVALID_INPUT' },
219
+ },
220
+ {
221
+ name: 'repository kind mismatch is rejected',
222
+ initial: CREATE_CUBE_INITIAL,
223
+ retry: {
224
+ ...CREATE_CUBE_INITIAL,
225
+ repository: { kind: 'local', value: '00000000-0000-4000-8000-000000000122' },
226
+ },
227
+ expected: { outcome: 'retry_tuple_mismatch', status: 409, error: 'INVALID_INPUT' },
228
+ },
229
+ {
230
+ name: 'repository value mismatch is rejected',
231
+ initial: CREATE_CUBE_INITIAL,
232
+ retry: {
233
+ ...CREATE_CUBE_INITIAL,
234
+ repository: { kind: 'origin', value: 'https://github.com/Byte-Ventures/repository-two' },
235
+ },
236
+ expected: { outcome: 'retry_tuple_mismatch', status: 409, error: 'INVALID_INPUT' },
237
+ },
238
+ ];
239
+
240
+ export interface CreateCubeAssociationConformanceVector {
241
+ name: string;
242
+ created: CreateCubeRequest;
243
+ request: CreateCubeRequest;
244
+ expected:
245
+ | { outcome: 'resolved'; authority_state_delta: Record<string, never> }
246
+ | {
247
+ outcome: 'created';
248
+ authority_state_delta: {
249
+ cubes: 1;
250
+ roles: 2;
251
+ grants: 1;
252
+ cube_create_bindings: 1;
253
+ repository_associations: 1;
254
+ };
255
+ };
256
+ }
257
+
258
+ /** A creator-scoped repository association resolves independently of operation retry keys. */
259
+ export const CREATE_CUBE_ASSOCIATION_CONFORMANCE:
260
+ readonly CreateCubeAssociationConformanceVector[] = [
261
+ {
262
+ name: 'fresh retry for the same repository resolves stored authoritative fields',
263
+ created: CREATE_CUBE_INITIAL,
264
+ request: {
265
+ ...CREATE_CUBE_INITIAL,
266
+ retry_key: '00000000-0000-4000-8000-000000000123',
267
+ name: 'Ignored New Cube Name',
268
+ working_repo_name: 'ignored-new-display',
269
+ template: 'starter',
270
+ },
271
+ expected: { outcome: 'resolved', authority_state_delta: {} },
272
+ },
273
+ {
274
+ name: 'fresh retry for a different repository may create',
275
+ created: CREATE_CUBE_INITIAL,
276
+ request: {
277
+ ...CREATE_CUBE_INITIAL,
278
+ retry_key: '00000000-0000-4000-8000-000000000124',
279
+ name: 'Repository Two',
280
+ working_repo_name: 'repository-two',
281
+ repository: { kind: 'origin', value: 'https://github.com/Byte-Ventures/repository-two' },
282
+ },
283
+ expected: {
284
+ outcome: 'created',
285
+ authority_state_delta: {
286
+ cubes: 1,
287
+ roles: 2,
288
+ grants: 1,
289
+ cube_create_bindings: 1,
290
+ repository_associations: 1,
291
+ },
292
+ },
293
+ },
294
+ ];
295
+
296
+ export interface ResolveRepositoryCubeConformanceVector {
297
+ name: string;
298
+ request: ResolveRepositoryCubeRequest;
299
+ associated: boolean;
300
+ expected:
301
+ | { outcome: 'none'; status: 200; authority_state_delta: Record<string, never> }
302
+ | { outcome: 'resolved'; status: 200; authority_state_delta: Record<string, never> };
303
+ }
304
+
305
+ const REPOSITORY_CUBE_ONE = '00000000-0000-4000-8000-000000000131';
306
+ const REPOSITORY_ONE: ResolveRepositoryCubeRequest = {
307
+ working_repo_name: 'repository-one',
308
+ repository: { kind: 'origin', value: 'https://github.com/Byte-Ventures/repository-one' },
309
+ };
310
+ const REPOSITORY_TWO: ResolveRepositoryCubeRequest = {
311
+ working_repo_name: 'repository-two',
312
+ repository: { kind: 'origin', value: 'https://github.com/Byte-Ventures/repository-two' },
313
+ };
314
+
315
+ /** Resolution is read-only and returns only explicit none or authoritative stored fields. */
316
+ export const RESOLVE_REPOSITORY_CUBE_CONFORMANCE:
317
+ readonly ResolveRepositoryCubeConformanceVector[] = [
318
+ {
319
+ name: 'unassociated repository resolves explicit none without mutation',
320
+ request: REPOSITORY_ONE,
321
+ associated: false,
322
+ expected: { outcome: 'none', status: 200, authority_state_delta: {} },
323
+ },
324
+ {
325
+ name: 'associated repository resolves authoritative stored fields without mutation',
326
+ request: { ...REPOSITORY_ONE, working_repo_name: 'ignored-new-display' },
327
+ associated: true,
328
+ expected: { outcome: 'resolved', status: 200, authority_state_delta: {} },
329
+ },
330
+ ];
331
+
332
+ export interface AssociateRepositoryCubeConformanceVector {
333
+ name: string;
334
+ initial: AssociateRepositoryCubeRequest;
335
+ retry: AssociateRepositoryCubeRequest;
336
+ expected:
337
+ | {
338
+ outcome: 'resolved';
339
+ status: 200;
340
+ initial_authority_state_delta: { repository_associations: 1 };
341
+ retry_authority_state_delta: Record<string, never>;
342
+ }
343
+ | {
344
+ outcome: 'repository_conflict' | 'cube_conflict';
345
+ status: 409;
346
+ error: 'REPOSITORY_ALREADY_ASSOCIATED' | 'CUBE_ALREADY_ASSOCIATED';
347
+ diagnostic_disclosure: 'none';
348
+ retry_authority_state_delta: Record<string, never>;
349
+ };
350
+ }
351
+
352
+ /** Explicit cube IDs bind atomically; names never select or identify a cube. */
353
+ export const ASSOCIATE_REPOSITORY_CUBE_CONFORMANCE:
354
+ readonly AssociateRepositoryCubeConformanceVector[] = [
355
+ {
356
+ name: 'same explicit cube and repository binding is idempotent',
357
+ initial: { cube_id: REPOSITORY_CUBE_ONE, ...REPOSITORY_ONE },
358
+ retry: { cube_id: REPOSITORY_CUBE_ONE, ...REPOSITORY_ONE },
359
+ expected: {
360
+ outcome: 'resolved',
361
+ status: 200,
362
+ initial_authority_state_delta: { repository_associations: 1 },
363
+ retry_authority_state_delta: {},
364
+ },
365
+ },
366
+ {
367
+ name: 'repository already bound to another cube conflicts without mutation',
368
+ initial: { cube_id: REPOSITORY_CUBE_ONE, ...REPOSITORY_ONE },
369
+ retry: { cube_id: '00000000-0000-4000-8000-000000000132', ...REPOSITORY_ONE },
370
+ expected: {
371
+ outcome: 'repository_conflict',
372
+ status: 409,
373
+ error: 'REPOSITORY_ALREADY_ASSOCIATED',
374
+ diagnostic_disclosure: 'none',
375
+ retry_authority_state_delta: {},
376
+ },
377
+ },
378
+ {
379
+ name: 'cube already bound to another repository conflicts without mutation',
380
+ initial: { cube_id: REPOSITORY_CUBE_ONE, ...REPOSITORY_ONE },
381
+ retry: { cube_id: REPOSITORY_CUBE_ONE, ...REPOSITORY_TWO },
382
+ expected: {
383
+ outcome: 'cube_conflict',
384
+ status: 409,
385
+ error: 'CUBE_ALREADY_ASSOCIATED',
386
+ diagnostic_disclosure: 'none',
387
+ retry_authority_state_delta: {},
388
+ },
389
+ },
390
+ ];
391
+
392
+ export const REPOSITORY_CUBE_PERMISSION_CONFORMANCE = [
393
+ {
394
+ name: 'association denies an inaccessible explicit cube without mutation',
395
+ request: { cube_id: REPOSITORY_CUBE_ONE, ...REPOSITORY_ONE },
396
+ expected: { status: 403, error: 'ACCESS_DENIED', authority_state_delta: {} },
397
+ },
398
+ {
399
+ name: 'same-client binding to an inaccessible cube is non-enumerating',
400
+ request: { cube_id: REPOSITORY_CUBE_ONE, ...REPOSITORY_ONE },
401
+ precondition: 'repository_bound_to_inaccessible_cube',
402
+ expected: {
403
+ resolve: { status: 200, outcome: 'none', authority_state_delta: {} },
404
+ associate: {
405
+ status: 403,
406
+ error: 'ACCESS_DENIED',
407
+ diagnostic_disclosure: 'none',
408
+ authority_state_delta: {},
409
+ },
410
+ },
411
+ },
412
+ {
413
+ name: 'another client binding is neither resolved nor treated as a conflict',
414
+ request: { cube_id: REPOSITORY_CUBE_ONE, ...REPOSITORY_ONE },
415
+ precondition: 'repository_bound_by_another_client',
416
+ expected: {
417
+ resolve: { status: 200, outcome: 'none', authority_state_delta: {} },
418
+ associate: {
419
+ status: 200,
420
+ outcome: 'resolved',
421
+ authority_state_delta: { repository_associations: 1 },
422
+ },
423
+ },
424
+ },
425
+ ] as const;
426
+
427
+ export const REPOSITORY_CUBE_AUTHORITATIVE_STATE_CONFORMANCE = [
428
+ {
429
+ name: 'legacy cube with invalid authoritative roles is rejected without mutation',
430
+ request: { cube_id: REPOSITORY_CUBE_ONE, ...REPOSITORY_ONE },
431
+ expected: {
432
+ status: 409,
433
+ error: 'INVALID_INPUT',
434
+ diagnostic_disclosure: 'none',
435
+ authority_state_delta: {},
436
+ },
437
+ },
438
+ ] as const;
439
+
170
440
  export const ENROLLMENT_AUTHORITY_CONFORMANCE = [
171
441
  {
172
442
  name: 'ordinary enrollment creates no authority or cube state',
@@ -1,6 +1,7 @@
1
1
  import { ErrorCode } from './errors.js';
2
2
  import { PROTOCOL_VERSION, type ProtocolVersion } from './version.js';
3
3
  import {
4
+ canonicalizeRepositoryIdentity,
4
5
  RuntimeMetadataValidationError,
5
6
  validateRuntimeMetadata,
6
7
  validateRuntimeMetadataPatch,
@@ -12,12 +13,14 @@ import type {
12
13
  } from './types.js';
13
14
 
14
15
  export const SHARED_PACKAGE_NAME = 'borgmcp-shared' as const;
15
- export const SHARED_PACKAGE_VERSION = '0.6.2' as const;
16
+ export const SHARED_PACKAGE_VERSION = '0.6.4' as const;
16
17
 
17
18
  export const HEALTH_PATH = '/healthz' as const;
18
19
  export const PROTOCOL_INFO_PATH = '/api/protocol' as const;
19
20
  export const ENROLLMENT_EXCHANGE_PATH = '/api/enrollment/exchange' as const;
20
21
  export const CUBES_PATH = '/api/cubes' as const;
22
+ export const REPOSITORY_CUBE_RESOLVE_PATH = '/api/repository-cubes/resolve' as const;
23
+ export const REPOSITORY_CUBE_ASSOCIATION_PATH = '/api/repository-cubes/association' as const;
21
24
  export const ATTACH_PATH = '/api/client/attach' as const;
22
25
  export const SELF_RUNTIME_METADATA_PATH = '/api/cubes/:cubeId/drones/self/metadata' as const;
23
26
 
@@ -26,6 +29,20 @@ export const PROTOCOL_HTTP_CONTRACT = {
26
29
  protocol: { method: 'GET', path: PROTOCOL_INFO_PATH, authenticated: false, success_status: 200 },
27
30
  enrollment: { method: 'POST', path: ENROLLMENT_EXCHANGE_PATH, authenticated: 'invitation', success_status: 201 },
28
31
  cubes: { method: 'POST', path: CUBES_PATH, authenticated: true, success_status: 201 },
32
+ repository_cube_resolve: {
33
+ method: 'POST',
34
+ path: REPOSITORY_CUBE_RESOLVE_PATH,
35
+ authenticated: true,
36
+ success_status: 200,
37
+ mutation: false,
38
+ },
39
+ repository_cube_association: {
40
+ method: 'PUT',
41
+ path: REPOSITORY_CUBE_ASSOCIATION_PATH,
42
+ authenticated: true,
43
+ success_status: 200,
44
+ mutation: true,
45
+ },
29
46
  attach: { method: 'POST', path: ATTACH_PATH, authenticated: true, success_status: 200 },
30
47
  drone_reassign: {
31
48
  method: 'PATCH',
@@ -120,22 +137,60 @@ export type EnrollmentExchangeResponse =
120
137
  | ClientEnrollmentExchangeResponse
121
138
  | OwnerEnrollmentExchangeResponse;
122
139
 
123
- export const CUBE_TEMPLATES = ['default'] as const;
140
+ export const CUBE_TEMPLATES = ['default', 'software-dev', 'starter'] as const;
124
141
  export type CubeTemplate = (typeof CUBE_TEMPLATES)[number];
125
142
 
143
+ export type CreateCubeRepository =
144
+ | { kind: 'origin'; value: string }
145
+ | { kind: 'local'; value: string };
146
+
126
147
  export interface CreateCubeRequest {
127
148
  retry_key: string;
128
149
  name: string;
150
+ working_repo_name: string;
151
+ repository: CreateCubeRepository;
129
152
  template: CubeTemplate;
130
153
  }
131
154
 
132
155
  export interface CreateCubeResponse {
156
+ result: 'created' | 'resolved';
157
+ cube_id: string;
158
+ name: string;
159
+ working_repo_name: string;
160
+ repository: CreateCubeRepository;
161
+ template: CubeTemplate;
162
+ human_seat_role_id: string;
163
+ default_worker_role_id: string;
164
+ access: 'manage';
165
+ }
166
+
167
+ export interface ResolveRepositoryCubeRequest {
168
+ working_repo_name: string;
169
+ repository: CreateCubeRepository;
170
+ }
171
+
172
+ export interface AssociateRepositoryCubeRequest extends ResolveRepositoryCubeRequest {
173
+ cube_id: string;
174
+ }
175
+
176
+ export interface ResolvedRepositoryCube {
177
+ result: 'resolved';
133
178
  cube_id: string;
179
+ name: string;
180
+ working_repo_name: string;
181
+ repository: CreateCubeRepository;
182
+ template: CubeTemplate;
134
183
  human_seat_role_id: string;
135
184
  default_worker_role_id: string;
136
185
  access: 'manage';
137
186
  }
138
187
 
188
+ export type ResolveRepositoryCubeResponse =
189
+ | { result: 'none' }
190
+ | ResolvedRepositoryCube;
191
+
192
+ export type AssociateRepositoryCubeResponse = ResolvedRepositoryCube;
193
+
139
194
  export interface AckLogRequest {
140
195
  entry_id: string;
141
196
  kind: 'ack' | 'claim';
@@ -434,17 +489,24 @@ function decodeExactServerCapabilities(
434
489
 
435
490
  export function decodeCreateCubeRequest(value: unknown): CreateCubeRequest {
436
491
  const input = record(value);
437
- exactKeys(input, ['retry_key', 'name', 'template'], ['retry_key', 'name', 'template']);
492
+ exactKeys(
493
+ input,
494
+ ['retry_key', 'name', 'working_repo_name', 'repository', 'template'],
495
+ ['retry_key', 'name', 'working_repo_name', 'repository', 'template'],
496
+ );
438
497
  const name = boundedString(input.name, 1, 120, ['name']);
439
498
  if (!/^[A-Za-z0-9][A-Za-z0-9 ._-]*$/.test(name)) {
440
499
  fail('Cube name contains unsupported characters.', ['name']);
441
500
  }
501
+ const workingRepoName = decodeWorkingRepositoryName(input.working_repo_name, ['working_repo_name']);
442
502
  if (!CUBE_TEMPLATES.includes(input.template as CubeTemplate)) {
443
503
  fail('Unsupported cube template.', ['template']);
444
504
  }
445
505
  return {
446
506
  retry_key: decodeUuid(input.retry_key, ['retry_key']),
447
507
  name,
508
+ working_repo_name: workingRepoName,
509
+ repository: decodeCreateCubeRepository(input.repository, ['repository']),
448
510
  template: input.template as CubeTemplate,
449
511
  };
450
512
  }
@@ -457,22 +519,203 @@ export function decodeCreateCubeResponse(value: unknown): CreateCubeResponse {
457
519
  const input = record(value);
458
520
  exactKeys(
459
521
  input,
460
- ['cube_id', 'human_seat_role_id', 'default_worker_role_id', 'access'],
461
- ['cube_id', 'human_seat_role_id', 'default_worker_role_id', 'access'],
522
+ [
523
+ 'result',
524
+ 'cube_id',
525
+ 'name',
526
+ 'working_repo_name',
527
+ 'repository',
528
+ 'template',
529
+ 'human_seat_role_id',
530
+ 'default_worker_role_id',
531
+ 'access',
532
+ ],
533
+ [
534
+ 'result',
535
+ 'cube_id',
536
+ 'name',
537
+ 'working_repo_name',
538
+ 'repository',
539
+ 'template',
540
+ 'human_seat_role_id',
541
+ 'default_worker_role_id',
542
+ 'access',
543
+ ],
462
544
  );
545
+ if (input.result !== 'created' && input.result !== 'resolved') {
546
+ fail('Invalid cube creation result.', ['result']);
547
+ }
548
+ const name = boundedString(input.name, 1, 120, ['name']);
549
+ if (!/^[A-Za-z0-9][A-Za-z0-9 ._-]*$/.test(name)) {
550
+ fail('Cube name contains unsupported characters.', ['name']);
551
+ }
552
+ const workingRepoName = decodeWorkingRepositoryName(input.working_repo_name, ['working_repo_name']);
553
+ if (!CUBE_TEMPLATES.includes(input.template as CubeTemplate)) {
554
+ fail('Unsupported cube template.', ['template']);
555
+ }
463
556
  if (input.access !== 'manage') fail('Created cube access must be manage.', ['access']);
464
557
  return {
558
+ result: input.result,
465
559
  cube_id: decodeUuid(input.cube_id, ['cube_id']),
560
+ name,
561
+ working_repo_name: workingRepoName,
562
+ repository: decodeCreateCubeRepository(input.repository, ['repository']),
563
+ template: input.template as CubeTemplate,
466
564
  human_seat_role_id: decodeUuid(input.human_seat_role_id, ['human_seat_role_id']),
467
565
  default_worker_role_id: decodeUuid(input.default_worker_role_id, ['default_worker_role_id']),
468
566
  access: 'manage',
469
567
  };
470
568
  }
471
569
 
570
+ function decodeCreateCubeRepository(
571
+ value: unknown,
572
+ path: readonly (string | number)[],
573
+ ): CreateCubeRepository {
574
+ const input = record(value, path);
575
+ exactKeys(input, ['kind', 'value'], ['kind', 'value'], path);
576
+ if (input.kind === 'local') {
577
+ return { kind: 'local', value: decodeUuid(input.value, [...path, 'value']) };
578
+ }
579
+ if (input.kind !== 'origin') fail('Unsupported repository identity kind.', [...path, 'kind']);
580
+ const origin = boundedString(input.value, 1, 512, [...path, 'value']);
581
+ try {
582
+ if (canonicalizeRepositoryIdentity(origin).working_repo_origin !== origin) {
583
+ fail('Repository origin must be canonical.', [...path, 'value']);
584
+ }
585
+ } catch (error) {
586
+ if (error instanceof ProtocolContractError) throw error;
587
+ if (error instanceof RuntimeMetadataValidationError) {
588
+ fail('Repository origin must be canonical.', [...path, 'value']);
589
+ }
590
+ throw error;
591
+ }
592
+ return { kind: 'origin', value: origin };
593
+ }
594
+
472
595
  export function decodeCreateCubeResponseEnvelope(value: unknown): ProtocolEnvelope<CreateCubeResponse> {
473
596
  return decodeProtocolEnvelope(value, decodeCreateCubeResponse);
474
597
  }
475
598
 
599
+ export function decodeResolveRepositoryCubeRequest(value: unknown): ResolveRepositoryCubeRequest {
600
+ const input = record(value);
601
+ exactKeys(input, ['working_repo_name', 'repository'], ['working_repo_name', 'repository']);
602
+ return {
603
+ working_repo_name: decodeWorkingRepositoryName(input.working_repo_name, ['working_repo_name']),
604
+ repository: decodeCreateCubeRepository(input.repository, ['repository']),
605
+ };
606
+ }
607
+
608
+ export function decodeResolveRepositoryCubeRequestEnvelope(
609
+ value: unknown,
610
+ ): ProtocolEnvelope<ResolveRepositoryCubeRequest> {
611
+ return decodeProtocolEnvelope(value, decodeResolveRepositoryCubeRequest);
612
+ }
613
+
614
+ export function decodeAssociateRepositoryCubeRequest(value: unknown): AssociateRepositoryCubeRequest {
615
+ const input = record(value);
616
+ exactKeys(
617
+ input,
618
+ ['cube_id', 'working_repo_name', 'repository'],
619
+ ['cube_id', 'working_repo_name', 'repository'],
620
+ );
621
+ return {
622
+ cube_id: decodeUuid(input.cube_id, ['cube_id']),
623
+ working_repo_name: decodeWorkingRepositoryName(input.working_repo_name, ['working_repo_name']),
624
+ repository: decodeCreateCubeRepository(input.repository, ['repository']),
625
+ };
626
+ }
627
+
628
+ export function decodeAssociateRepositoryCubeRequestEnvelope(
629
+ value: unknown,
630
+ ): ProtocolEnvelope<AssociateRepositoryCubeRequest> {
631
+ return decodeProtocolEnvelope(value, decodeAssociateRepositoryCubeRequest);
632
+ }
633
+
634
+ function decodeResolvedRepositoryCube(value: unknown): ResolvedRepositoryCube {
635
+ const input = record(value);
636
+ exactKeys(
637
+ input,
638
+ [
639
+ 'result',
640
+ 'cube_id',
641
+ 'name',
642
+ 'working_repo_name',
643
+ 'repository',
644
+ 'template',
645
+ 'human_seat_role_id',
646
+ 'default_worker_role_id',
647
+ 'access',
648
+ ],
649
+ [
650
+ 'result',
651
+ 'cube_id',
652
+ 'name',
653
+ 'working_repo_name',
654
+ 'repository',
655
+ 'template',
656
+ 'human_seat_role_id',
657
+ 'default_worker_role_id',
658
+ 'access',
659
+ ],
660
+ );
661
+ if (input.result !== 'resolved') fail('Invalid repository cube result.', ['result']);
662
+ const name = boundedString(input.name, 1, 120, ['name']);
663
+ if (!/^[A-Za-z0-9][A-Za-z0-9 ._-]*$/.test(name)) {
664
+ fail('Cube name contains unsupported characters.', ['name']);
665
+ }
666
+ if (!CUBE_TEMPLATES.includes(input.template as CubeTemplate)) {
667
+ fail('Unsupported cube template.', ['template']);
668
+ }
669
+ if (input.access !== 'manage') fail('Repository cube access must be manage.', ['access']);
670
+ return {
671
+ result: 'resolved',
672
+ cube_id: decodeUuid(input.cube_id, ['cube_id']),
673
+ name,
674
+ working_repo_name: decodeWorkingRepositoryName(input.working_repo_name, ['working_repo_name']),
675
+ repository: decodeCreateCubeRepository(input.repository, ['repository']),
676
+ template: input.template as CubeTemplate,
677
+ human_seat_role_id: decodeUuid(input.human_seat_role_id, ['human_seat_role_id']),
678
+ default_worker_role_id: decodeUuid(input.default_worker_role_id, ['default_worker_role_id']),
679
+ access: 'manage',
680
+ };
681
+ }
682
+
683
+ export function decodeResolveRepositoryCubeResponse(value: unknown): ResolveRepositoryCubeResponse {
684
+ const input = record(value);
685
+ if (input.result === 'none') {
686
+ exactKeys(input, ['result'], ['result']);
687
+ return { result: 'none' };
688
+ }
689
+ return decodeResolvedRepositoryCube(input);
690
+ }
691
+
692
+ export function decodeResolveRepositoryCubeResponseEnvelope(
693
+ value: unknown,
694
+ ): ProtocolEnvelope<ResolveRepositoryCubeResponse> {
695
+ return decodeProtocolEnvelope(value, decodeResolveRepositoryCubeResponse);
696
+ }
697
+
698
+ export function decodeAssociateRepositoryCubeResponse(value: unknown): AssociateRepositoryCubeResponse {
699
+ return decodeResolvedRepositoryCube(value);
700
+ }
701
+
702
+ export function decodeAssociateRepositoryCubeResponseEnvelope(
703
+ value: unknown,
704
+ ): ProtocolEnvelope<AssociateRepositoryCubeResponse> {
705
+ return decodeProtocolEnvelope(value, decodeAssociateRepositoryCubeResponse);
706
+ }
707
+
708
+ function decodeWorkingRepositoryName(
709
+ value: unknown,
710
+ path: readonly (string | number)[],
711
+ ): string {
712
+ const name = boundedString(value, 1, 120, path);
713
+ if (!/^[A-Za-z0-9][A-Za-z0-9 ._-]*$/.test(name)) {
714
+ fail('Repository name contains unsupported characters.', path);
715
+ }
716
+ return name;
717
+ }
718
+
476
719
  export function decodeAppendLogRequest(value: unknown): import('./types.js').AppendLogRequest {
477
720
  const input = record(value);
478
721
  exactKeys(
@@ -698,7 +941,7 @@ export function decodeUpdateDroneRuntimeMetadataResponseEnvelope(
698
941
  return decodeProtocolEnvelope(value, decodeUpdateDroneRuntimeMetadataResponse);
699
942
  }
700
943
 
701
- // ── v3 clean-slate wire types ──────────────────────────────────────────────
944
+ // ── Clean-slate attach wire types (introduced in v3) ──────────────────────
702
945
 
703
946
  export interface AttachRequest {
704
947
  cube_id: string;
@@ -800,7 +1043,7 @@ function decodeAttachSession(value: unknown, path: readonly (string | number)[])
800
1043
  }
801
1044
 
802
1045
  /**
803
- * Decode a v3 attach request. Strict: exact keys, bounded sizes,
1046
+ * Decode an attach request. Strict: exact keys, bounded sizes,
804
1047
  * session_credential is token-safe and never echoed in errors.
805
1048
  */
806
1049
  export function decodeAttachRequest(value: unknown): AttachRequest {
@@ -825,7 +1068,7 @@ export function decodeAttachRequest(value: unknown): AttachRequest {
825
1068
  }
826
1069
 
827
1070
  /**
828
- * Create a v3 attach request envelope. Stamps the canonical protocol version.
1071
+ * Create an attach request envelope. Stamps the canonical protocol version.
829
1072
  */
830
1073
  export function createAttachRequestEnvelope(
831
1074
  requestId: string,
@@ -839,7 +1082,7 @@ export function createAttachRequestEnvelope(
839
1082
  }
840
1083
 
841
1084
  /**
842
- * Decode a v3 attach request envelope. Verifies protocol_version === PROTOCOL_VERSION
1085
+ * Decode an attach request envelope. Verifies protocol_version === PROTOCOL_VERSION
843
1086
  * BEFORE decoding the payload — a wrong tag never invokes the payload decoder
844
1087
  * and never exposes or returns the supplied session_credential.
845
1088
  * Uses a static token-safe diagnostic; does not interpolate attacker-controlled text.
@@ -869,7 +1112,7 @@ export function decodeAttachRequestEnvelope(
869
1112
  }
870
1113
 
871
1114
  /**
872
- * Decode a v3 attach response. Strict: exact keys and result discriminant.
1115
+ * Decode an attach response. Strict: exact keys and result discriminant.
873
1116
  */
874
1117
  export function decodeAttachResponse(value: unknown): AttachResponse {
875
1118
  const input = record(value);
@@ -893,7 +1136,7 @@ export function decodeAttachResponse(value: unknown): AttachResponse {
893
1136
  }
894
1137
 
895
1138
  /**
896
- * Decode a v3 attach response wrapped in a ProtocolEnvelope.
1139
+ * Decode an attach response wrapped in a ProtocolEnvelope.
897
1140
  * Verifies protocol_version === PROTOCOL_VERSION before decoding payload.
898
1141
  */
899
1142
  export function decodeAttachResponseEnvelope(value: unknown): ProtocolEnvelope<AttachResponse> {
@@ -13,6 +13,8 @@ export enum ErrorCode {
13
13
  DATABASE_ERROR = 'DATABASE_ERROR',
14
14
  EXTERNAL_SERVICE_ERROR = 'EXTERNAL_SERVICE_ERROR',
15
15
  NOT_FOUND = 'NOT_FOUND',
16
+ REPOSITORY_ALREADY_ASSOCIATED = 'REPOSITORY_ALREADY_ASSOCIATED',
17
+ CUBE_ALREADY_ASSOCIATED = 'CUBE_ALREADY_ASSOCIATED',
16
18
  ROLE_IN_USE = 'ROLE_IN_USE',
17
19
  ROLE_HAS_FROZEN_DRONES = 'ROLE_HAS_FROZEN_DRONES',
18
20
  DRONE_EVICTED = 'DRONE_EVICTED',
@@ -1,4 +1,4 @@
1
- /** Current Borg coordination protocol generation. Clean-slate v3. */
2
- export const PROTOCOL_VERSION = '3' as const;
1
+ /** Current Borg coordination protocol generation. Clean-slate v5. */
2
+ export const PROTOCOL_VERSION = '5' as const;
3
3
 
4
4
  export type ProtocolVersion = typeof PROTOCOL_VERSION;