borgmcp-shared 0.7.0 → 0.7.1
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 +5 -28
- package/RELEASES.md +29 -0
- package/dist/conformance/adapter.d.ts +19 -0
- package/dist/conformance/adapter.d.ts.map +1 -1
- package/dist/conformance/adapter.js +141 -1
- package/dist/conformance/adapter.js.map +1 -1
- package/dist/conformance/index.d.ts +20 -1
- package/dist/conformance/index.d.ts.map +1 -1
- package/dist/conformance/index.js +37 -0
- package/dist/conformance/index.js.map +1 -1
- package/dist/protocol/contract.d.ts +19 -1
- package/dist/protocol/contract.d.ts.map +1 -1
- package/dist/protocol/contract.js +32 -2
- package/dist/protocol/contract.js.map +1 -1
- package/dist/protocol/errors.d.ts +1 -0
- package/dist/protocol/errors.d.ts.map +1 -1
- package/dist/protocol/errors.js +1 -0
- package/dist/protocol/errors.js.map +1 -1
- package/dist/protocol/sse.d.ts +6 -2
- package/dist/protocol/sse.d.ts.map +1 -1
- package/dist/protocol/sse.js +8 -2
- package/dist/protocol/sse.js.map +1 -1
- package/dist/protocol/version.d.ts +1 -1
- package/dist/protocol/version.js +1 -1
- package/docs/compatibility.md +10 -4
- package/docs/enrollment.md +4 -3
- package/docs/releasing.md +4 -3
- package/package.json +2 -1
- package/src/conformance/adapter.ts +294 -0
- package/src/conformance/index.ts +53 -0
- package/src/protocol/contract.ts +42 -2
- package/src/protocol/errors.ts +1 -0
- package/src/protocol/sse.ts +17 -2
- package/src/protocol/version.ts +2 -2
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
createProtocolEnvelope,
|
|
5
5
|
decodeAssociateRepositoryCubeResponseEnvelope,
|
|
6
6
|
decodeCreateCubeResponseEnvelope,
|
|
7
|
+
decodeDeleteCubeResponseEnvelope,
|
|
7
8
|
decodeAppendLogResultEnvelope,
|
|
8
9
|
decodeDecisionResultEnvelope,
|
|
9
10
|
decodeDecisionsResultEnvelope,
|
|
@@ -130,6 +131,20 @@ export interface ConformanceCreatedCubeState {
|
|
|
130
131
|
default_worker_role_matches: boolean;
|
|
131
132
|
}
|
|
132
133
|
|
|
134
|
+
export interface ConformanceDeletedCubeState {
|
|
135
|
+
readonly cube_exists: boolean;
|
|
136
|
+
readonly role_count: number;
|
|
137
|
+
readonly drone_count: number;
|
|
138
|
+
readonly log_count: number;
|
|
139
|
+
readonly claim_count: number;
|
|
140
|
+
readonly decision_count: number;
|
|
141
|
+
readonly grant_count: number;
|
|
142
|
+
readonly cube_create_binding_count: number;
|
|
143
|
+
readonly repository_association_count: number;
|
|
144
|
+
readonly active_stream_count: number;
|
|
145
|
+
readonly terminal_credential_count: number;
|
|
146
|
+
}
|
|
147
|
+
|
|
133
148
|
export interface ConformanceRepositoryCubeFixture {
|
|
134
149
|
cube_id: string;
|
|
135
150
|
name: string;
|
|
@@ -168,6 +183,8 @@ export interface ConformanceStreamResponse extends ConformanceHttpResponse {
|
|
|
168
183
|
*/
|
|
169
184
|
export interface ConformanceAdmin {
|
|
170
185
|
reset(): Promise<void>;
|
|
186
|
+
/** Restarts the authority while preserving durable state. */
|
|
187
|
+
restartAuthority(): Promise<void>;
|
|
171
188
|
createPrincipal(name: string): Promise<ConformancePrincipal>;
|
|
172
189
|
createCube(name: string): Promise<ConformanceCube>;
|
|
173
190
|
/** Grants the requested cube authority; omitted access defaults to manage. */
|
|
@@ -205,6 +222,7 @@ export interface ConformanceAdmin {
|
|
|
205
222
|
creator: ConformancePrincipal,
|
|
206
223
|
response: CreateCubeResponse,
|
|
207
224
|
): Promise<ConformanceCreatedCubeState>;
|
|
225
|
+
inspectDeletedCube(cube: ConformanceCube): Promise<ConformanceDeletedCubeState>;
|
|
208
226
|
prepareRepositoryCube(
|
|
209
227
|
cube: ConformanceCube,
|
|
210
228
|
input: {
|
|
@@ -227,6 +245,11 @@ export interface ConformanceOperations {
|
|
|
227
245
|
protocol(credential: string | null): Promise<ConformanceHttpResponse>;
|
|
228
246
|
enroll(request: unknown): Promise<ConformanceHttpResponse>;
|
|
229
247
|
createCube(credential: string | null, request: unknown): Promise<ConformanceHttpResponse>;
|
|
248
|
+
deleteCube(
|
|
249
|
+
credential: string,
|
|
250
|
+
cube: ConformanceCube,
|
|
251
|
+
request: unknown,
|
|
252
|
+
): Promise<ConformanceHttpResponse>;
|
|
230
253
|
resolveRepositoryCube(credential: string | null, request: unknown): Promise<ConformanceHttpResponse>;
|
|
231
254
|
associateRepositoryCube(credential: string | null, request: unknown): Promise<ConformanceHttpResponse>;
|
|
232
255
|
attach(credential: string, request: unknown): Promise<ConformanceHttpResponse>;
|
|
@@ -337,6 +360,7 @@ export const ADAPTER_CONFORMANCE_FIXTURES = [
|
|
|
337
360
|
{ id: 'security.metadata-noninterference', area: 'security' },
|
|
338
361
|
{ id: 'security.metadata-secret-non-echo', area: 'security' },
|
|
339
362
|
{ id: 'security.active-stream-revocation', area: 'security' },
|
|
363
|
+
{ id: 'cubes.delete-terminal-cascade', area: 'cubes' },
|
|
340
364
|
] as const;
|
|
341
365
|
|
|
342
366
|
export type AdapterConformanceFixtureId =
|
|
@@ -2431,6 +2455,276 @@ export async function runAdapterConformance(
|
|
|
2431
2455
|
return { stream_terminated: true, subsequent_status: 401, subsequent_code: ErrorCode.SESSION_REVOKED };
|
|
2432
2456
|
});
|
|
2433
2457
|
|
|
2458
|
+
await record('cubes.delete-terminal-cascade', async () => {
|
|
2459
|
+
const enrollParent = async (
|
|
2460
|
+
name: string,
|
|
2461
|
+
marker: string,
|
|
2462
|
+
retrySuffix: string,
|
|
2463
|
+
): Promise<{ principal: ConformancePrincipal; credential: string }> => {
|
|
2464
|
+
const principal = await environment.admin.createPrincipal(name);
|
|
2465
|
+
const invitation = await environment.admin.issueSingleUseInvitation(principal, 'client');
|
|
2466
|
+
const credential = `${marker.repeat(41)}AQ`;
|
|
2467
|
+
expectStatus(await environment.operations.enroll(createProtocolEnvelope(
|
|
2468
|
+
`delete-${name}-enroll`,
|
|
2469
|
+
{
|
|
2470
|
+
invitation,
|
|
2471
|
+
retry_key: `00000000-0000-4000-8000-${retrySuffix.padStart(12, '0')}`,
|
|
2472
|
+
client_credential: credential,
|
|
2473
|
+
client_name: name,
|
|
2474
|
+
},
|
|
2475
|
+
)), 201, `${name} enrollment`);
|
|
2476
|
+
return { principal, credential };
|
|
2477
|
+
};
|
|
2478
|
+
const creator = await enrollParent('delete-creator', 'C', '701');
|
|
2479
|
+
const manager = await enrollParent('delete-manager', 'M', '702');
|
|
2480
|
+
const reader = await enrollParent('delete-reader', 'R', '703');
|
|
2481
|
+
const writer = await enrollParent('delete-writer', 'W', '704');
|
|
2482
|
+
const outsider = await enrollParent('delete-outsider', 'O', '705');
|
|
2483
|
+
await environment.admin.grantCreateCubeCapability(creator.principal);
|
|
2484
|
+
const createResponse = await environment.operations.createCube(
|
|
2485
|
+
creator.credential,
|
|
2486
|
+
createProtocolEnvelope('delete-cube-create', {
|
|
2487
|
+
retry_key: '00000000-0000-4000-8000-000000000706',
|
|
2488
|
+
name: 'Disposable Cube',
|
|
2489
|
+
working_repo_name: 'disposable-cube',
|
|
2490
|
+
repository: { kind: 'local', value: '00000000-0000-4000-8000-000000000707' },
|
|
2491
|
+
template: 'default',
|
|
2492
|
+
}),
|
|
2493
|
+
);
|
|
2494
|
+
expectStatus(createResponse, 201, 'Disposable cube creation');
|
|
2495
|
+
const created = decodeCreateCubeResponseEnvelope(createResponse.body).payload;
|
|
2496
|
+
const cube = { id: created.cube_id };
|
|
2497
|
+
const role = { id: created.default_worker_role_id };
|
|
2498
|
+
await environment.admin.grantCube(manager.principal, cube, 'manage');
|
|
2499
|
+
await environment.admin.grantCube(reader.principal, cube, 'read');
|
|
2500
|
+
await environment.admin.grantCube(writer.principal, cube, 'write');
|
|
2501
|
+
const drone = await environment.admin.createDrone(creator.principal, cube, role);
|
|
2502
|
+
const droneCredential = await environment.admin.issueManagedDroneSession(drone);
|
|
2503
|
+
const append = await environment.operations.append(
|
|
2504
|
+
creator.credential,
|
|
2505
|
+
cube,
|
|
2506
|
+
createProtocolEnvelope('delete-log', { message: 'deleted with cube' }),
|
|
2507
|
+
);
|
|
2508
|
+
expectStatus(append, 201, 'Deletion fixture log');
|
|
2509
|
+
const entry = decodeAppendLogResultEnvelope(append.body).payload.entry;
|
|
2510
|
+
expectStatus(await environment.operations.ack(
|
|
2511
|
+
creator.credential,
|
|
2512
|
+
cube,
|
|
2513
|
+
createProtocolEnvelope('delete-claim', { entry_id: entry.id, kind: 'claim' }),
|
|
2514
|
+
), 204, 'Deletion fixture claim');
|
|
2515
|
+
expectStatus(await environment.operations.recordDecision(
|
|
2516
|
+
manager.credential,
|
|
2517
|
+
cube,
|
|
2518
|
+
createProtocolEnvelope('delete-decision', { topic: 'cleanup', decision: 'delete' }),
|
|
2519
|
+
), 201, 'Deletion fixture decision');
|
|
2520
|
+
|
|
2521
|
+
const beforeDenied = await environment.admin.inspectCubeManagementState(cube);
|
|
2522
|
+
for (const [kind, credential] of [
|
|
2523
|
+
['read', reader.credential],
|
|
2524
|
+
['write', writer.credential],
|
|
2525
|
+
['drone-session', droneCredential],
|
|
2526
|
+
] as const) {
|
|
2527
|
+
expectError(
|
|
2528
|
+
await environment.operations.deleteCube(
|
|
2529
|
+
credential,
|
|
2530
|
+
cube,
|
|
2531
|
+
createProtocolEnvelope(`delete-${kind}-denied`, {}),
|
|
2532
|
+
),
|
|
2533
|
+
403,
|
|
2534
|
+
ErrorCode.ACCESS_DENIED,
|
|
2535
|
+
`${kind} cube deletion`,
|
|
2536
|
+
);
|
|
2537
|
+
invariant(
|
|
2538
|
+
same(await environment.admin.inspectCubeManagementState(cube), beforeDenied),
|
|
2539
|
+
`${kind} cube deletion denial mutated the cube.`,
|
|
2540
|
+
);
|
|
2541
|
+
}
|
|
2542
|
+
expectError(
|
|
2543
|
+
await environment.operations.deleteCube(
|
|
2544
|
+
outsider.credential,
|
|
2545
|
+
cube,
|
|
2546
|
+
createProtocolEnvelope('delete-outsider-denied', {}),
|
|
2547
|
+
),
|
|
2548
|
+
404,
|
|
2549
|
+
ErrorCode.NOT_FOUND,
|
|
2550
|
+
'Never-authorized cube deletion',
|
|
2551
|
+
);
|
|
2552
|
+
|
|
2553
|
+
const parentStream = await environment.operations.openStream(manager.credential, cube, null);
|
|
2554
|
+
const droneStream = await environment.operations.openStream(droneCredential, cube, null);
|
|
2555
|
+
expectStatus(parentStream, 200, 'Deletion parent stream');
|
|
2556
|
+
expectStatus(droneStream, 200, 'Deletion drone stream');
|
|
2557
|
+
invariant(parentStream.stream && droneStream.stream, 'Deletion fixture streams did not open.');
|
|
2558
|
+
const parentEvents = new SseEventReader(parentStream.stream);
|
|
2559
|
+
const droneEvents = new SseEventReader(droneStream.stream);
|
|
2560
|
+
for (const streamReader of [parentEvents, droneEvents]) {
|
|
2561
|
+
invariant((await streamReader.next()).type === 'log', 'Deletion stream omitted replay log.');
|
|
2562
|
+
const bookmark = await streamReader.next();
|
|
2563
|
+
invariant(bookmark.type === 'bookmark' && bookmark.replay_complete, 'Deletion stream omitted bookmark.');
|
|
2564
|
+
}
|
|
2565
|
+
const parentTerminal = parentEvents.next();
|
|
2566
|
+
const droneTerminal = droneEvents.next();
|
|
2567
|
+
await provePending(parentTerminal, 'Parent deletion terminal event', pendingProbeMs);
|
|
2568
|
+
await provePending(droneTerminal, 'Drone deletion terminal event', pendingProbeMs);
|
|
2569
|
+
|
|
2570
|
+
const deleted = await environment.operations.deleteCube(
|
|
2571
|
+
manager.credential,
|
|
2572
|
+
cube,
|
|
2573
|
+
createProtocolEnvelope('delete-managed-cube', {}),
|
|
2574
|
+
);
|
|
2575
|
+
expectStatus(deleted, 200, 'Managed cube deletion');
|
|
2576
|
+
invariant(
|
|
2577
|
+
same(decodeDeleteCubeResponseEnvelope(deleted.body).payload, {
|
|
2578
|
+
cube_id: cube.id,
|
|
2579
|
+
deleted: true,
|
|
2580
|
+
}),
|
|
2581
|
+
'Cube deletion response did not identify the terminal cube.',
|
|
2582
|
+
);
|
|
2583
|
+
for (const [kind, event] of [
|
|
2584
|
+
['parent', await within(parentTerminal, 'Parent deletion terminal event', streamDeadlineMs)],
|
|
2585
|
+
['drone', await within(droneTerminal, 'Drone deletion terminal event', streamDeadlineMs)],
|
|
2586
|
+
] as const) {
|
|
2587
|
+
invariant(
|
|
2588
|
+
event.type === 'error' && event.error.error.code === ErrorCode.CUBE_DELETED,
|
|
2589
|
+
`${kind} stream did not receive CUBE_DELETED.`,
|
|
2590
|
+
);
|
|
2591
|
+
}
|
|
2592
|
+
for (const streamReader of [parentEvents, droneEvents]) {
|
|
2593
|
+
let closed = false;
|
|
2594
|
+
try {
|
|
2595
|
+
await within(streamReader.next(), 'Deleted cube stream close', streamDeadlineMs);
|
|
2596
|
+
} catch (error) {
|
|
2597
|
+
if (error instanceof Error && error.message.includes('did not settle')) throw error;
|
|
2598
|
+
closed = true;
|
|
2599
|
+
}
|
|
2600
|
+
invariant(closed, 'Deleted cube stream yielded after its terminal error.');
|
|
2601
|
+
await streamReader.close();
|
|
2602
|
+
}
|
|
2603
|
+
|
|
2604
|
+
invariant(
|
|
2605
|
+
same(await environment.admin.inspectDeletedCube(cube), {
|
|
2606
|
+
cube_exists: false,
|
|
2607
|
+
role_count: 0,
|
|
2608
|
+
drone_count: 0,
|
|
2609
|
+
log_count: 0,
|
|
2610
|
+
claim_count: 0,
|
|
2611
|
+
decision_count: 0,
|
|
2612
|
+
grant_count: 0,
|
|
2613
|
+
cube_create_binding_count: 0,
|
|
2614
|
+
repository_association_count: 0,
|
|
2615
|
+
active_stream_count: 0,
|
|
2616
|
+
terminal_credential_count: 5,
|
|
2617
|
+
}),
|
|
2618
|
+
'Cube deletion did not atomically remove cube-owned state and preserve only terminal credentials.',
|
|
2619
|
+
);
|
|
2620
|
+
for (const [kind, credential] of [
|
|
2621
|
+
['creator', creator.credential],
|
|
2622
|
+
['manager', manager.credential],
|
|
2623
|
+
['reader', reader.credential],
|
|
2624
|
+
['writer', writer.credential],
|
|
2625
|
+
['drone', droneCredential],
|
|
2626
|
+
] as const) {
|
|
2627
|
+
expectError(
|
|
2628
|
+
await environment.operations.read(
|
|
2629
|
+
credential,
|
|
2630
|
+
cube,
|
|
2631
|
+
createProtocolEnvelope(`delete-${kind}-terminal`, { cursor: null, limit: 1 }),
|
|
2632
|
+
),
|
|
2633
|
+
PROTOCOL_HTTP_CONTRACT.cube_deleted_status,
|
|
2634
|
+
ErrorCode.CUBE_DELETED,
|
|
2635
|
+
`${kind} post-delete request`,
|
|
2636
|
+
);
|
|
2637
|
+
expectError(
|
|
2638
|
+
await environment.operations.deleteCube(
|
|
2639
|
+
credential,
|
|
2640
|
+
cube,
|
|
2641
|
+
createProtocolEnvelope(`delete-${kind}-repeat-terminal`, {}),
|
|
2642
|
+
),
|
|
2643
|
+
PROTOCOL_HTTP_CONTRACT.cube_deleted_status,
|
|
2644
|
+
ErrorCode.CUBE_DELETED,
|
|
2645
|
+
`${kind} post-delete DELETE`,
|
|
2646
|
+
);
|
|
2647
|
+
}
|
|
2648
|
+
expectError(
|
|
2649
|
+
await environment.operations.deleteCube(
|
|
2650
|
+
outsider.credential,
|
|
2651
|
+
cube,
|
|
2652
|
+
createProtocolEnvelope('delete-outsider-hidden-repeat', {}),
|
|
2653
|
+
),
|
|
2654
|
+
404,
|
|
2655
|
+
ErrorCode.NOT_FOUND,
|
|
2656
|
+
'Never-authorized post-delete DELETE',
|
|
2657
|
+
);
|
|
2658
|
+
expectError(
|
|
2659
|
+
await environment.operations.read(
|
|
2660
|
+
outsider.credential,
|
|
2661
|
+
cube,
|
|
2662
|
+
createProtocolEnvelope('delete-outsider-hidden', { cursor: null, limit: 1 }),
|
|
2663
|
+
),
|
|
2664
|
+
404,
|
|
2665
|
+
ErrorCode.NOT_FOUND,
|
|
2666
|
+
'Never-authorized post-delete request',
|
|
2667
|
+
);
|
|
2668
|
+
await environment.admin.restartAuthority();
|
|
2669
|
+
for (const [kind, credential] of [
|
|
2670
|
+
['creator', creator.credential],
|
|
2671
|
+
['manager', manager.credential],
|
|
2672
|
+
['reader', reader.credential],
|
|
2673
|
+
['writer', writer.credential],
|
|
2674
|
+
['drone', droneCredential],
|
|
2675
|
+
] as const) {
|
|
2676
|
+
expectError(
|
|
2677
|
+
await environment.operations.read(
|
|
2678
|
+
credential,
|
|
2679
|
+
cube,
|
|
2680
|
+
createProtocolEnvelope(`delete-${kind}-after-restart`, { cursor: null, limit: 1 }),
|
|
2681
|
+
),
|
|
2682
|
+
PROTOCOL_HTTP_CONTRACT.cube_deleted_status,
|
|
2683
|
+
ErrorCode.CUBE_DELETED,
|
|
2684
|
+
`${kind} post-restart deleted-cube request`,
|
|
2685
|
+
);
|
|
2686
|
+
expectError(
|
|
2687
|
+
await environment.operations.deleteCube(
|
|
2688
|
+
credential,
|
|
2689
|
+
cube,
|
|
2690
|
+
createProtocolEnvelope(`delete-${kind}-repeat-after-restart`, {}),
|
|
2691
|
+
),
|
|
2692
|
+
PROTOCOL_HTTP_CONTRACT.cube_deleted_status,
|
|
2693
|
+
ErrorCode.CUBE_DELETED,
|
|
2694
|
+
`${kind} post-restart DELETE`,
|
|
2695
|
+
);
|
|
2696
|
+
}
|
|
2697
|
+
expectError(
|
|
2698
|
+
await environment.operations.deleteCube(
|
|
2699
|
+
outsider.credential,
|
|
2700
|
+
cube,
|
|
2701
|
+
createProtocolEnvelope('delete-outsider-hidden-repeat-after-restart', {}),
|
|
2702
|
+
),
|
|
2703
|
+
404,
|
|
2704
|
+
ErrorCode.NOT_FOUND,
|
|
2705
|
+
'Never-authorized post-restart DELETE',
|
|
2706
|
+
);
|
|
2707
|
+
expectError(
|
|
2708
|
+
await environment.operations.read(
|
|
2709
|
+
outsider.credential,
|
|
2710
|
+
cube,
|
|
2711
|
+
createProtocolEnvelope('delete-outsider-hidden-after-restart', { cursor: null, limit: 1 }),
|
|
2712
|
+
),
|
|
2713
|
+
404,
|
|
2714
|
+
ErrorCode.NOT_FOUND,
|
|
2715
|
+
'Never-authorized post-restart request',
|
|
2716
|
+
);
|
|
2717
|
+
return {
|
|
2718
|
+
deletion_status: 200,
|
|
2719
|
+
cascade_complete: true,
|
|
2720
|
+
terminal_stream_error: ErrorCode.CUBE_DELETED,
|
|
2721
|
+
terminal_http_status: 410,
|
|
2722
|
+
terminal_state_durable_after_restart: true,
|
|
2723
|
+
unknown_hidden_status: 404,
|
|
2724
|
+
non_member_manager: true,
|
|
2725
|
+
};
|
|
2726
|
+
});
|
|
2727
|
+
|
|
2434
2728
|
const normalizedTranscript = results.map(({ id, observations }) => ({ id, observations }));
|
|
2435
2729
|
return { ok: results.every((result) => result.ok), results, normalizedTranscript };
|
|
2436
2730
|
}
|
package/src/conformance/index.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type {
|
|
|
3
3
|
AttachResponse,
|
|
4
4
|
AssociateRepositoryCubeRequest,
|
|
5
5
|
CreateCubeRequest,
|
|
6
|
+
DeleteCubeRequest,
|
|
6
7
|
EnrollmentExchangeRequest,
|
|
7
8
|
ResolveRepositoryCubeRequest,
|
|
8
9
|
} from '../protocol/contract.js';
|
|
@@ -443,6 +444,58 @@ readonly CreateCubeAssociationConformanceVector[] = [
|
|
|
443
444
|
},
|
|
444
445
|
];
|
|
445
446
|
|
|
447
|
+
export interface DeleteCubeConformanceVector {
|
|
448
|
+
name: string;
|
|
449
|
+
request: DeleteCubeRequest;
|
|
450
|
+
expected: {
|
|
451
|
+
status: 200 | 403 | 404 | 410;
|
|
452
|
+
error?: 'ACCESS_DENIED' | 'NOT_FOUND' | 'CUBE_DELETED';
|
|
453
|
+
response?: { deleted: true };
|
|
454
|
+
terminal_sse?: { event: 'error'; error: 'CUBE_DELETED'; closes_after_event: true };
|
|
455
|
+
durable_after_restart?: true;
|
|
456
|
+
mutation: 'cascade' | 'none';
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** Cube deletion is manage-gated, cascading, terminal, and durable across authority restart. */
|
|
461
|
+
export const DELETE_CUBE_CONFORMANCE: readonly DeleteCubeConformanceVector[] = [
|
|
462
|
+
{
|
|
463
|
+
name: 'non-member managing parent deletes the cube atomically',
|
|
464
|
+
request: {},
|
|
465
|
+
expected: { status: 200, response: { deleted: true }, mutation: 'cascade' },
|
|
466
|
+
},
|
|
467
|
+
{
|
|
468
|
+
name: 'known read and write grants and drone sessions cannot delete',
|
|
469
|
+
request: {},
|
|
470
|
+
expected: { status: 403, error: 'ACCESS_DENIED', mutation: 'none' },
|
|
471
|
+
},
|
|
472
|
+
{
|
|
473
|
+
name: 'never-authorized and unknown callers cannot enumerate the cube',
|
|
474
|
+
request: {},
|
|
475
|
+
expected: { status: 404, error: 'NOT_FOUND', mutation: 'none' },
|
|
476
|
+
},
|
|
477
|
+
{
|
|
478
|
+
name: 'connected clients receive one terminal error frame before close',
|
|
479
|
+
request: {},
|
|
480
|
+
expected: {
|
|
481
|
+
status: 410,
|
|
482
|
+
error: 'CUBE_DELETED',
|
|
483
|
+
terminal_sse: { event: 'error', error: 'CUBE_DELETED', closes_after_event: true },
|
|
484
|
+
mutation: 'none',
|
|
485
|
+
},
|
|
486
|
+
},
|
|
487
|
+
{
|
|
488
|
+
name: 'former authorized credentials retain the typed terminal state after restart',
|
|
489
|
+
request: {},
|
|
490
|
+
expected: {
|
|
491
|
+
status: 410,
|
|
492
|
+
error: 'CUBE_DELETED',
|
|
493
|
+
durable_after_restart: true,
|
|
494
|
+
mutation: 'none',
|
|
495
|
+
},
|
|
496
|
+
},
|
|
497
|
+
];
|
|
498
|
+
|
|
446
499
|
export interface ResolveRepositoryCubeConformanceVector {
|
|
447
500
|
name: string;
|
|
448
501
|
request: ResolveRepositoryCubeRequest;
|
package/src/protocol/contract.ts
CHANGED
|
@@ -13,12 +13,13 @@ import type {
|
|
|
13
13
|
} from './types.js';
|
|
14
14
|
|
|
15
15
|
export const SHARED_PACKAGE_NAME = 'borgmcp-shared' as const;
|
|
16
|
-
export const SHARED_PACKAGE_VERSION = '0.7.
|
|
16
|
+
export const SHARED_PACKAGE_VERSION = '0.7.1' as const;
|
|
17
17
|
|
|
18
18
|
export const HEALTH_PATH = '/healthz' as const;
|
|
19
19
|
export const PROTOCOL_INFO_PATH = '/api/protocol' as const;
|
|
20
20
|
export const ENROLLMENT_EXCHANGE_PATH = '/api/enrollment/exchange' as const;
|
|
21
21
|
export const CUBES_PATH = '/api/cubes' as const;
|
|
22
|
+
export const CUBE_PATH = '/api/cubes/:cubeId' as const;
|
|
22
23
|
export const REPOSITORY_CUBE_RESOLVE_PATH = '/api/repository-cubes/resolve' as const;
|
|
23
24
|
export const REPOSITORY_CUBE_ASSOCIATION_PATH = '/api/repository-cubes/association' as const;
|
|
24
25
|
export const ATTACH_PATH = '/api/client/attach' as const;
|
|
@@ -29,6 +30,13 @@ export const PROTOCOL_HTTP_CONTRACT = {
|
|
|
29
30
|
protocol: { method: 'GET', path: PROTOCOL_INFO_PATH, authenticated: false, success_status: 200 },
|
|
30
31
|
enrollment: { method: 'POST', path: ENROLLMENT_EXCHANGE_PATH, authenticated: 'invitation', success_status: 201 },
|
|
31
32
|
cubes: { method: 'POST', path: CUBES_PATH, authenticated: true, success_status: 201 },
|
|
33
|
+
cube_delete: {
|
|
34
|
+
method: 'DELETE',
|
|
35
|
+
path: CUBE_PATH,
|
|
36
|
+
authenticated: true,
|
|
37
|
+
success_status: 200,
|
|
38
|
+
mutation: true,
|
|
39
|
+
},
|
|
32
40
|
repository_cube_resolve: {
|
|
33
41
|
method: 'POST',
|
|
34
42
|
path: REPOSITORY_CUBE_RESOLVE_PATH,
|
|
@@ -68,6 +76,7 @@ export const PROTOCOL_HTTP_CONTRACT = {
|
|
|
68
76
|
session_revoked_status: 401,
|
|
69
77
|
session_rejected_status: 401,
|
|
70
78
|
cursor_expired_status: 410,
|
|
79
|
+
cube_deleted_status: 410,
|
|
71
80
|
drone_evicted_status: 410,
|
|
72
81
|
content_too_large_status: 413,
|
|
73
82
|
unsupported_protocol_status: 426,
|
|
@@ -164,6 +173,13 @@ export interface CreateCubeResponse {
|
|
|
164
173
|
access: 'manage';
|
|
165
174
|
}
|
|
166
175
|
|
|
176
|
+
export type DeleteCubeRequest = Record<string, never>;
|
|
177
|
+
|
|
178
|
+
export interface DeleteCubeResponse {
|
|
179
|
+
cube_id: string;
|
|
180
|
+
deleted: true;
|
|
181
|
+
}
|
|
182
|
+
|
|
167
183
|
export interface ResolveRepositoryCubeRequest {
|
|
168
184
|
working_repo_name: string;
|
|
169
185
|
repository: CreateCubeRepository;
|
|
@@ -327,7 +343,7 @@ export function decodeProtocolTagPreflight(value: unknown): ProtocolTagPreflight
|
|
|
327
343
|
exactKeys(input, ['protocol_version'], ['protocol_version']);
|
|
328
344
|
if (input.protocol_version !== PROTOCOL_VERSION) {
|
|
329
345
|
throw new ProtocolContractError(
|
|
330
|
-
'This client requires protocol
|
|
346
|
+
'This client requires protocol v7. The peer presents a different version. Update `borgmcp-server` and `borgmcp` to matching releases — server first, then client.',
|
|
331
347
|
ErrorCode.UNSUPPORTED_PROTOCOL_VERSION,
|
|
332
348
|
['protocol_version'],
|
|
333
349
|
);
|
|
@@ -596,6 +612,30 @@ export function decodeCreateCubeResponseEnvelope(value: unknown): ProtocolEnvelo
|
|
|
596
612
|
return decodeProtocolEnvelope(value, decodeCreateCubeResponse);
|
|
597
613
|
}
|
|
598
614
|
|
|
615
|
+
export function decodeDeleteCubeRequest(value: unknown): DeleteCubeRequest {
|
|
616
|
+
const input = record(value);
|
|
617
|
+
exactKeys(input, [], []);
|
|
618
|
+
return {};
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
export function decodeDeleteCubeRequestEnvelope(value: unknown): ProtocolEnvelope<DeleteCubeRequest> {
|
|
622
|
+
return decodeProtocolEnvelope(value, decodeDeleteCubeRequest);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
export function decodeDeleteCubeResponse(value: unknown): DeleteCubeResponse {
|
|
626
|
+
const input = record(value);
|
|
627
|
+
exactKeys(input, ['cube_id', 'deleted'], ['cube_id', 'deleted']);
|
|
628
|
+
if (input.deleted !== true) fail('Cube deletion result must be terminal.', ['deleted']);
|
|
629
|
+
return {
|
|
630
|
+
cube_id: decodeUuid(input.cube_id, ['cube_id']),
|
|
631
|
+
deleted: true,
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
export function decodeDeleteCubeResponseEnvelope(value: unknown): ProtocolEnvelope<DeleteCubeResponse> {
|
|
636
|
+
return decodeProtocolEnvelope(value, decodeDeleteCubeResponse);
|
|
637
|
+
}
|
|
638
|
+
|
|
599
639
|
export function decodeResolveRepositoryCubeRequest(value: unknown): ResolveRepositoryCubeRequest {
|
|
600
640
|
const input = record(value);
|
|
601
641
|
exactKeys(input, ['working_repo_name', 'repository'], ['working_repo_name', 'repository']);
|
package/src/protocol/errors.ts
CHANGED
|
@@ -17,6 +17,7 @@ export enum ErrorCode {
|
|
|
17
17
|
CUBE_ALREADY_ASSOCIATED = 'CUBE_ALREADY_ASSOCIATED',
|
|
18
18
|
ROLE_IN_USE = 'ROLE_IN_USE',
|
|
19
19
|
ROLE_HAS_FROZEN_DRONES = 'ROLE_HAS_FROZEN_DRONES',
|
|
20
|
+
CUBE_DELETED = 'CUBE_DELETED',
|
|
20
21
|
DRONE_EVICTED = 'DRONE_EVICTED',
|
|
21
22
|
DRONE_FROZEN = 'DRONE_FROZEN',
|
|
22
23
|
UNSUPPORTED_PROTOCOL_VERSION = 'UNSUPPORTED_PROTOCOL_VERSION',
|
package/src/protocol/sse.ts
CHANGED
|
@@ -2,11 +2,13 @@ import type { EnrichedStreamEntry } from './types.js';
|
|
|
2
2
|
import {
|
|
3
3
|
ProtocolContractError,
|
|
4
4
|
decodeCanonicalTimestamp,
|
|
5
|
+
decodeProtocolErrorEnvelope,
|
|
5
6
|
decodeLogCursor,
|
|
6
7
|
decodeOpaqueIdentifier,
|
|
7
8
|
decodeUuid,
|
|
8
9
|
utf8ByteLength,
|
|
9
10
|
type LogCursor,
|
|
11
|
+
type ProtocolErrorEnvelope,
|
|
10
12
|
} from './contract.js';
|
|
11
13
|
|
|
12
14
|
export const SSE_LIMITS = {
|
|
@@ -31,6 +33,7 @@ export type StreamEvent =
|
|
|
31
33
|
actor_drone_id: string;
|
|
32
34
|
occurred_at: string;
|
|
33
35
|
}
|
|
36
|
+
| StreamErrorEvent
|
|
34
37
|
| { type: 'heartbeat'; at: string; broadcast_hwm: LogCursor | null }
|
|
35
38
|
| {
|
|
36
39
|
type: 'bookmark';
|
|
@@ -41,6 +44,12 @@ export type StreamEvent =
|
|
|
41
44
|
}
|
|
42
45
|
| { type: 'unknown'; event: string; raw_data: string };
|
|
43
46
|
|
|
47
|
+
/** A terminal stream failure; the server sends this frame once and then closes. */
|
|
48
|
+
export interface StreamErrorEvent {
|
|
49
|
+
type: 'error';
|
|
50
|
+
error: ProtocolErrorEnvelope;
|
|
51
|
+
}
|
|
52
|
+
|
|
44
53
|
function object(value: unknown): Record<string, unknown> {
|
|
45
54
|
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
46
55
|
throw new ProtocolContractError('SSE data must be a JSON object.');
|
|
@@ -124,7 +133,7 @@ export function decodeEnrichedStreamEntry(value: unknown): EnrichedStreamEntry {
|
|
|
124
133
|
|
|
125
134
|
export function encodeSseEvent(event: Exclude<StreamEvent, { type: 'unknown' }>): string {
|
|
126
135
|
const lines = [`event: ${event.type}`];
|
|
127
|
-
let data:
|
|
136
|
+
let data: unknown;
|
|
128
137
|
if (event.type === 'log') {
|
|
129
138
|
const cursor = decodeLogCursor(event.cursor);
|
|
130
139
|
const entry = decodeEnrichedStreamEntry(event.entry);
|
|
@@ -142,6 +151,8 @@ export function encodeSseEvent(event: Exclude<StreamEvent, { type: 'unknown' }>)
|
|
|
142
151
|
actor_drone_id: decodeUuid(event.actor_drone_id, ['actor_drone_id']),
|
|
143
152
|
occurred_at: decodeCanonicalTimestamp(event.occurred_at, ['occurred_at']),
|
|
144
153
|
};
|
|
154
|
+
} else if (event.type === 'error') {
|
|
155
|
+
data = decodeProtocolErrorEnvelope(event.error);
|
|
145
156
|
} else if (event.type === 'heartbeat') {
|
|
146
157
|
data = {
|
|
147
158
|
at: decodeCanonicalTimestamp(event.at, ['at']),
|
|
@@ -217,7 +228,7 @@ function decodeFrame(frame: string): StreamEvent {
|
|
|
217
228
|
if (rawDataBytes > SSE_LIMITS.data_bytes) {
|
|
218
229
|
throw new ProtocolContractError('SSE data exceeds the byte limit.');
|
|
219
230
|
}
|
|
220
|
-
if (!['log', 'ack', 'claim', 'heartbeat', 'bookmark'].includes(eventName)) {
|
|
231
|
+
if (!['log', 'ack', 'claim', 'error', 'heartbeat', 'bookmark'].includes(eventName)) {
|
|
221
232
|
if (rawDataBytes > SSE_LIMITS.unknown_data_bytes) {
|
|
222
233
|
throw new ProtocolContractError('Unknown SSE event data exceeds the byte limit.');
|
|
223
234
|
}
|
|
@@ -247,6 +258,10 @@ function decodeFrame(frame: string): StreamEvent {
|
|
|
247
258
|
throw new ProtocolContractError(`${eventName} SSE events must not carry a resume id.`);
|
|
248
259
|
}
|
|
249
260
|
|
|
261
|
+
if (eventName === 'error') {
|
|
262
|
+
return { type: 'error', error: decodeProtocolErrorEnvelope(parsed) };
|
|
263
|
+
}
|
|
264
|
+
|
|
250
265
|
if (eventName === 'ack' || eventName === 'claim') {
|
|
251
266
|
exactKeys(data, ['log_entry_id', 'actor_drone_id', 'occurred_at'], [
|
|
252
267
|
'log_entry_id',
|
package/src/protocol/version.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/** Current Borg coordination protocol generation. Clean-slate
|
|
2
|
-
export const PROTOCOL_VERSION = '
|
|
1
|
+
/** Current Borg coordination protocol generation. Clean-slate v7. */
|
|
2
|
+
export const PROTOCOL_VERSION = '7' as const;
|
|
3
3
|
|
|
4
4
|
export type ProtocolVersion = typeof PROTOCOL_VERSION;
|