borgmcp-shared 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -21
- package/dist/conformance/adapter.d.ts +5 -4
- package/dist/conformance/adapter.d.ts.map +1 -1
- package/dist/conformance/adapter.js +18 -45
- package/dist/conformance/adapter.js.map +1 -1
- package/dist/protocol/contract.d.ts +43 -25
- package/dist/protocol/contract.d.ts.map +1 -1
- package/dist/protocol/contract.js +134 -116
- package/dist/protocol/contract.js.map +1 -1
- package/dist/protocol/errors.d.ts +2 -4
- package/dist/protocol/errors.d.ts.map +1 -1
- package/dist/protocol/errors.js +1 -1
- package/dist/protocol/errors.js.map +1 -1
- package/dist/protocol/version.d.ts +2 -10
- package/dist/protocol/version.d.ts.map +1 -1
- package/dist/protocol/version.js +1 -18
- package/dist/protocol/version.js.map +1 -1
- package/docs/compatibility.md +38 -50
- package/docs/enrollment.md +16 -9
- package/docs/releasing.md +22 -16
- package/package.json +1 -1
- package/src/conformance/adapter.ts +34 -60
- package/src/protocol/contract.ts +237 -160
- package/src/protocol/errors.ts +7 -3
- package/src/protocol/version.ts +3 -35
package/src/protocol/contract.ts
CHANGED
|
@@ -2,7 +2,7 @@ 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.
|
|
5
|
+
export const SHARED_PACKAGE_VERSION = '0.4.0' as const;
|
|
6
6
|
|
|
7
7
|
export const HEALTH_PATH = '/healthz' as const;
|
|
8
8
|
export const PROTOCOL_INFO_PATH = '/api/protocol' as const;
|
|
@@ -11,7 +11,7 @@ export const CUBES_PATH = '/api/cubes' as const;
|
|
|
11
11
|
|
|
12
12
|
export const PROTOCOL_HTTP_CONTRACT = {
|
|
13
13
|
health: { method: 'GET', path: HEALTH_PATH, authenticated: false, success_status: 204, bodyless: true },
|
|
14
|
-
protocol: { method: 'GET', path: PROTOCOL_INFO_PATH, authenticated:
|
|
14
|
+
protocol: { method: 'GET', path: PROTOCOL_INFO_PATH, authenticated: false, success_status: 200 },
|
|
15
15
|
enrollment: { method: 'POST', path: ENROLLMENT_EXCHANGE_PATH, authenticated: 'invitation', success_status: 201 },
|
|
16
16
|
cubes: { method: 'POST', path: CUBES_PATH, authenticated: true, success_status: 201 },
|
|
17
17
|
auth_missing_status: 401,
|
|
@@ -19,7 +19,6 @@ export const PROTOCOL_HTTP_CONTRACT = {
|
|
|
19
19
|
cursor_expired_status: 410,
|
|
20
20
|
content_too_large_status: 413,
|
|
21
21
|
unsupported_protocol_status: 426,
|
|
22
|
-
unsupported_capability_status: 501,
|
|
23
22
|
redirect_policy: 'error',
|
|
24
23
|
} as const;
|
|
25
24
|
|
|
@@ -30,49 +29,14 @@ export const PROTOCOL_LIMIT_CEILINGS = {
|
|
|
30
29
|
max_replay_page_size: 1000,
|
|
31
30
|
} as const;
|
|
32
31
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
'authority.no-cloud-fallback',
|
|
41
|
-
'log.cursor',
|
|
42
|
-
'stream.sse',
|
|
43
|
-
'stream.replay',
|
|
44
|
-
'acks',
|
|
45
|
-
'claims',
|
|
46
|
-
'decisions',
|
|
47
|
-
] as const;
|
|
48
|
-
|
|
49
|
-
export type KnownCapability = (typeof KNOWN_CAPABILITIES)[number];
|
|
50
|
-
export type Capability = KnownCapability | (string & {});
|
|
51
|
-
|
|
52
|
-
export const REQUIRED_SECURITY_CAPABILITIES = [
|
|
53
|
-
'auth.bearer',
|
|
54
|
-
'auth.revocation',
|
|
55
|
-
'auth.retry-safe-enrollment',
|
|
56
|
-
'scope.cube-isolation',
|
|
57
|
-
'transport.tls',
|
|
58
|
-
'authority.no-cloud-fallback',
|
|
59
|
-
] as const satisfies readonly Capability[];
|
|
60
|
-
|
|
61
|
-
export interface ProtocolLimits {
|
|
62
|
-
max_request_bytes: number;
|
|
63
|
-
max_log_message_bytes: number;
|
|
64
|
-
max_read_page_size: number;
|
|
65
|
-
max_replay_page_size: number;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
export interface ProtocolInfo {
|
|
32
|
+
/**
|
|
33
|
+
* The credential-free protocol-tag preflight body. It carries ONLY the exact
|
|
34
|
+
* protocol tag — no package version, limits, server identity, or other
|
|
35
|
+
* fingerprint surface — so a client can verify pinned TLS and the exact tag
|
|
36
|
+
* before it creates or sends any credential.
|
|
37
|
+
*/
|
|
38
|
+
export interface ProtocolTagPreflight {
|
|
69
39
|
protocol_version: ProtocolVersion;
|
|
70
|
-
package: {
|
|
71
|
-
name: typeof SHARED_PACKAGE_NAME;
|
|
72
|
-
version: string;
|
|
73
|
-
};
|
|
74
|
-
capabilities: Capability[];
|
|
75
|
-
limits: ProtocolLimits;
|
|
76
40
|
}
|
|
77
41
|
|
|
78
42
|
export interface ProtocolEnvelope<T> {
|
|
@@ -89,8 +53,6 @@ export interface ProtocolErrorEnvelope {
|
|
|
89
53
|
message: string;
|
|
90
54
|
details?: string;
|
|
91
55
|
retry_after?: number;
|
|
92
|
-
required_capability?: string;
|
|
93
|
-
supported_versions?: readonly string[];
|
|
94
56
|
};
|
|
95
57
|
}
|
|
96
58
|
|
|
@@ -220,17 +182,6 @@ export function utf8ByteLength(value: string): number {
|
|
|
220
182
|
return bytes;
|
|
221
183
|
}
|
|
222
184
|
|
|
223
|
-
function isSemanticVersion(value: string): boolean {
|
|
224
|
-
const match = value.match(
|
|
225
|
-
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/,
|
|
226
|
-
);
|
|
227
|
-
if (!match) return false;
|
|
228
|
-
const prerelease = match[4];
|
|
229
|
-
return prerelease === undefined || prerelease.split('.').every((identifier) =>
|
|
230
|
-
!/^\d+$/.test(identifier) || identifier === '0' || !identifier.startsWith('0')
|
|
231
|
-
);
|
|
232
|
-
}
|
|
233
|
-
|
|
234
185
|
function boundedPositiveInteger(
|
|
235
186
|
value: unknown,
|
|
236
187
|
maximum: number,
|
|
@@ -266,95 +217,32 @@ function decodeRequestId(value: unknown, path: readonly (string | number)[]): st
|
|
|
266
217
|
return decoded;
|
|
267
218
|
}
|
|
268
219
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
return
|
|
220
|
+
/**
|
|
221
|
+
* Emit the credential-free protocol-tag preflight body. Servers return exactly
|
|
222
|
+
* this — the tag and nothing else — from the unauthenticated `GET /api/protocol`.
|
|
223
|
+
*/
|
|
224
|
+
export function createProtocolTagPreflight(): ProtocolTagPreflight {
|
|
225
|
+
return { protocol_version: PROTOCOL_VERSION };
|
|
275
226
|
}
|
|
276
227
|
|
|
277
|
-
|
|
228
|
+
/**
|
|
229
|
+
* Decode the credential-free, mutation-free protocol-tag preflight. The body must
|
|
230
|
+
* be exactly `{ protocol_version }` and carry the exact expected tag; any other
|
|
231
|
+
* tag, an extra field, or a non-object fails closed before any credential is
|
|
232
|
+
* created or sent. This is the sole acceptance authority — there is no
|
|
233
|
+
* negotiation, capability list, or package/limit surface to inspect.
|
|
234
|
+
*/
|
|
235
|
+
export function decodeProtocolTagPreflight(value: unknown): ProtocolTagPreflight {
|
|
278
236
|
const input = record(value);
|
|
279
|
-
exactKeys(input, ['protocol_version', '
|
|
280
|
-
'protocol_version',
|
|
281
|
-
'package',
|
|
282
|
-
'capabilities',
|
|
283
|
-
'limits',
|
|
284
|
-
]);
|
|
237
|
+
exactKeys(input, ['protocol_version'], ['protocol_version']);
|
|
285
238
|
if (input.protocol_version !== PROTOCOL_VERSION) {
|
|
286
239
|
throw new ProtocolContractError(
|
|
287
|
-
|
|
240
|
+
'Unsupported protocol version.',
|
|
288
241
|
ErrorCode.UNSUPPORTED_PROTOCOL_VERSION,
|
|
289
242
|
['protocol_version'],
|
|
290
243
|
);
|
|
291
244
|
}
|
|
292
|
-
|
|
293
|
-
const packageInfo = record(input.package, ['package']);
|
|
294
|
-
exactKeys(packageInfo, ['name', 'version'], ['name', 'version'], ['package']);
|
|
295
|
-
if (packageInfo.name !== SHARED_PACKAGE_NAME) {
|
|
296
|
-
fail(`Expected package name "${SHARED_PACKAGE_NAME}".`, ['package', 'name']);
|
|
297
|
-
}
|
|
298
|
-
const packageVersion = boundedString(packageInfo.version, 5, 64, ['package', 'version']);
|
|
299
|
-
if (!isSemanticVersion(packageVersion)) {
|
|
300
|
-
fail('Expected a semantic package version.', ['package', 'version']);
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
if (!Array.isArray(input.capabilities)) fail('Expected an array.', ['capabilities']);
|
|
304
|
-
const capabilities = input.capabilities.map((capability, index) => {
|
|
305
|
-
return capabilityName(capability, ['capabilities', index]) as Capability;
|
|
306
|
-
});
|
|
307
|
-
if (new Set(capabilities).size !== capabilities.length) {
|
|
308
|
-
fail('Capabilities must be unique.', ['capabilities']);
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
const limits = record(input.limits, ['limits']);
|
|
312
|
-
exactKeys(
|
|
313
|
-
limits,
|
|
314
|
-
[
|
|
315
|
-
'max_request_bytes',
|
|
316
|
-
'max_log_message_bytes',
|
|
317
|
-
'max_read_page_size',
|
|
318
|
-
'max_replay_page_size',
|
|
319
|
-
],
|
|
320
|
-
[
|
|
321
|
-
'max_request_bytes',
|
|
322
|
-
'max_log_message_bytes',
|
|
323
|
-
'max_read_page_size',
|
|
324
|
-
'max_replay_page_size',
|
|
325
|
-
],
|
|
326
|
-
['limits'],
|
|
327
|
-
);
|
|
328
|
-
|
|
329
|
-
return {
|
|
330
|
-
protocol_version: PROTOCOL_VERSION,
|
|
331
|
-
package: { name: SHARED_PACKAGE_NAME, version: packageVersion },
|
|
332
|
-
capabilities,
|
|
333
|
-
limits: {
|
|
334
|
-
max_request_bytes: boundedPositiveInteger(limits.max_request_bytes, PROTOCOL_LIMIT_CEILINGS.max_request_bytes, ['limits', 'max_request_bytes']),
|
|
335
|
-
max_log_message_bytes: boundedPositiveInteger(limits.max_log_message_bytes, PROTOCOL_LIMIT_CEILINGS.max_log_message_bytes, ['limits', 'max_log_message_bytes']),
|
|
336
|
-
max_read_page_size: boundedPositiveInteger(limits.max_read_page_size, PROTOCOL_LIMIT_CEILINGS.max_read_page_size, ['limits', 'max_read_page_size']),
|
|
337
|
-
max_replay_page_size: boundedPositiveInteger(limits.max_replay_page_size, PROTOCOL_LIMIT_CEILINGS.max_replay_page_size, ['limits', 'max_replay_page_size']),
|
|
338
|
-
},
|
|
339
|
-
};
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
export function negotiateProtocol(
|
|
343
|
-
value: unknown,
|
|
344
|
-
requiredCapabilities: readonly Capability[] = [],
|
|
345
|
-
): ProtocolInfo {
|
|
346
|
-
const info = decodeProtocolInfo(value);
|
|
347
|
-
const required = [...REQUIRED_SECURITY_CAPABILITIES, ...requiredCapabilities];
|
|
348
|
-
for (const capability of new Set(required)) {
|
|
349
|
-
if (!info.capabilities.includes(capability)) {
|
|
350
|
-
throw new ProtocolContractError(
|
|
351
|
-
`Required capability "${capability}" is unavailable.`,
|
|
352
|
-
ErrorCode.UNSUPPORTED_CAPABILITY,
|
|
353
|
-
['capabilities'],
|
|
354
|
-
);
|
|
355
|
-
}
|
|
356
|
-
}
|
|
357
|
-
return info;
|
|
245
|
+
return { protocol_version: PROTOCOL_VERSION };
|
|
358
246
|
}
|
|
359
247
|
|
|
360
248
|
export function createProtocolEnvelope<T>(requestId: string, payload: T): ProtocolEnvelope<T> {
|
|
@@ -377,7 +265,7 @@ export function decodeProtocolEnvelope<T>(
|
|
|
377
265
|
]);
|
|
378
266
|
if (input.protocol_version !== PROTOCOL_VERSION) {
|
|
379
267
|
throw new ProtocolContractError(
|
|
380
|
-
|
|
268
|
+
'Unsupported protocol version.',
|
|
381
269
|
ErrorCode.UNSUPPORTED_PROTOCOL_VERSION,
|
|
382
270
|
['protocol_version'],
|
|
383
271
|
);
|
|
@@ -390,16 +278,13 @@ export function decodeProtocolEnvelope<T>(
|
|
|
390
278
|
};
|
|
391
279
|
}
|
|
392
280
|
|
|
393
|
-
export function decodeProtocolInfoEnvelope(value: unknown): ProtocolEnvelope<ProtocolInfo> {
|
|
394
|
-
return decodeProtocolEnvelope(value, decodeProtocolInfo);
|
|
395
|
-
}
|
|
396
281
|
|
|
397
282
|
export function decodeProtocolErrorEnvelope(value: unknown): ProtocolErrorEnvelope {
|
|
398
283
|
const input = record(value);
|
|
399
284
|
exactKeys(input, ['protocol_version', 'request_id', 'error'], ['protocol_version', 'error']);
|
|
400
285
|
if (input.protocol_version !== PROTOCOL_VERSION) {
|
|
401
286
|
throw new ProtocolContractError(
|
|
402
|
-
|
|
287
|
+
'Unsupported protocol version.',
|
|
403
288
|
ErrorCode.UNSUPPORTED_PROTOCOL_VERSION,
|
|
404
289
|
['protocol_version'],
|
|
405
290
|
);
|
|
@@ -412,8 +297,6 @@ export function decodeProtocolErrorEnvelope(value: unknown): ProtocolErrorEnvelo
|
|
|
412
297
|
'message',
|
|
413
298
|
'details',
|
|
414
299
|
'retry_after',
|
|
415
|
-
'required_capability',
|
|
416
|
-
'supported_versions',
|
|
417
300
|
],
|
|
418
301
|
['code', 'message'],
|
|
419
302
|
['error'],
|
|
@@ -435,21 +318,6 @@ export function decodeProtocolErrorEnvelope(value: unknown): ProtocolErrorEnvelo
|
|
|
435
318
|
if (error.retry_after !== undefined) {
|
|
436
319
|
decodedError.retry_after = boundedPositiveInteger(error.retry_after, 86_400, ['error', 'retry_after']);
|
|
437
320
|
}
|
|
438
|
-
if (error.required_capability !== undefined) {
|
|
439
|
-
decodedError.required_capability = capabilityName(
|
|
440
|
-
error.required_capability,
|
|
441
|
-
['error', 'required_capability'],
|
|
442
|
-
);
|
|
443
|
-
}
|
|
444
|
-
if (error.supported_versions !== undefined) {
|
|
445
|
-
if (!Array.isArray(error.supported_versions) || error.supported_versions.length === 0 ||
|
|
446
|
-
error.supported_versions.length > 16 ||
|
|
447
|
-
!error.supported_versions.every((version) => version === PROTOCOL_VERSION) ||
|
|
448
|
-
new Set(error.supported_versions).size !== error.supported_versions.length) {
|
|
449
|
-
fail('Invalid supported protocol versions.', ['error', 'supported_versions']);
|
|
450
|
-
}
|
|
451
|
-
decodedError.supported_versions = [...error.supported_versions] as ProtocolVersion[];
|
|
452
|
-
}
|
|
453
321
|
const decodedRequestId = input.request_id === undefined
|
|
454
322
|
? undefined
|
|
455
323
|
: decodeRequestId(input.request_id, ['request_id']);
|
|
@@ -722,3 +590,212 @@ export function maxLogCursor(a: LogCursor | null, b: LogCursor | null): LogCurso
|
|
|
722
590
|
if (b === null) return decodeLogCursor(a);
|
|
723
591
|
return compareLogCursor(a, b) >= 0 ? decodeLogCursor(a) : decodeLogCursor(b);
|
|
724
592
|
}
|
|
593
|
+
|
|
594
|
+
// ── v2 clean-slate wire types ──────────────────────────────────────────────
|
|
595
|
+
|
|
596
|
+
export const ATTACH_PATH = '/api/client/attach' as const;
|
|
597
|
+
|
|
598
|
+
export interface AttachRequest {
|
|
599
|
+
cube_id: string;
|
|
600
|
+
role_id: string;
|
|
601
|
+
session_credential: string;
|
|
602
|
+
prior_drone_id?: string;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
export interface AttachCube {
|
|
606
|
+
id: string;
|
|
607
|
+
name: string;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
export type AttachRoleClass = 'queen' | 'worker';
|
|
611
|
+
|
|
612
|
+
export interface AttachRole {
|
|
613
|
+
id: string;
|
|
614
|
+
name: string;
|
|
615
|
+
role_class?: AttachRoleClass;
|
|
616
|
+
is_human_seat?: boolean;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
export interface AttachDrone {
|
|
620
|
+
id: string;
|
|
621
|
+
label: string;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
export interface AttachSession {
|
|
625
|
+
id: string;
|
|
626
|
+
expires_at: string;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
export interface AttachResponse {
|
|
630
|
+
result: 'created' | 'reused';
|
|
631
|
+
cube: AttachCube;
|
|
632
|
+
role: AttachRole;
|
|
633
|
+
drone: AttachDrone;
|
|
634
|
+
session: AttachSession;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function decodeAttachCube(value: unknown, path: readonly (string | number)[]): AttachCube {
|
|
638
|
+
const input = record(value, path);
|
|
639
|
+
exactKeys(input, ['id', 'name'], ['id', 'name'], path);
|
|
640
|
+
return {
|
|
641
|
+
id: decodeUuid(input.id, [...path, 'id']),
|
|
642
|
+
name: boundedString(input.name, 1, 128, [...path, 'name']),
|
|
643
|
+
};
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
function decodeAttachRole(value: unknown, path: readonly (string | number)[]): AttachRole {
|
|
647
|
+
const input = record(value, path);
|
|
648
|
+
exactKeys(input, ['id', 'name', 'role_class', 'is_human_seat'], ['id', 'name'], path);
|
|
649
|
+
const result: AttachRole = {
|
|
650
|
+
id: decodeUuid(input.id, [...path, 'id']),
|
|
651
|
+
name: boundedString(input.name, 1, 128, [...path, 'name']),
|
|
652
|
+
};
|
|
653
|
+
if (input.role_class !== undefined) {
|
|
654
|
+
if (input.role_class !== 'queen' && input.role_class !== 'worker') {
|
|
655
|
+
fail('Expected role_class "queen" or "worker".', [...path, 'role_class']);
|
|
656
|
+
}
|
|
657
|
+
result.role_class = input.role_class;
|
|
658
|
+
}
|
|
659
|
+
if (input.is_human_seat !== undefined) {
|
|
660
|
+
if (typeof input.is_human_seat !== 'boolean') {
|
|
661
|
+
fail('Expected a boolean.', [...path, 'is_human_seat']);
|
|
662
|
+
}
|
|
663
|
+
result.is_human_seat = input.is_human_seat;
|
|
664
|
+
}
|
|
665
|
+
return result;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
function decodeAttachDrone(value: unknown, path: readonly (string | number)[]): AttachDrone {
|
|
669
|
+
const input = record(value, path);
|
|
670
|
+
exactKeys(input, ['id', 'label'], ['id', 'label'], path);
|
|
671
|
+
return {
|
|
672
|
+
id: decodeUuid(input.id, [...path, 'id']),
|
|
673
|
+
label: boundedString(input.label, 1, 128, [...path, 'label']),
|
|
674
|
+
};
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function decodeAttachSession(value: unknown, path: readonly (string | number)[]): AttachSession {
|
|
678
|
+
const input = record(value, path);
|
|
679
|
+
exactKeys(input, ['id', 'expires_at'], ['id', 'expires_at'], path);
|
|
680
|
+
return {
|
|
681
|
+
id: decodeUuid(input.id, [...path, 'id']),
|
|
682
|
+
expires_at: decodeCanonicalTimestamp(input.expires_at, [...path, 'expires_at']),
|
|
683
|
+
};
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/**
|
|
687
|
+
* Decode a v2 attach request. Strict: exact keys, bounded sizes,
|
|
688
|
+
* session_credential is token-safe and never echoed in errors.
|
|
689
|
+
*/
|
|
690
|
+
export function decodeAttachRequest(value: unknown): AttachRequest {
|
|
691
|
+
const input = record(value);
|
|
692
|
+
exactKeys(input, ['cube_id', 'role_id', 'session_credential', 'prior_drone_id'], [
|
|
693
|
+
'cube_id',
|
|
694
|
+
'role_id',
|
|
695
|
+
'session_credential',
|
|
696
|
+
]);
|
|
697
|
+
const result: AttachRequest = {
|
|
698
|
+
cube_id: decodeUuid(input.cube_id, ['cube_id']),
|
|
699
|
+
role_id: decodeUuid(input.role_id, ['role_id']),
|
|
700
|
+
session_credential: opaqueToken(input.session_credential, ['session_credential']),
|
|
701
|
+
};
|
|
702
|
+
if (input.prior_drone_id !== undefined) {
|
|
703
|
+
result.prior_drone_id = decodeUuid(input.prior_drone_id, ['prior_drone_id']);
|
|
704
|
+
}
|
|
705
|
+
return result;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/**
|
|
709
|
+
* Create a v2 attach request envelope. Stamps the canonical protocol version.
|
|
710
|
+
*/
|
|
711
|
+
export function createAttachRequestEnvelope(
|
|
712
|
+
requestId: string,
|
|
713
|
+
payload: AttachRequest,
|
|
714
|
+
): ProtocolEnvelope<AttachRequest> {
|
|
715
|
+
return {
|
|
716
|
+
protocol_version: PROTOCOL_VERSION,
|
|
717
|
+
request_id: decodeRequestId(requestId, ['request_id']),
|
|
718
|
+
payload,
|
|
719
|
+
};
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/**
|
|
723
|
+
* Decode a v2 attach request envelope. Verifies protocol_version === PROTOCOL_VERSION
|
|
724
|
+
* BEFORE decoding the payload — a wrong tag never invokes the payload decoder
|
|
725
|
+
* and never exposes or returns the supplied session_credential.
|
|
726
|
+
* Uses a static token-safe diagnostic; does not interpolate attacker-controlled text.
|
|
727
|
+
*/
|
|
728
|
+
export function decodeAttachRequestEnvelope(
|
|
729
|
+
value: unknown,
|
|
730
|
+
): ProtocolEnvelope<AttachRequest> {
|
|
731
|
+
const input = record(value);
|
|
732
|
+
exactKeys(input, ['protocol_version', 'request_id', 'payload'], [
|
|
733
|
+
'protocol_version',
|
|
734
|
+
'request_id',
|
|
735
|
+
'payload',
|
|
736
|
+
]);
|
|
737
|
+
if (input.protocol_version !== PROTOCOL_VERSION) {
|
|
738
|
+
throw new ProtocolContractError(
|
|
739
|
+
'Unsupported protocol version.',
|
|
740
|
+
ErrorCode.UNSUPPORTED_PROTOCOL_VERSION,
|
|
741
|
+
['protocol_version'],
|
|
742
|
+
);
|
|
743
|
+
}
|
|
744
|
+
const decodedRequestId = decodeRequestId(input.request_id, ['request_id']);
|
|
745
|
+
return {
|
|
746
|
+
protocol_version: PROTOCOL_VERSION,
|
|
747
|
+
request_id: decodedRequestId,
|
|
748
|
+
payload: decodeAttachRequest(input.payload),
|
|
749
|
+
};
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/**
|
|
753
|
+
* Decode a v2 attach response. Strict: exact keys, result discriminant,
|
|
754
|
+
* expires_at required non-null finite ISO-8601.
|
|
755
|
+
*/
|
|
756
|
+
export function decodeAttachResponse(value: unknown): AttachResponse {
|
|
757
|
+
const input = record(value);
|
|
758
|
+
exactKeys(input, ['result', 'cube', 'role', 'drone', 'session'], [
|
|
759
|
+
'result',
|
|
760
|
+
'cube',
|
|
761
|
+
'role',
|
|
762
|
+
'drone',
|
|
763
|
+
'session',
|
|
764
|
+
]);
|
|
765
|
+
if (input.result !== 'created' && input.result !== 'reused') {
|
|
766
|
+
fail('Expected result "created" or "reused".', ['result']);
|
|
767
|
+
}
|
|
768
|
+
return {
|
|
769
|
+
result: input.result,
|
|
770
|
+
cube: decodeAttachCube(input.cube, ['cube']),
|
|
771
|
+
role: decodeAttachRole(input.role, ['role']),
|
|
772
|
+
drone: decodeAttachDrone(input.drone, ['drone']),
|
|
773
|
+
session: decodeAttachSession(input.session, ['session']),
|
|
774
|
+
};
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
/**
|
|
778
|
+
* Decode a v2 attach response wrapped in a ProtocolEnvelope.
|
|
779
|
+
* Verifies protocol_version === PROTOCOL_VERSION before decoding payload.
|
|
780
|
+
*/
|
|
781
|
+
export function decodeAttachResponseEnvelope(value: unknown): ProtocolEnvelope<AttachResponse> {
|
|
782
|
+
const input = record(value);
|
|
783
|
+
exactKeys(input, ['protocol_version', 'request_id', 'payload'], [
|
|
784
|
+
'protocol_version',
|
|
785
|
+
'request_id',
|
|
786
|
+
'payload',
|
|
787
|
+
]);
|
|
788
|
+
if (input.protocol_version !== PROTOCOL_VERSION) {
|
|
789
|
+
throw new ProtocolContractError(
|
|
790
|
+
'Unsupported protocol version.',
|
|
791
|
+
ErrorCode.UNSUPPORTED_PROTOCOL_VERSION,
|
|
792
|
+
['protocol_version'],
|
|
793
|
+
);
|
|
794
|
+
}
|
|
795
|
+
const decodedRequestId = decodeRequestId(input.request_id, ['request_id']);
|
|
796
|
+
return {
|
|
797
|
+
protocol_version: PROTOCOL_VERSION,
|
|
798
|
+
request_id: decodedRequestId,
|
|
799
|
+
payload: decodeAttachResponse(input.payload),
|
|
800
|
+
};
|
|
801
|
+
}
|
package/src/protocol/errors.ts
CHANGED
|
@@ -18,10 +18,16 @@ export enum ErrorCode {
|
|
|
18
18
|
DRONE_EVICTED = 'DRONE_EVICTED',
|
|
19
19
|
DRONE_FROZEN = 'DRONE_FROZEN',
|
|
20
20
|
UNSUPPORTED_PROTOCOL_VERSION = 'UNSUPPORTED_PROTOCOL_VERSION',
|
|
21
|
-
UNSUPPORTED_CAPABILITY = 'UNSUPPORTED_CAPABILITY',
|
|
22
21
|
CURSOR_INVALID = 'CURSOR_INVALID',
|
|
23
22
|
CURSOR_EXPIRED = 'CURSOR_EXPIRED',
|
|
24
23
|
SESSION_REVOKED = 'SESSION_REVOKED',
|
|
24
|
+
/**
|
|
25
|
+
* The presented session bearer does not match the seat it targets: a fresh or
|
|
26
|
+
* non-matching bearer against an already-bound active seat. Distinct from
|
|
27
|
+
* SESSION_REVOKED (a formerly valid credential that was explicitly revoked or
|
|
28
|
+
* expired). Carried by the server's typed 401 takeover rejection.
|
|
29
|
+
*/
|
|
30
|
+
SESSION_REJECTED = 'SESSION_REJECTED',
|
|
25
31
|
}
|
|
26
32
|
|
|
27
33
|
/** @deprecated Wire failures use the versioned ProtocolErrorEnvelope. */
|
|
@@ -31,6 +37,4 @@ export interface ErrorResponse {
|
|
|
31
37
|
details?: string;
|
|
32
38
|
/** Number of seconds a rate-limited caller should wait. */
|
|
33
39
|
retryAfter?: number;
|
|
34
|
-
requiredCapability?: string;
|
|
35
|
-
supportedVersions?: readonly string[];
|
|
36
40
|
}
|
package/src/protocol/version.ts
CHANGED
|
@@ -1,36 +1,4 @@
|
|
|
1
|
-
/** Current Borg coordination protocol generation. */
|
|
2
|
-
export const PROTOCOL_VERSION = '
|
|
1
|
+
/** Current Borg coordination protocol generation. Clean-slate v2. */
|
|
2
|
+
export const PROTOCOL_VERSION = '2' as const;
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
export const SUPPORTED_PROTOCOL_VERSIONS = [PROTOCOL_VERSION] as const;
|
|
6
|
-
|
|
7
|
-
export type ProtocolVersion = (typeof SUPPORTED_PROTOCOL_VERSIONS)[number];
|
|
8
|
-
|
|
9
|
-
export interface CompatibilityEntry {
|
|
10
|
-
packageRange: string;
|
|
11
|
-
protocolVersions: readonly ProtocolVersion[];
|
|
12
|
-
notes: string;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* Compatibility table for published package releases. Pre-1.0 package
|
|
17
|
-
* releases may add contracts, but do not change an existing wire shape without
|
|
18
|
-
* a documented migration path.
|
|
19
|
-
*/
|
|
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
|
-
},
|
|
26
|
-
{
|
|
27
|
-
packageRange: '>=0.2.0 <0.3.0',
|
|
28
|
-
protocolVersions: SUPPORTED_PROTOCOL_VERSIONS,
|
|
29
|
-
notes: 'Legacy server-generated enrollment credential response.',
|
|
30
|
-
},
|
|
31
|
-
];
|
|
32
|
-
|
|
33
|
-
export function isProtocolVersionSupported(value: unknown): value is ProtocolVersion {
|
|
34
|
-
return typeof value === 'string' &&
|
|
35
|
-
(SUPPORTED_PROTOCOL_VERSIONS as readonly string[]).includes(value);
|
|
36
|
-
}
|
|
4
|
+
export type ProtocolVersion = typeof PROTOCOL_VERSION;
|