borgmcp-shared 0.2.2 → 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 +52 -35
- package/dist/conformance/adapter.d.ts +34 -6
- package/dist/conformance/adapter.d.ts.map +1 -1
- package/dist/conformance/adapter.js +215 -54
- 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 +81 -28
- package/dist/protocol/contract.d.ts.map +1 -1
- package/dist/protocol/contract.js +213 -128
- 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 -13
- package/dist/protocol/version.js.map +1 -1
- package/docs/compatibility.md +44 -25
- package/docs/enrollment.md +167 -0
- package/docs/releasing.md +77 -6
- package/package.json +2 -1
- package/src/conformance/adapter.ts +386 -63
- package/src/conformance/index.ts +139 -0
- package/src/protocol/contract.ts +373 -180
- package/src/protocol/errors.ts +7 -3
- package/src/protocol/version.ts +3 -30
package/src/protocol/contract.ts
CHANGED
|
@@ -2,22 +2,23 @@ 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;
|
|
9
9
|
export const ENROLLMENT_EXCHANGE_PATH = '/api/enrollment/exchange' as const;
|
|
10
|
+
export const CUBES_PATH = '/api/cubes' as const;
|
|
10
11
|
|
|
11
12
|
export const PROTOCOL_HTTP_CONTRACT = {
|
|
12
13
|
health: { method: 'GET', path: HEALTH_PATH, authenticated: false, success_status: 204, bodyless: true },
|
|
13
|
-
protocol: { method: 'GET', path: PROTOCOL_INFO_PATH, authenticated:
|
|
14
|
+
protocol: { method: 'GET', path: PROTOCOL_INFO_PATH, authenticated: false, success_status: 200 },
|
|
14
15
|
enrollment: { method: 'POST', path: ENROLLMENT_EXCHANGE_PATH, authenticated: 'invitation', success_status: 201 },
|
|
16
|
+
cubes: { method: 'POST', path: CUBES_PATH, authenticated: true, success_status: 201 },
|
|
15
17
|
auth_missing_status: 401,
|
|
16
18
|
auth_invalid_status: 401,
|
|
17
19
|
cursor_expired_status: 410,
|
|
18
20
|
content_too_large_status: 413,
|
|
19
21
|
unsupported_protocol_status: 426,
|
|
20
|
-
unsupported_capability_status: 501,
|
|
21
22
|
redirect_policy: 'error',
|
|
22
23
|
} as const;
|
|
23
24
|
|
|
@@ -28,47 +29,14 @@ export const PROTOCOL_LIMIT_CEILINGS = {
|
|
|
28
29
|
max_replay_page_size: 1000,
|
|
29
30
|
} as const;
|
|
30
31
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
'log.cursor',
|
|
39
|
-
'stream.sse',
|
|
40
|
-
'stream.replay',
|
|
41
|
-
'acks',
|
|
42
|
-
'claims',
|
|
43
|
-
'decisions',
|
|
44
|
-
] as const;
|
|
45
|
-
|
|
46
|
-
export type KnownCapability = (typeof KNOWN_CAPABILITIES)[number];
|
|
47
|
-
export type Capability = KnownCapability | (string & {});
|
|
48
|
-
|
|
49
|
-
export const REQUIRED_SECURITY_CAPABILITIES = [
|
|
50
|
-
'auth.bearer',
|
|
51
|
-
'auth.revocation',
|
|
52
|
-
'scope.cube-isolation',
|
|
53
|
-
'transport.tls',
|
|
54
|
-
'authority.no-cloud-fallback',
|
|
55
|
-
] as const satisfies readonly Capability[];
|
|
56
|
-
|
|
57
|
-
export interface ProtocolLimits {
|
|
58
|
-
max_request_bytes: number;
|
|
59
|
-
max_log_message_bytes: number;
|
|
60
|
-
max_read_page_size: number;
|
|
61
|
-
max_replay_page_size: number;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
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 {
|
|
65
39
|
protocol_version: ProtocolVersion;
|
|
66
|
-
package: {
|
|
67
|
-
name: typeof SHARED_PACKAGE_NAME;
|
|
68
|
-
version: string;
|
|
69
|
-
};
|
|
70
|
-
capabilities: Capability[];
|
|
71
|
-
limits: ProtocolLimits;
|
|
72
40
|
}
|
|
73
41
|
|
|
74
42
|
export interface ProtocolEnvelope<T> {
|
|
@@ -85,22 +53,52 @@ export interface ProtocolErrorEnvelope {
|
|
|
85
53
|
message: string;
|
|
86
54
|
details?: string;
|
|
87
55
|
retry_after?: number;
|
|
88
|
-
required_capability?: string;
|
|
89
|
-
supported_versions?: readonly string[];
|
|
90
56
|
};
|
|
91
57
|
}
|
|
92
58
|
|
|
93
|
-
/**
|
|
59
|
+
/** All secret values are generated and persisted pending by the client before send. */
|
|
94
60
|
export interface EnrollmentExchangeRequest {
|
|
95
61
|
invitation: string;
|
|
62
|
+
retry_key: string;
|
|
63
|
+
client_credential: string;
|
|
96
64
|
client_name?: string;
|
|
97
65
|
}
|
|
98
66
|
|
|
99
|
-
|
|
100
|
-
export
|
|
67
|
+
export const SERVER_CAPABILITIES = ['create_cube'] as const;
|
|
68
|
+
export type ServerCapability = (typeof SERVER_CAPABILITIES)[number];
|
|
69
|
+
|
|
70
|
+
/** Ordinary enrollment creates an ungranted client and never returns a bearer. */
|
|
71
|
+
export interface ClientEnrollmentExchangeResponse {
|
|
72
|
+
purpose: 'client';
|
|
73
|
+
client_id: string;
|
|
74
|
+
server_capabilities: [];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Owner enrollment grants only the narrow authority to create cubes. */
|
|
78
|
+
export interface OwnerEnrollmentExchangeResponse {
|
|
79
|
+
purpose: 'owner';
|
|
101
80
|
client_id: string;
|
|
102
|
-
|
|
103
|
-
|
|
81
|
+
server_capabilities: ['create_cube'];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export type EnrollmentExchangeResponse =
|
|
85
|
+
| ClientEnrollmentExchangeResponse
|
|
86
|
+
| OwnerEnrollmentExchangeResponse;
|
|
87
|
+
|
|
88
|
+
export const CUBE_TEMPLATES = ['default'] as const;
|
|
89
|
+
export type CubeTemplate = (typeof CUBE_TEMPLATES)[number];
|
|
90
|
+
|
|
91
|
+
export interface CreateCubeRequest {
|
|
92
|
+
retry_key: string;
|
|
93
|
+
name: string;
|
|
94
|
+
template: CubeTemplate;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface CreateCubeResponse {
|
|
98
|
+
cube_id: string;
|
|
99
|
+
human_seat_role_id: string;
|
|
100
|
+
default_worker_role_id: string;
|
|
101
|
+
access: 'manage';
|
|
104
102
|
}
|
|
105
103
|
|
|
106
104
|
export interface AckLogRequest {
|
|
@@ -184,17 +182,6 @@ export function utf8ByteLength(value: string): number {
|
|
|
184
182
|
return bytes;
|
|
185
183
|
}
|
|
186
184
|
|
|
187
|
-
function isSemanticVersion(value: string): boolean {
|
|
188
|
-
const match = value.match(
|
|
189
|
-
/^(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-]+)*))?$/,
|
|
190
|
-
);
|
|
191
|
-
if (!match) return false;
|
|
192
|
-
const prerelease = match[4];
|
|
193
|
-
return prerelease === undefined || prerelease.split('.').every((identifier) =>
|
|
194
|
-
!/^\d+$/.test(identifier) || identifier === '0' || !identifier.startsWith('0')
|
|
195
|
-
);
|
|
196
|
-
}
|
|
197
|
-
|
|
198
185
|
function boundedPositiveInteger(
|
|
199
186
|
value: unknown,
|
|
200
187
|
maximum: number,
|
|
@@ -230,95 +217,32 @@ function decodeRequestId(value: unknown, path: readonly (string | number)[]): st
|
|
|
230
217
|
return decoded;
|
|
231
218
|
}
|
|
232
219
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
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 };
|
|
239
226
|
}
|
|
240
227
|
|
|
241
|
-
|
|
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 {
|
|
242
236
|
const input = record(value);
|
|
243
|
-
exactKeys(input, ['protocol_version', '
|
|
244
|
-
'protocol_version',
|
|
245
|
-
'package',
|
|
246
|
-
'capabilities',
|
|
247
|
-
'limits',
|
|
248
|
-
]);
|
|
237
|
+
exactKeys(input, ['protocol_version'], ['protocol_version']);
|
|
249
238
|
if (input.protocol_version !== PROTOCOL_VERSION) {
|
|
250
239
|
throw new ProtocolContractError(
|
|
251
|
-
|
|
240
|
+
'Unsupported protocol version.',
|
|
252
241
|
ErrorCode.UNSUPPORTED_PROTOCOL_VERSION,
|
|
253
242
|
['protocol_version'],
|
|
254
243
|
);
|
|
255
244
|
}
|
|
256
|
-
|
|
257
|
-
const packageInfo = record(input.package, ['package']);
|
|
258
|
-
exactKeys(packageInfo, ['name', 'version'], ['name', 'version'], ['package']);
|
|
259
|
-
if (packageInfo.name !== SHARED_PACKAGE_NAME) {
|
|
260
|
-
fail(`Expected package name "${SHARED_PACKAGE_NAME}".`, ['package', 'name']);
|
|
261
|
-
}
|
|
262
|
-
const packageVersion = boundedString(packageInfo.version, 5, 64, ['package', 'version']);
|
|
263
|
-
if (!isSemanticVersion(packageVersion)) {
|
|
264
|
-
fail('Expected a semantic package version.', ['package', 'version']);
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
if (!Array.isArray(input.capabilities)) fail('Expected an array.', ['capabilities']);
|
|
268
|
-
const capabilities = input.capabilities.map((capability, index) => {
|
|
269
|
-
return capabilityName(capability, ['capabilities', index]) as Capability;
|
|
270
|
-
});
|
|
271
|
-
if (new Set(capabilities).size !== capabilities.length) {
|
|
272
|
-
fail('Capabilities must be unique.', ['capabilities']);
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
const limits = record(input.limits, ['limits']);
|
|
276
|
-
exactKeys(
|
|
277
|
-
limits,
|
|
278
|
-
[
|
|
279
|
-
'max_request_bytes',
|
|
280
|
-
'max_log_message_bytes',
|
|
281
|
-
'max_read_page_size',
|
|
282
|
-
'max_replay_page_size',
|
|
283
|
-
],
|
|
284
|
-
[
|
|
285
|
-
'max_request_bytes',
|
|
286
|
-
'max_log_message_bytes',
|
|
287
|
-
'max_read_page_size',
|
|
288
|
-
'max_replay_page_size',
|
|
289
|
-
],
|
|
290
|
-
['limits'],
|
|
291
|
-
);
|
|
292
|
-
|
|
293
|
-
return {
|
|
294
|
-
protocol_version: PROTOCOL_VERSION,
|
|
295
|
-
package: { name: SHARED_PACKAGE_NAME, version: packageVersion },
|
|
296
|
-
capabilities,
|
|
297
|
-
limits: {
|
|
298
|
-
max_request_bytes: boundedPositiveInteger(limits.max_request_bytes, PROTOCOL_LIMIT_CEILINGS.max_request_bytes, ['limits', 'max_request_bytes']),
|
|
299
|
-
max_log_message_bytes: boundedPositiveInteger(limits.max_log_message_bytes, PROTOCOL_LIMIT_CEILINGS.max_log_message_bytes, ['limits', 'max_log_message_bytes']),
|
|
300
|
-
max_read_page_size: boundedPositiveInteger(limits.max_read_page_size, PROTOCOL_LIMIT_CEILINGS.max_read_page_size, ['limits', 'max_read_page_size']),
|
|
301
|
-
max_replay_page_size: boundedPositiveInteger(limits.max_replay_page_size, PROTOCOL_LIMIT_CEILINGS.max_replay_page_size, ['limits', 'max_replay_page_size']),
|
|
302
|
-
},
|
|
303
|
-
};
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
export function negotiateProtocol(
|
|
307
|
-
value: unknown,
|
|
308
|
-
requiredCapabilities: readonly Capability[] = [],
|
|
309
|
-
): ProtocolInfo {
|
|
310
|
-
const info = decodeProtocolInfo(value);
|
|
311
|
-
const required = [...REQUIRED_SECURITY_CAPABILITIES, ...requiredCapabilities];
|
|
312
|
-
for (const capability of new Set(required)) {
|
|
313
|
-
if (!info.capabilities.includes(capability)) {
|
|
314
|
-
throw new ProtocolContractError(
|
|
315
|
-
`Required capability "${capability}" is unavailable.`,
|
|
316
|
-
ErrorCode.UNSUPPORTED_CAPABILITY,
|
|
317
|
-
['capabilities'],
|
|
318
|
-
);
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
return info;
|
|
245
|
+
return { protocol_version: PROTOCOL_VERSION };
|
|
322
246
|
}
|
|
323
247
|
|
|
324
248
|
export function createProtocolEnvelope<T>(requestId: string, payload: T): ProtocolEnvelope<T> {
|
|
@@ -341,7 +265,7 @@ export function decodeProtocolEnvelope<T>(
|
|
|
341
265
|
]);
|
|
342
266
|
if (input.protocol_version !== PROTOCOL_VERSION) {
|
|
343
267
|
throw new ProtocolContractError(
|
|
344
|
-
|
|
268
|
+
'Unsupported protocol version.',
|
|
345
269
|
ErrorCode.UNSUPPORTED_PROTOCOL_VERSION,
|
|
346
270
|
['protocol_version'],
|
|
347
271
|
);
|
|
@@ -354,16 +278,13 @@ export function decodeProtocolEnvelope<T>(
|
|
|
354
278
|
};
|
|
355
279
|
}
|
|
356
280
|
|
|
357
|
-
export function decodeProtocolInfoEnvelope(value: unknown): ProtocolEnvelope<ProtocolInfo> {
|
|
358
|
-
return decodeProtocolEnvelope(value, decodeProtocolInfo);
|
|
359
|
-
}
|
|
360
281
|
|
|
361
282
|
export function decodeProtocolErrorEnvelope(value: unknown): ProtocolErrorEnvelope {
|
|
362
283
|
const input = record(value);
|
|
363
284
|
exactKeys(input, ['protocol_version', 'request_id', 'error'], ['protocol_version', 'error']);
|
|
364
285
|
if (input.protocol_version !== PROTOCOL_VERSION) {
|
|
365
286
|
throw new ProtocolContractError(
|
|
366
|
-
|
|
287
|
+
'Unsupported protocol version.',
|
|
367
288
|
ErrorCode.UNSUPPORTED_PROTOCOL_VERSION,
|
|
368
289
|
['protocol_version'],
|
|
369
290
|
);
|
|
@@ -376,8 +297,6 @@ export function decodeProtocolErrorEnvelope(value: unknown): ProtocolErrorEnvelo
|
|
|
376
297
|
'message',
|
|
377
298
|
'details',
|
|
378
299
|
'retry_after',
|
|
379
|
-
'required_capability',
|
|
380
|
-
'supported_versions',
|
|
381
300
|
],
|
|
382
301
|
['code', 'message'],
|
|
383
302
|
['error'],
|
|
@@ -399,21 +318,6 @@ export function decodeProtocolErrorEnvelope(value: unknown): ProtocolErrorEnvelo
|
|
|
399
318
|
if (error.retry_after !== undefined) {
|
|
400
319
|
decodedError.retry_after = boundedPositiveInteger(error.retry_after, 86_400, ['error', 'retry_after']);
|
|
401
320
|
}
|
|
402
|
-
if (error.required_capability !== undefined) {
|
|
403
|
-
decodedError.required_capability = capabilityName(
|
|
404
|
-
error.required_capability,
|
|
405
|
-
['error', 'required_capability'],
|
|
406
|
-
);
|
|
407
|
-
}
|
|
408
|
-
if (error.supported_versions !== undefined) {
|
|
409
|
-
if (!Array.isArray(error.supported_versions) || error.supported_versions.length === 0 ||
|
|
410
|
-
error.supported_versions.length > 16 ||
|
|
411
|
-
!error.supported_versions.every((version) => version === PROTOCOL_VERSION) ||
|
|
412
|
-
new Set(error.supported_versions).size !== error.supported_versions.length) {
|
|
413
|
-
fail('Invalid supported protocol versions.', ['error', 'supported_versions']);
|
|
414
|
-
}
|
|
415
|
-
decodedError.supported_versions = [...error.supported_versions] as ProtocolVersion[];
|
|
416
|
-
}
|
|
417
321
|
const decodedRequestId = input.request_id === undefined
|
|
418
322
|
? undefined
|
|
419
323
|
: decodeRequestId(input.request_id, ['request_id']);
|
|
@@ -424,15 +328,29 @@ export function decodeProtocolErrorEnvelope(value: unknown): ProtocolErrorEnvelo
|
|
|
424
328
|
|
|
425
329
|
export function decodeEnrollmentExchangeRequest(value: unknown): EnrollmentExchangeRequest {
|
|
426
330
|
const input = record(value);
|
|
427
|
-
exactKeys(
|
|
331
|
+
exactKeys(
|
|
332
|
+
input,
|
|
333
|
+
['invitation', 'retry_key', 'client_credential', 'client_name'],
|
|
334
|
+
['invitation', 'retry_key', 'client_credential'],
|
|
335
|
+
);
|
|
428
336
|
const invitation = opaqueToken(input.invitation, ['invitation']);
|
|
337
|
+
const retryKey = decodeUuid(input.retry_key, ['retry_key']);
|
|
338
|
+
const clientCredential = decodeEnrollmentClientCredential(
|
|
339
|
+
input.client_credential,
|
|
340
|
+
['client_credential'],
|
|
341
|
+
);
|
|
429
342
|
const clientName = input.client_name === undefined
|
|
430
343
|
? undefined
|
|
431
344
|
: boundedString(input.client_name, 1, 120, ['client_name']);
|
|
432
345
|
if (clientName !== undefined && !/^[A-Za-z0-9][A-Za-z0-9 ._-]*$/.test(clientName)) {
|
|
433
346
|
fail('Client name contains unsupported characters.', ['client_name']);
|
|
434
347
|
}
|
|
435
|
-
|
|
348
|
+
const request = {
|
|
349
|
+
invitation,
|
|
350
|
+
retry_key: retryKey,
|
|
351
|
+
client_credential: clientCredential,
|
|
352
|
+
};
|
|
353
|
+
return clientName === undefined ? request : { ...request, client_name: clientName };
|
|
436
354
|
}
|
|
437
355
|
|
|
438
356
|
export function decodeEnrollmentExchangeRequestEnvelope(
|
|
@@ -443,21 +361,23 @@ export function decodeEnrollmentExchangeRequestEnvelope(
|
|
|
443
361
|
|
|
444
362
|
export function decodeEnrollmentExchangeResponse(value: unknown): EnrollmentExchangeResponse {
|
|
445
363
|
const input = record(value);
|
|
446
|
-
|
|
447
|
-
input,
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
return
|
|
459
|
-
|
|
460
|
-
:
|
|
364
|
+
if (input.purpose === 'client') {
|
|
365
|
+
exactKeys(input, ['purpose', 'client_id', 'server_capabilities'], ['purpose', 'client_id', 'server_capabilities']);
|
|
366
|
+
decodeExactServerCapabilities(input.server_capabilities, [], ['server_capabilities']);
|
|
367
|
+
return {
|
|
368
|
+
purpose: 'client',
|
|
369
|
+
client_id: decodeUuid(input.client_id, ['client_id']),
|
|
370
|
+
server_capabilities: [],
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
if (input.purpose !== 'owner') fail('Invalid enrollment purpose.', ['purpose']);
|
|
374
|
+
exactKeys(input, ['purpose', 'client_id', 'server_capabilities'], ['purpose', 'client_id', 'server_capabilities']);
|
|
375
|
+
decodeExactServerCapabilities(input.server_capabilities, ['create_cube'], ['server_capabilities']);
|
|
376
|
+
return {
|
|
377
|
+
purpose: 'owner',
|
|
378
|
+
client_id: decodeUuid(input.client_id, ['client_id']),
|
|
379
|
+
server_capabilities: ['create_cube'],
|
|
380
|
+
};
|
|
461
381
|
}
|
|
462
382
|
|
|
463
383
|
export function decodeEnrollmentExchangeResponseEnvelope(
|
|
@@ -466,6 +386,58 @@ export function decodeEnrollmentExchangeResponseEnvelope(
|
|
|
466
386
|
return decodeProtocolEnvelope(value, decodeEnrollmentExchangeResponse);
|
|
467
387
|
}
|
|
468
388
|
|
|
389
|
+
function decodeExactServerCapabilities(
|
|
390
|
+
value: unknown,
|
|
391
|
+
expected: readonly ServerCapability[],
|
|
392
|
+
path: readonly (string | number)[],
|
|
393
|
+
): void {
|
|
394
|
+
if (!Array.isArray(value) || value.length !== expected.length ||
|
|
395
|
+
value.some((capability, index) => capability !== expected[index])) {
|
|
396
|
+
fail(`Expected server capabilities [${expected.join(', ')}].`, path);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
export function decodeCreateCubeRequest(value: unknown): CreateCubeRequest {
|
|
401
|
+
const input = record(value);
|
|
402
|
+
exactKeys(input, ['retry_key', 'name', 'template'], ['retry_key', 'name', 'template']);
|
|
403
|
+
const name = boundedString(input.name, 1, 120, ['name']);
|
|
404
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9 ._-]*$/.test(name)) {
|
|
405
|
+
fail('Cube name contains unsupported characters.', ['name']);
|
|
406
|
+
}
|
|
407
|
+
if (!CUBE_TEMPLATES.includes(input.template as CubeTemplate)) {
|
|
408
|
+
fail('Unsupported cube template.', ['template']);
|
|
409
|
+
}
|
|
410
|
+
return {
|
|
411
|
+
retry_key: decodeUuid(input.retry_key, ['retry_key']),
|
|
412
|
+
name,
|
|
413
|
+
template: input.template as CubeTemplate,
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
export function decodeCreateCubeRequestEnvelope(value: unknown): ProtocolEnvelope<CreateCubeRequest> {
|
|
418
|
+
return decodeProtocolEnvelope(value, decodeCreateCubeRequest);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
export function decodeCreateCubeResponse(value: unknown): CreateCubeResponse {
|
|
422
|
+
const input = record(value);
|
|
423
|
+
exactKeys(
|
|
424
|
+
input,
|
|
425
|
+
['cube_id', 'human_seat_role_id', 'default_worker_role_id', 'access'],
|
|
426
|
+
['cube_id', 'human_seat_role_id', 'default_worker_role_id', 'access'],
|
|
427
|
+
);
|
|
428
|
+
if (input.access !== 'manage') fail('Created cube access must be manage.', ['access']);
|
|
429
|
+
return {
|
|
430
|
+
cube_id: decodeUuid(input.cube_id, ['cube_id']),
|
|
431
|
+
human_seat_role_id: decodeUuid(input.human_seat_role_id, ['human_seat_role_id']),
|
|
432
|
+
default_worker_role_id: decodeUuid(input.default_worker_role_id, ['default_worker_role_id']),
|
|
433
|
+
access: 'manage',
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
export function decodeCreateCubeResponseEnvelope(value: unknown): ProtocolEnvelope<CreateCubeResponse> {
|
|
438
|
+
return decodeProtocolEnvelope(value, decodeCreateCubeResponse);
|
|
439
|
+
}
|
|
440
|
+
|
|
469
441
|
export function decodeAppendLogRequest(value: unknown): import('./types.js').AppendLogRequest {
|
|
470
442
|
const input = record(value);
|
|
471
443
|
exactKeys(
|
|
@@ -577,6 +549,17 @@ export function decodeUuid(value: unknown, path: readonly (string | number)[] =
|
|
|
577
549
|
return id.toLowerCase();
|
|
578
550
|
}
|
|
579
551
|
|
|
552
|
+
export function decodeEnrollmentClientCredential(
|
|
553
|
+
value: unknown,
|
|
554
|
+
path: readonly (string | number)[] = [],
|
|
555
|
+
): string {
|
|
556
|
+
const credential = boundedString(value, 43, 43, path);
|
|
557
|
+
if (!/^[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$/.test(credential)) {
|
|
558
|
+
fail('Expected an unpadded base64url encoding of exactly 256 bits.', path);
|
|
559
|
+
}
|
|
560
|
+
return credential;
|
|
561
|
+
}
|
|
562
|
+
|
|
580
563
|
export function decodeOpaqueIdentifier(
|
|
581
564
|
value: unknown,
|
|
582
565
|
path: readonly (string | number)[] = [],
|
|
@@ -586,6 +569,7 @@ export function decodeOpaqueIdentifier(
|
|
|
586
569
|
|
|
587
570
|
export function redactProtocolDiagnostic(value: string): string {
|
|
588
571
|
return value
|
|
572
|
+
.replace(/(\bretry[_-]?key\b["']?\s*(?:=|:)\s*["']?)[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/gi, '$1<REDACTED>')
|
|
589
573
|
.replace(/[\u0000-\u001f\u007f-\u009f]/g, (character) =>
|
|
590
574
|
`\\u${character.charCodeAt(0).toString(16).padStart(4, '0')}`
|
|
591
575
|
)
|
|
@@ -606,3 +590,212 @@ export function maxLogCursor(a: LogCursor | null, b: LogCursor | null): LogCurso
|
|
|
606
590
|
if (b === null) return decodeLogCursor(a);
|
|
607
591
|
return compareLogCursor(a, b) >= 0 ? decodeLogCursor(a) : decodeLogCursor(b);
|
|
608
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
|
+
}
|