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.
- package/README.md +30 -21
- package/dist/conformance/adapter.d.ts +29 -2
- package/dist/conformance/adapter.d.ts.map +1 -1
- package/dist/conformance/adapter.js +202 -14
- package/dist/conformance/adapter.js.map +1 -1
- package/dist/conformance/index.d.ts +49 -0
- package/dist/conformance/index.d.ts.map +1 -1
- package/dist/conformance/index.js +111 -0
- package/dist/conformance/index.js.map +1 -1
- package/dist/protocol/contract.d.ts +41 -6
- package/dist/protocol/contract.d.ts.map +1 -1
- package/dist/protocol/contract.js +82 -15
- package/dist/protocol/contract.js.map +1 -1
- package/dist/protocol/version.d.ts.map +1 -1
- package/dist/protocol/version.js +6 -1
- package/dist/protocol/version.js.map +1 -1
- package/docs/compatibility.md +40 -9
- package/docs/enrollment.md +160 -0
- package/docs/releasing.md +71 -6
- package/package.json +2 -1
- package/src/conformance/adapter.ts +364 -15
- package/src/conformance/index.ts +139 -0
- package/src/protocol/contract.ts +139 -23
- package/src/protocol/version.ts +6 -1
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
ProtocolContractError,
|
|
4
4
|
compareLogCursor,
|
|
5
5
|
createProtocolEnvelope,
|
|
6
|
+
decodeCreateCubeResponseEnvelope,
|
|
6
7
|
decodeAppendLogResultEnvelope,
|
|
7
8
|
decodeDecisionResultEnvelope,
|
|
8
9
|
decodeDecisionsResultEnvelope,
|
|
@@ -14,10 +15,12 @@ import {
|
|
|
14
15
|
negotiateProtocol,
|
|
15
16
|
utf8ByteLength,
|
|
16
17
|
type Capability,
|
|
18
|
+
type CreateCubeResponse,
|
|
17
19
|
type LogCursor,
|
|
18
20
|
type ProtocolInfo,
|
|
19
21
|
type StreamEvent,
|
|
20
22
|
} from '../protocol/index.js';
|
|
23
|
+
import { ENROLLMENT_RETRY_CONFORMANCE } from './index.js';
|
|
21
24
|
|
|
22
25
|
export interface ConformanceHttpResponse {
|
|
23
26
|
status: number;
|
|
@@ -32,6 +35,30 @@ export interface ConformanceCube {
|
|
|
32
35
|
readonly id: string;
|
|
33
36
|
}
|
|
34
37
|
|
|
38
|
+
export interface ConformanceAuthorityState {
|
|
39
|
+
enrolled_clients: number;
|
|
40
|
+
enrollment_claims: number;
|
|
41
|
+
cubes: number;
|
|
42
|
+
roles: number;
|
|
43
|
+
grants: number;
|
|
44
|
+
server_capabilities: number;
|
|
45
|
+
cube_create_bindings: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface ConformanceCreatedCubeState {
|
|
49
|
+
cube_exists: boolean;
|
|
50
|
+
creator_has_grant: boolean;
|
|
51
|
+
grant_count: number;
|
|
52
|
+
role_count: number;
|
|
53
|
+
human_seat_role_matches: boolean;
|
|
54
|
+
default_worker_role_matches: boolean;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface ConformanceEnrollmentPrincipalState {
|
|
58
|
+
response_client_matches: boolean;
|
|
59
|
+
active_credential_bindings: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
35
62
|
export interface ConformanceReplayBarrier {
|
|
36
63
|
/** Resolves after the replay snapshot is read but before live delivery is active. */
|
|
37
64
|
readonly reached: Promise<void>;
|
|
@@ -52,7 +79,18 @@ export interface ConformanceAdmin {
|
|
|
52
79
|
createPrincipal(name: string): Promise<ConformancePrincipal>;
|
|
53
80
|
createCube(name: string): Promise<ConformanceCube>;
|
|
54
81
|
grantCube(principal: ConformancePrincipal, cube: ConformanceCube): Promise<void>;
|
|
55
|
-
|
|
82
|
+
grantCreateCubeCapability(principal: ConformancePrincipal): Promise<void>;
|
|
83
|
+
issueDroneSession(principal: ConformancePrincipal): Promise<string>;
|
|
84
|
+
issueSingleUseInvitation(principal: ConformancePrincipal, purpose: 'owner' | 'client'): Promise<string>;
|
|
85
|
+
observeAuthorityState(): Promise<ConformanceAuthorityState>;
|
|
86
|
+
inspectCreatedCube(
|
|
87
|
+
creator: ConformancePrincipal,
|
|
88
|
+
response: CreateCubeResponse,
|
|
89
|
+
): Promise<ConformanceCreatedCubeState>;
|
|
90
|
+
inspectEnrollmentPrincipal(
|
|
91
|
+
principal: ConformancePrincipal,
|
|
92
|
+
responseClientId: string,
|
|
93
|
+
): Promise<ConformanceEnrollmentPrincipalState>;
|
|
56
94
|
revokePrincipal(principal: ConformancePrincipal): Promise<void>;
|
|
57
95
|
expireCursor(cube: ConformanceCube, cursor: LogCursor): Promise<void>;
|
|
58
96
|
armReplayTransition(): ConformanceReplayBarrier;
|
|
@@ -63,6 +101,7 @@ export interface ConformanceOperations {
|
|
|
63
101
|
health(): Promise<ConformanceHttpResponse>;
|
|
64
102
|
protocol(credential: string | null): Promise<ConformanceHttpResponse>;
|
|
65
103
|
enroll(request: unknown): Promise<ConformanceHttpResponse>;
|
|
104
|
+
createCube(credential: string | null, request: unknown): Promise<ConformanceHttpResponse>;
|
|
66
105
|
append(
|
|
67
106
|
credential: string,
|
|
68
107
|
cube: ConformanceCube,
|
|
@@ -294,6 +333,50 @@ function same(left: unknown, right: unknown): boolean {
|
|
|
294
333
|
return JSON.stringify(left) === JSON.stringify(right);
|
|
295
334
|
}
|
|
296
335
|
|
|
336
|
+
function assertStateDelta(
|
|
337
|
+
before: ConformanceAuthorityState,
|
|
338
|
+
after: ConformanceAuthorityState,
|
|
339
|
+
expected: Partial<ConformanceAuthorityState>,
|
|
340
|
+
description: string,
|
|
341
|
+
): void {
|
|
342
|
+
for (const key of Object.keys(before) as Array<keyof ConformanceAuthorityState>) {
|
|
343
|
+
invariant(
|
|
344
|
+
after[key] - before[key] === (expected[key] ?? 0),
|
|
345
|
+
`${description} changed ${key} by ${after[key] - before[key]}; expected ${expected[key] ?? 0}.`,
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function assertEnrollmentErrorIsSecretFree(
|
|
351
|
+
response: ConformanceHttpResponse,
|
|
352
|
+
requests: readonly { invitation: string; retry_key: string; client_credential: string }[],
|
|
353
|
+
description: string,
|
|
354
|
+
): void {
|
|
355
|
+
const diagnostic = JSON.stringify(response.body);
|
|
356
|
+
const secrets = new Set(requests.flatMap((request) => [
|
|
357
|
+
request.invitation,
|
|
358
|
+
request.retry_key,
|
|
359
|
+
request.client_credential,
|
|
360
|
+
]));
|
|
361
|
+
for (const secret of secrets) {
|
|
362
|
+
invariant(!diagnostic.includes(secret), `${description} exposed enrollment retry material.`);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function expectSecretFreeError(
|
|
367
|
+
response: ConformanceHttpResponse,
|
|
368
|
+
status: number,
|
|
369
|
+
code: ErrorCode,
|
|
370
|
+
operation: string,
|
|
371
|
+
secrets: readonly string[],
|
|
372
|
+
): void {
|
|
373
|
+
expectError(response, status, code, operation);
|
|
374
|
+
const diagnostic = JSON.stringify(response.body);
|
|
375
|
+
for (const secret of new Set(secrets)) {
|
|
376
|
+
invariant(!diagnostic.includes(secret), `${operation} exposed retry material.`);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
297
380
|
export async function runAdapterConformance(
|
|
298
381
|
environment: ConformanceEnvironment,
|
|
299
382
|
options: AdapterConformanceOptions = {},
|
|
@@ -320,14 +403,10 @@ export async function runAdapterConformance(
|
|
|
320
403
|
};
|
|
321
404
|
|
|
322
405
|
await environment.admin.reset();
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
await environment.admin.grantCube(principalA, cubeA);
|
|
328
|
-
await environment.admin.grantCube(principalB, cubeB);
|
|
329
|
-
const invitationA = await environment.admin.issueSingleUseInvitation(principalA);
|
|
330
|
-
const invitationB = await environment.admin.issueSingleUseInvitation(principalB);
|
|
406
|
+
let principalA!: ConformancePrincipal;
|
|
407
|
+
let principalB!: ConformancePrincipal;
|
|
408
|
+
let cubeA!: ConformanceCube;
|
|
409
|
+
let cubeB!: ConformanceCube;
|
|
331
410
|
|
|
332
411
|
let credentialA = '';
|
|
333
412
|
let credentialB = '';
|
|
@@ -342,26 +421,294 @@ export async function runAdapterConformance(
|
|
|
342
421
|
|
|
343
422
|
await record('protocol.enrollment-auth', async () => {
|
|
344
423
|
expectError(await environment.operations.protocol(null), 401, ErrorCode.AUTH_MISSING, 'Unauthenticated protocol request');
|
|
424
|
+
|
|
425
|
+
const retryVectorErrors: string[] = [];
|
|
426
|
+
for (const [index, vector] of ENROLLMENT_RETRY_CONFORMANCE.entries()) {
|
|
427
|
+
for (const purpose of ['client', 'owner'] as const) {
|
|
428
|
+
await environment.admin.reset();
|
|
429
|
+
try {
|
|
430
|
+
const principal = await environment.admin.createPrincipal(`${purpose}-retry-vector-${index}`);
|
|
431
|
+
const invitation = await environment.admin.issueSingleUseInvitation(principal, purpose);
|
|
432
|
+
const initialPayload = { ...vector.initial, invitation };
|
|
433
|
+
const retryPayload = { ...vector.retry, invitation };
|
|
434
|
+
const beforeInitial = await environment.admin.observeAuthorityState();
|
|
435
|
+
const initialResponse = await environment.operations.enroll(
|
|
436
|
+
createProtocolEnvelope(`retry-${index}-initial`, initialPayload),
|
|
437
|
+
);
|
|
438
|
+
expectStatus(initialResponse, 201, `${vector.name} initial request`);
|
|
439
|
+
const initial = decodeEnrollmentExchangeResponseEnvelope(initialResponse.body).payload;
|
|
440
|
+
invariant(initial.purpose === purpose, `${purpose} ${vector.name} returned the wrong purpose.`);
|
|
441
|
+
invariant(
|
|
442
|
+
same(initial.server_capabilities, purpose === 'owner' ? ['create_cube'] : []),
|
|
443
|
+
`${purpose} ${vector.name} returned incorrect server authority.`,
|
|
444
|
+
);
|
|
445
|
+
const afterInitial = await environment.admin.observeAuthorityState();
|
|
446
|
+
assertStateDelta(
|
|
447
|
+
beforeInitial,
|
|
448
|
+
afterInitial,
|
|
449
|
+
{
|
|
450
|
+
enrolled_clients: 1,
|
|
451
|
+
enrollment_claims: 1,
|
|
452
|
+
server_capabilities: purpose === 'owner' ? 1 : 0,
|
|
453
|
+
},
|
|
454
|
+
`${purpose} ${vector.name} initial request`,
|
|
455
|
+
);
|
|
456
|
+
|
|
457
|
+
const beforeRetry = await environment.admin.observeAuthorityState();
|
|
458
|
+
const retryResponse = await environment.operations.enroll(
|
|
459
|
+
createProtocolEnvelope(`retry-${index}-retry`, retryPayload),
|
|
460
|
+
);
|
|
461
|
+
if (vector.expected.outcome === 'stable_non_secret_identity') {
|
|
462
|
+
expectStatus(retryResponse, 201, `${purpose} ${vector.name}`);
|
|
463
|
+
const retry = decodeEnrollmentExchangeResponseEnvelope(retryResponse.body).payload;
|
|
464
|
+
invariant(same(initial, retry), `${purpose} ${vector.name} returned different identities.`);
|
|
465
|
+
for (const field of vector.expected.forbidden_response_fields) {
|
|
466
|
+
invariant(!(field in retry), `${purpose} ${vector.name} returned forbidden field ${field}.`);
|
|
467
|
+
}
|
|
468
|
+
} else {
|
|
469
|
+
expectError(retryResponse, vector.expected.status, ErrorCode.AUTH_INVALID, `${purpose} ${vector.name}`);
|
|
470
|
+
assertEnrollmentErrorIsSecretFree(
|
|
471
|
+
retryResponse,
|
|
472
|
+
[initialPayload, retryPayload],
|
|
473
|
+
`${purpose} ${vector.name}`,
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
assertStateDelta(beforeRetry, await environment.admin.observeAuthorityState(), {}, `${purpose} ${vector.name} retry`);
|
|
477
|
+
expectStatus(
|
|
478
|
+
await environment.operations.protocol(vector.initial.client_credential),
|
|
479
|
+
200,
|
|
480
|
+
`${purpose} ${vector.name} original credential continuity`,
|
|
481
|
+
);
|
|
482
|
+
if (vector.retry.client_credential !== vector.initial.client_credential) {
|
|
483
|
+
expectError(
|
|
484
|
+
await environment.operations.protocol(vector.retry.client_credential),
|
|
485
|
+
401,
|
|
486
|
+
ErrorCode.AUTH_INVALID,
|
|
487
|
+
`${purpose} ${vector.name} mismatched credential rejection`,
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
invariant(
|
|
491
|
+
same(await environment.admin.inspectEnrollmentPrincipal(principal, initial.client_id), {
|
|
492
|
+
response_client_matches: true,
|
|
493
|
+
active_credential_bindings: 1,
|
|
494
|
+
}),
|
|
495
|
+
`${purpose} ${vector.name} changed enrollment binding ownership.`,
|
|
496
|
+
);
|
|
497
|
+
} catch (error) {
|
|
498
|
+
retryVectorErrors.push(
|
|
499
|
+
`${purpose} ${vector.name}: ${error instanceof Error ? error.message : String(error)}`,
|
|
500
|
+
);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
invariant(retryVectorErrors.length === 0, retryVectorErrors.join(' | '));
|
|
505
|
+
|
|
506
|
+
await environment.admin.reset();
|
|
507
|
+
const ownerPrincipal = await environment.admin.createPrincipal('owner');
|
|
508
|
+
const ordinaryPrincipal = await environment.admin.createPrincipal('ordinary');
|
|
509
|
+
const ownerInvitation = await environment.admin.issueSingleUseInvitation(ownerPrincipal, 'owner');
|
|
510
|
+
const ordinaryInvitation = await environment.admin.issueSingleUseInvitation(ordinaryPrincipal, 'client');
|
|
511
|
+
const ownerCredential = `${'Q'.repeat(42)}U`;
|
|
512
|
+
const ordinaryCredential = `${'Y'.repeat(42)}U`;
|
|
513
|
+
const beforeAuthorityEnrollment = await environment.admin.observeAuthorityState();
|
|
514
|
+
const ownerResponse = await environment.operations.enroll(createProtocolEnvelope('owner-enroll', {
|
|
515
|
+
invitation: ownerInvitation,
|
|
516
|
+
retry_key: '00000000-0000-4000-8000-000000000211',
|
|
517
|
+
client_credential: ownerCredential,
|
|
518
|
+
client_name: 'owner-client',
|
|
519
|
+
}));
|
|
520
|
+
const ordinaryResponse = await environment.operations.enroll(createProtocolEnvelope('ordinary-enroll', {
|
|
521
|
+
invitation: ordinaryInvitation,
|
|
522
|
+
retry_key: '00000000-0000-4000-8000-000000000212',
|
|
523
|
+
client_credential: ordinaryCredential,
|
|
524
|
+
client_name: 'ordinary-client',
|
|
525
|
+
}));
|
|
526
|
+
expectStatus(ownerResponse, 201, 'Owner enrollment');
|
|
527
|
+
expectStatus(ordinaryResponse, 201, 'Ordinary enrollment');
|
|
528
|
+
const owner = decodeEnrollmentExchangeResponseEnvelope(ownerResponse.body).payload;
|
|
529
|
+
const ordinary = decodeEnrollmentExchangeResponseEnvelope(ordinaryResponse.body).payload;
|
|
530
|
+
invariant(owner.purpose === 'owner' && same(owner.server_capabilities, ['create_cube']), 'Owner enrollment lacked exact create-cube authority.');
|
|
531
|
+
invariant(ordinary.purpose === 'client' && ordinary.server_capabilities.length === 0, 'Ordinary enrollment gained authority.');
|
|
532
|
+
assertStateDelta(
|
|
533
|
+
beforeAuthorityEnrollment,
|
|
534
|
+
await environment.admin.observeAuthorityState(),
|
|
535
|
+
{ enrolled_clients: 2, enrollment_claims: 2, server_capabilities: 1 },
|
|
536
|
+
'Owner and ordinary enrollment',
|
|
537
|
+
);
|
|
538
|
+
|
|
539
|
+
const cubeRequest = {
|
|
540
|
+
retry_key: '00000000-0000-4000-8000-000000000213',
|
|
541
|
+
name: 'repository-one',
|
|
542
|
+
template: 'default',
|
|
543
|
+
};
|
|
544
|
+
const droneCredential = await environment.admin.issueDroneSession(ownerPrincipal);
|
|
545
|
+
const beforeDeniedCreate = await environment.admin.observeAuthorityState();
|
|
546
|
+
expectSecretFreeError(
|
|
547
|
+
await environment.operations.createCube(null, createProtocolEnvelope('cube-missing-auth', cubeRequest)),
|
|
548
|
+
401,
|
|
549
|
+
ErrorCode.AUTH_MISSING,
|
|
550
|
+
'Missing-auth cube create',
|
|
551
|
+
[cubeRequest.retry_key],
|
|
552
|
+
);
|
|
553
|
+
expectSecretFreeError(
|
|
554
|
+
await environment.operations.createCube('invalid-credential', createProtocolEnvelope('cube-invalid-auth', cubeRequest)),
|
|
555
|
+
401,
|
|
556
|
+
ErrorCode.AUTH_INVALID,
|
|
557
|
+
'Invalid-auth cube create',
|
|
558
|
+
[cubeRequest.retry_key],
|
|
559
|
+
);
|
|
560
|
+
expectSecretFreeError(
|
|
561
|
+
await environment.operations.createCube(ordinaryCredential, createProtocolEnvelope('cube-denied', cubeRequest)),
|
|
562
|
+
403,
|
|
563
|
+
ErrorCode.ACCESS_DENIED,
|
|
564
|
+
'Ordinary cube create',
|
|
565
|
+
[cubeRequest.retry_key],
|
|
566
|
+
);
|
|
567
|
+
expectSecretFreeError(
|
|
568
|
+
await environment.operations.createCube(droneCredential, createProtocolEnvelope('cube-drone-denied', cubeRequest)),
|
|
569
|
+
403,
|
|
570
|
+
ErrorCode.ACCESS_DENIED,
|
|
571
|
+
'Drone-session cube create',
|
|
572
|
+
[cubeRequest.retry_key],
|
|
573
|
+
);
|
|
574
|
+
assertStateDelta(beforeDeniedCreate, await environment.admin.observeAuthorityState(), {}, 'Denied ordinary cube create');
|
|
575
|
+
const beforeCreate = await environment.admin.observeAuthorityState();
|
|
576
|
+
const createdResponse = await environment.operations.createCube(ownerCredential, createProtocolEnvelope('cube-create', cubeRequest));
|
|
577
|
+
expectStatus(createdResponse, 201, 'Owner cube create');
|
|
578
|
+
const created = decodeCreateCubeResponseEnvelope(createdResponse.body).payload;
|
|
579
|
+
assertStateDelta(beforeCreate, await environment.admin.observeAuthorityState(), { cubes: 1, roles: 2, grants: 1, cube_create_bindings: 1 }, 'Owner cube create');
|
|
580
|
+
invariant(
|
|
581
|
+
same(await environment.admin.inspectCreatedCube(ownerPrincipal, created), {
|
|
582
|
+
cube_exists: true,
|
|
583
|
+
creator_has_grant: true,
|
|
584
|
+
grant_count: 1,
|
|
585
|
+
role_count: 2,
|
|
586
|
+
human_seat_role_matches: true,
|
|
587
|
+
default_worker_role_matches: true,
|
|
588
|
+
}),
|
|
589
|
+
'Created cube identities or creator grant did not match persisted authority state.',
|
|
590
|
+
);
|
|
591
|
+
const beforeCreateRetry = await environment.admin.observeAuthorityState();
|
|
592
|
+
const retriedCreateResponse = await environment.operations.createCube(ownerCredential, createProtocolEnvelope('cube-retry', cubeRequest));
|
|
593
|
+
expectStatus(retriedCreateResponse, 201, 'Exact cube-create retry');
|
|
594
|
+
invariant(same(decodeCreateCubeResponseEnvelope(retriedCreateResponse.body).payload, created), 'Exact cube-create retry returned different identities.');
|
|
595
|
+
assertStateDelta(beforeCreateRetry, await environment.admin.observeAuthorityState(), {}, 'Exact cube-create retry');
|
|
596
|
+
const beforeCreateMismatch = await environment.admin.observeAuthorityState();
|
|
597
|
+
expectSecretFreeError(
|
|
598
|
+
await environment.operations.createCube(ownerCredential, createProtocolEnvelope('cube-mismatch', { ...cubeRequest, name: 'repository-two' })),
|
|
599
|
+
409,
|
|
600
|
+
ErrorCode.INVALID_INPUT,
|
|
601
|
+
'Cube-create retry mismatch',
|
|
602
|
+
[cubeRequest.retry_key],
|
|
603
|
+
);
|
|
604
|
+
assertStateDelta(beforeCreateMismatch, await environment.admin.observeAuthorityState(), {}, 'Cube-create retry mismatch');
|
|
605
|
+
await environment.admin.grantCreateCubeCapability(ordinaryPrincipal);
|
|
606
|
+
const crossClientRequest = { ...cubeRequest, name: 'ordinary-repository' };
|
|
607
|
+
const beforeCrossClientCreate = await environment.admin.observeAuthorityState();
|
|
608
|
+
const crossClientResponse = await environment.operations.createCube(
|
|
609
|
+
ordinaryCredential,
|
|
610
|
+
createProtocolEnvelope('cube-cross-client', crossClientRequest),
|
|
611
|
+
);
|
|
612
|
+
expectStatus(crossClientResponse, 201, 'Cross-client cube create with reused retry key');
|
|
613
|
+
const crossClientCreated = decodeCreateCubeResponseEnvelope(crossClientResponse.body).payload;
|
|
614
|
+
invariant(crossClientCreated.cube_id !== created.cube_id, 'Cross-client retry key reused another client\'s cube.');
|
|
615
|
+
assertStateDelta(
|
|
616
|
+
beforeCrossClientCreate,
|
|
617
|
+
await environment.admin.observeAuthorityState(),
|
|
618
|
+
{ cubes: 1, roles: 2, grants: 1, cube_create_bindings: 1 },
|
|
619
|
+
'Cross-client cube create',
|
|
620
|
+
);
|
|
621
|
+
invariant(
|
|
622
|
+
(await environment.admin.inspectCreatedCube(ordinaryPrincipal, crossClientCreated)).creator_has_grant,
|
|
623
|
+
'Cross-client cube creation did not grant its authenticated creator.',
|
|
624
|
+
);
|
|
625
|
+
const beforeCrossClientRetry = await environment.admin.observeAuthorityState();
|
|
626
|
+
const crossClientRetry = await environment.operations.createCube(
|
|
627
|
+
ordinaryCredential,
|
|
628
|
+
createProtocolEnvelope('cube-cross-client-retry', crossClientRequest),
|
|
629
|
+
);
|
|
630
|
+
expectStatus(crossClientRetry, 201, 'Exact cross-client cube-create retry');
|
|
631
|
+
invariant(
|
|
632
|
+
same(decodeCreateCubeResponseEnvelope(crossClientRetry.body).payload, crossClientCreated),
|
|
633
|
+
'Exact cross-client cube-create retry returned different identities.',
|
|
634
|
+
);
|
|
635
|
+
assertStateDelta(beforeCrossClientRetry, await environment.admin.observeAuthorityState(), {}, 'Exact cross-client cube-create retry');
|
|
636
|
+
const beforeSecondCreate = await environment.admin.observeAuthorityState();
|
|
637
|
+
const secondCreatedResponse = await environment.operations.createCube(ownerCredential, createProtocolEnvelope('cube-create-second', {
|
|
638
|
+
...cubeRequest,
|
|
639
|
+
retry_key: '00000000-0000-4000-8000-000000000214',
|
|
640
|
+
name: 'repository-two',
|
|
641
|
+
}));
|
|
642
|
+
expectStatus(secondCreatedResponse, 201, 'Second cube create');
|
|
643
|
+
const secondCreated = decodeCreateCubeResponseEnvelope(secondCreatedResponse.body).payload;
|
|
644
|
+
invariant(secondCreated.cube_id !== created.cube_id, 'Fresh cube-create retry key reused an existing cube.');
|
|
645
|
+
assertStateDelta(
|
|
646
|
+
beforeSecondCreate,
|
|
647
|
+
await environment.admin.observeAuthorityState(),
|
|
648
|
+
{ cubes: 1, roles: 2, grants: 1, cube_create_bindings: 1 },
|
|
649
|
+
'Second cube create',
|
|
650
|
+
);
|
|
651
|
+
await environment.admin.revokePrincipal(ownerPrincipal);
|
|
652
|
+
const beforeRevokedCreate = await environment.admin.observeAuthorityState();
|
|
653
|
+
expectSecretFreeError(
|
|
654
|
+
await environment.operations.createCube(ownerCredential, createProtocolEnvelope('cube-revoked', {
|
|
655
|
+
...cubeRequest,
|
|
656
|
+
retry_key: '00000000-0000-4000-8000-000000000215',
|
|
657
|
+
})),
|
|
658
|
+
401,
|
|
659
|
+
ErrorCode.SESSION_REVOKED,
|
|
660
|
+
'Revoked owner cube create',
|
|
661
|
+
['00000000-0000-4000-8000-000000000215'],
|
|
662
|
+
);
|
|
663
|
+
assertStateDelta(beforeRevokedCreate, await environment.admin.observeAuthorityState(), {}, 'Revoked owner cube create');
|
|
664
|
+
|
|
665
|
+
await environment.admin.reset();
|
|
666
|
+
principalA = await environment.admin.createPrincipal('principal-a');
|
|
667
|
+
principalB = await environment.admin.createPrincipal('principal-b');
|
|
668
|
+
cubeA = await environment.admin.createCube('cube-a');
|
|
669
|
+
cubeB = await environment.admin.createCube('cube-b');
|
|
670
|
+
await environment.admin.grantCube(principalA, cubeA);
|
|
671
|
+
await environment.admin.grantCube(principalB, cubeB);
|
|
672
|
+
const invitationA = await environment.admin.issueSingleUseInvitation(principalA, 'client');
|
|
673
|
+
const invitationB = await environment.admin.issueSingleUseInvitation(principalB, 'client');
|
|
674
|
+
credentialA = 'A'.repeat(43);
|
|
675
|
+
credentialB = 'E'.repeat(43);
|
|
345
676
|
const enrollmentARequest = createProtocolEnvelope('enroll-a1', {
|
|
346
677
|
invitation: invitationA,
|
|
678
|
+
retry_key: '00000000-0000-4000-8000-000000000201',
|
|
679
|
+
client_credential: credentialA,
|
|
347
680
|
client_name: 'conformance-a',
|
|
348
681
|
});
|
|
349
682
|
const enrollmentBRequest = createProtocolEnvelope('enroll-b1', {
|
|
350
683
|
invitation: invitationB,
|
|
684
|
+
retry_key: '00000000-0000-4000-8000-000000000202',
|
|
685
|
+
client_credential: credentialB,
|
|
351
686
|
client_name: 'conformance-b',
|
|
352
687
|
});
|
|
353
688
|
const enrolledAResponse = await environment.operations.enroll(enrollmentARequest);
|
|
354
689
|
const enrolledBResponse = await environment.operations.enroll(enrollmentBRequest);
|
|
355
690
|
expectStatus(enrolledAResponse, 201, 'Principal A enrollment');
|
|
356
691
|
expectStatus(enrolledBResponse, 201, 'Principal B enrollment');
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
invariant(
|
|
692
|
+
const enrolledA = decodeEnrollmentExchangeResponseEnvelope(enrolledAResponse.body).payload;
|
|
693
|
+
const enrolledB = decodeEnrollmentExchangeResponseEnvelope(enrolledBResponse.body).payload;
|
|
694
|
+
invariant(enrolledA.purpose === 'client' && enrolledB.purpose === 'client', 'Ordinary enrollment returned owner authority.');
|
|
695
|
+
invariant(enrolledA.server_capabilities.length === 0 && enrolledB.server_capabilities.length === 0, 'Ordinary enrollment returned a server capability.');
|
|
696
|
+
invariant(!('credential' in enrolledA) && !('credential' in enrolledB), 'Enrollment response returned a bearer.');
|
|
697
|
+
const retriedAResponse = await environment.operations.enroll(enrollmentARequest);
|
|
698
|
+
expectStatus(retriedAResponse, 201, 'Exact enrollment retry');
|
|
699
|
+
invariant(
|
|
700
|
+
JSON.stringify(decodeEnrollmentExchangeResponseEnvelope(retriedAResponse.body).payload) ===
|
|
701
|
+
JSON.stringify(enrolledA),
|
|
702
|
+
'Exact enrollment retry returned different identities.',
|
|
703
|
+
);
|
|
360
704
|
expectError(
|
|
361
|
-
await environment.operations.enroll(
|
|
705
|
+
await environment.operations.enroll(createProtocolEnvelope('enroll-a-mismatch', {
|
|
706
|
+
...enrollmentARequest.payload,
|
|
707
|
+
retry_key: '00000000-0000-4000-8000-000000000203',
|
|
708
|
+
})),
|
|
362
709
|
401,
|
|
363
710
|
ErrorCode.AUTH_INVALID,
|
|
364
|
-
'
|
|
711
|
+
'Enrollment retry mismatch',
|
|
365
712
|
);
|
|
366
713
|
const protocolResponse = await environment.operations.protocol(credentialA);
|
|
367
714
|
expectStatus(protocolResponse, 200, 'Authenticated protocol request');
|
|
@@ -377,7 +724,9 @@ export async function runAdapterConformance(
|
|
|
377
724
|
return {
|
|
378
725
|
unauthenticated: ErrorCode.AUTH_MISSING,
|
|
379
726
|
enrollment_status: 201,
|
|
380
|
-
|
|
727
|
+
exact_retry_status: 201,
|
|
728
|
+
mismatched_retry: ErrorCode.AUTH_INVALID,
|
|
729
|
+
response_secret_free: true,
|
|
381
730
|
protocol_version: protocolInfo.protocol_version,
|
|
382
731
|
};
|
|
383
732
|
});
|
package/src/conformance/index.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { BroadcastHwm } from '../log-stream-hwm.js';
|
|
2
|
+
import type { EnrollmentExchangeRequest } from '../protocol/contract.js';
|
|
2
3
|
|
|
3
4
|
export * from './adapter.js';
|
|
4
5
|
|
|
@@ -66,3 +67,141 @@ export const ROLE_SECTION_ROUND_TRIP_CONFORMANCE: readonly string[] = [
|
|
|
66
67
|
'Preamble.\n\nWorkflow:\n- step one\n\nProject conventions:\n- TDD.\n',
|
|
67
68
|
'**Markdown heading:**\nMust remain part of the preamble.\n',
|
|
68
69
|
];
|
|
70
|
+
|
|
71
|
+
export interface EnrollmentRetryConformanceVector {
|
|
72
|
+
name: string;
|
|
73
|
+
initial: EnrollmentExchangeRequest;
|
|
74
|
+
retry: EnrollmentExchangeRequest;
|
|
75
|
+
expected:
|
|
76
|
+
| {
|
|
77
|
+
outcome: 'stable_non_secret_identity';
|
|
78
|
+
status: 201;
|
|
79
|
+
forbidden_response_fields: readonly [
|
|
80
|
+
'credential',
|
|
81
|
+
'client_credential',
|
|
82
|
+
'invitation',
|
|
83
|
+
'retry_key',
|
|
84
|
+
];
|
|
85
|
+
}
|
|
86
|
+
| {
|
|
87
|
+
outcome: 'uniform_auth_invalid';
|
|
88
|
+
status: 401;
|
|
89
|
+
error: 'AUTH_INVALID';
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const ENROLLMENT_INVITATION = 'I'.repeat(43);
|
|
94
|
+
const ENROLLMENT_CREDENTIAL = 'A'.repeat(43);
|
|
95
|
+
const ENROLLMENT_RETRY_KEY = '00000000-0000-4000-8000-000000000101';
|
|
96
|
+
|
|
97
|
+
/** Stateful vectors: adapters must compare the complete canonical retry tuple. */
|
|
98
|
+
export const ENROLLMENT_RETRY_CONFORMANCE: readonly EnrollmentRetryConformanceVector[] = [
|
|
99
|
+
{
|
|
100
|
+
name: 'exact credential-proven retry returns stable non-secret identities',
|
|
101
|
+
initial: {
|
|
102
|
+
invitation: ENROLLMENT_INVITATION,
|
|
103
|
+
retry_key: ENROLLMENT_RETRY_KEY,
|
|
104
|
+
client_credential: ENROLLMENT_CREDENTIAL,
|
|
105
|
+
client_name: 'operator-laptop',
|
|
106
|
+
},
|
|
107
|
+
retry: {
|
|
108
|
+
invitation: ENROLLMENT_INVITATION,
|
|
109
|
+
retry_key: ENROLLMENT_RETRY_KEY,
|
|
110
|
+
client_credential: ENROLLMENT_CREDENTIAL,
|
|
111
|
+
client_name: 'operator-laptop',
|
|
112
|
+
},
|
|
113
|
+
expected: {
|
|
114
|
+
outcome: 'stable_non_secret_identity',
|
|
115
|
+
status: 201,
|
|
116
|
+
forbidden_response_fields: [
|
|
117
|
+
'credential',
|
|
118
|
+
'client_credential',
|
|
119
|
+
'invitation',
|
|
120
|
+
'retry_key',
|
|
121
|
+
],
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
name: 'retry-key mismatch is uniformly invalid',
|
|
126
|
+
initial: {
|
|
127
|
+
invitation: ENROLLMENT_INVITATION,
|
|
128
|
+
retry_key: ENROLLMENT_RETRY_KEY,
|
|
129
|
+
client_credential: ENROLLMENT_CREDENTIAL,
|
|
130
|
+
},
|
|
131
|
+
retry: {
|
|
132
|
+
invitation: ENROLLMENT_INVITATION,
|
|
133
|
+
retry_key: '00000000-0000-4000-8000-000000000102',
|
|
134
|
+
client_credential: ENROLLMENT_CREDENTIAL,
|
|
135
|
+
},
|
|
136
|
+
expected: { outcome: 'uniform_auth_invalid', status: 401, error: 'AUTH_INVALID' },
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
name: 'credential mismatch is uniformly invalid',
|
|
140
|
+
initial: {
|
|
141
|
+
invitation: ENROLLMENT_INVITATION,
|
|
142
|
+
retry_key: ENROLLMENT_RETRY_KEY,
|
|
143
|
+
client_credential: ENROLLMENT_CREDENTIAL,
|
|
144
|
+
},
|
|
145
|
+
retry: {
|
|
146
|
+
invitation: ENROLLMENT_INVITATION,
|
|
147
|
+
retry_key: ENROLLMENT_RETRY_KEY,
|
|
148
|
+
client_credential: 'E'.repeat(43),
|
|
149
|
+
},
|
|
150
|
+
expected: { outcome: 'uniform_auth_invalid', status: 401, error: 'AUTH_INVALID' },
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
name: 'client-name mismatch is uniformly invalid',
|
|
154
|
+
initial: {
|
|
155
|
+
invitation: ENROLLMENT_INVITATION,
|
|
156
|
+
retry_key: ENROLLMENT_RETRY_KEY,
|
|
157
|
+
client_credential: ENROLLMENT_CREDENTIAL,
|
|
158
|
+
client_name: 'operator-laptop',
|
|
159
|
+
},
|
|
160
|
+
retry: {
|
|
161
|
+
invitation: ENROLLMENT_INVITATION,
|
|
162
|
+
retry_key: ENROLLMENT_RETRY_KEY,
|
|
163
|
+
client_credential: ENROLLMENT_CREDENTIAL,
|
|
164
|
+
client_name: 'different-client',
|
|
165
|
+
},
|
|
166
|
+
expected: { outcome: 'uniform_auth_invalid', status: 401, error: 'AUTH_INVALID' },
|
|
167
|
+
},
|
|
168
|
+
];
|
|
169
|
+
|
|
170
|
+
export const ENROLLMENT_AUTHORITY_CONFORMANCE = [
|
|
171
|
+
{
|
|
172
|
+
name: 'ordinary enrollment creates no authority or cube state',
|
|
173
|
+
response: {
|
|
174
|
+
purpose: 'client',
|
|
175
|
+
client_id: '00000000-0000-4000-8000-000000000111',
|
|
176
|
+
server_capabilities: [],
|
|
177
|
+
},
|
|
178
|
+
expected_state_delta: { cubes: 0, roles: 0, grants: 0, server_capabilities: 0 },
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
name: 'owner enrollment grants create-cube authority without cube state',
|
|
182
|
+
response: {
|
|
183
|
+
purpose: 'owner',
|
|
184
|
+
client_id: '00000000-0000-4000-8000-000000000111',
|
|
185
|
+
server_capabilities: ['create_cube'],
|
|
186
|
+
},
|
|
187
|
+
expected_state_delta: { cubes: 0, roles: 0, grants: 0, server_capabilities: 1 },
|
|
188
|
+
},
|
|
189
|
+
] as const;
|
|
190
|
+
|
|
191
|
+
export const ENROLLMENT_REDACTION_CONFORMANCE: readonly ConformanceVector<string, string>[] = [
|
|
192
|
+
{
|
|
193
|
+
name: 'redacts invitation and client credential from diagnostics',
|
|
194
|
+
input: `invitation=${ENROLLMENT_INVITATION} client_credential=${ENROLLMENT_CREDENTIAL}`,
|
|
195
|
+
expected: 'invitation=<REDACTED> client_credential=<REDACTED>',
|
|
196
|
+
},
|
|
197
|
+
{
|
|
198
|
+
name: 'redacts a contextual enrollment retry key',
|
|
199
|
+
input: `retry_key=${ENROLLMENT_RETRY_KEY}`,
|
|
200
|
+
expected: 'retry_key=<REDACTED>',
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
name: 'preserves unrelated public UUIDs',
|
|
204
|
+
input: `cube_id=${ENROLLMENT_RETRY_KEY}`,
|
|
205
|
+
expected: `cube_id=${ENROLLMENT_RETRY_KEY}`,
|
|
206
|
+
},
|
|
207
|
+
];
|