borgmcp-shared 0.12.3 → 0.13.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 +14 -0
- package/RELEASES.md +16 -0
- package/dist/conformance/adapter.d.ts +7 -0
- package/dist/conformance/adapter.d.ts.map +1 -1
- package/dist/conformance/adapter.js +157 -3
- package/dist/conformance/adapter.js.map +1 -1
- package/dist/conformance/index.d.ts +33 -0
- package/dist/conformance/index.d.ts.map +1 -1
- package/dist/conformance/index.js +10 -0
- package/dist/conformance/index.js.map +1 -1
- package/dist/protocol/contract.d.ts +36 -2
- package/dist/protocol/contract.d.ts.map +1 -1
- package/dist/protocol/contract.js +19 -5
- package/dist/protocol/contract.js.map +1 -1
- package/dist/protocol/coordination.d.ts.map +1 -1
- package/dist/protocol/coordination.js +11 -2
- package/dist/protocol/coordination.js.map +1 -1
- package/dist/protocol/documents.d.ts +78 -0
- package/dist/protocol/documents.d.ts.map +1 -0
- package/dist/protocol/documents.js +196 -0
- package/dist/protocol/documents.js.map +1 -0
- package/dist/protocol/errors.d.ts +5 -0
- package/dist/protocol/errors.d.ts.map +1 -1
- package/dist/protocol/errors.js +5 -0
- package/dist/protocol/errors.js.map +1 -1
- package/dist/protocol/index.d.ts +1 -0
- package/dist/protocol/index.d.ts.map +1 -1
- package/dist/protocol/index.js +1 -0
- package/dist/protocol/index.js.map +1 -1
- package/dist/protocol/sse.d.ts +2 -2
- package/dist/protocol/sse.d.ts.map +1 -1
- package/dist/protocol/sse.js +33 -4
- package/dist/protocol/sse.js.map +1 -1
- package/dist/protocol/types.d.ts +7 -0
- package/dist/protocol/types.d.ts.map +1 -1
- package/dist/protocol/version.d.ts +1 -1
- package/dist/protocol/version.d.ts.map +1 -1
- package/dist/protocol/version.js +1 -1
- package/dist/protocol/version.js.map +1 -1
- package/docs/compatibility.md +5 -0
- package/docs/cube-documents.md +35 -0
- package/docs/release-records.json +15 -0
- package/docs/releases/0.13.0.md +7 -0
- package/package.json +1 -1
- package/src/conformance/adapter.ts +340 -1
- package/src/conformance/index.ts +11 -0
- package/src/protocol/contract.ts +19 -5
- package/src/protocol/coordination.ts +15 -1
- package/src/protocol/documents.ts +256 -0
- package/src/protocol/errors.ts +5 -0
- package/src/protocol/index.ts +1 -0
- package/src/protocol/sse.ts +38 -3
- package/src/protocol/types.ts +7 -0
- package/src/protocol/version.ts +2 -2
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Cube document contract
|
|
2
|
+
|
|
3
|
+
Protocol v10 adds a cube-scoped working set for immutable text documents.
|
|
4
|
+
Implementations accept only UTF-8 `text/markdown` and `text/plain` content. Each
|
|
5
|
+
document has one opaque full id and a required, non-unique title of at most 120
|
|
6
|
+
Unicode characters. Titles support discovery; only the complete id addresses a
|
|
7
|
+
document.
|
|
8
|
+
|
|
9
|
+
The default per-document budget is 65,536 bytes
|
|
10
|
+
(`BORG_SERVER_MAX_DOCUMENT_BYTES`) and the default active budget per cube is
|
|
11
|
+
524,288 bytes (`BORG_SERVER_MAX_ACTIVE_DOCUMENT_BYTES_PER_CUBE`). Servers may
|
|
12
|
+
configure both budgets, validate their configuration at startup, and refuse a
|
|
13
|
+
write atomically when either budget is exceeded. Superseded revisions continue
|
|
14
|
+
to count until explicitly removed.
|
|
15
|
+
|
|
16
|
+
Creation may carry `supersedes`, which must identify an existing document in the
|
|
17
|
+
same cube. A document can be superseded by at most one later revision, producing
|
|
18
|
+
a linear chain. Supersession never edits content or hides a revision.
|
|
19
|
+
|
|
20
|
+
All cube seats may read documents. A write-authorized seat may create one. Only
|
|
21
|
+
the author or a cube manager may remove it. Removal hides the document from the
|
|
22
|
+
active list and active-byte budget but retains its immutable content and audit
|
|
23
|
+
metadata for exact-id resolution by coordination records.
|
|
24
|
+
|
|
25
|
+
Log creation may carry a structured `documents` array of full ids. The server
|
|
26
|
+
validates every id before writing the entry. Log reads and streams render each
|
|
27
|
+
citation as id, title, UTF-8 size, and current active, superseded, or removed
|
|
28
|
+
state. Inline text that resembles an id has no citation semantics.
|
|
29
|
+
|
|
30
|
+
The configurable defaults are `BORG_SERVER_LOG_ENTRY_ADVISORY_BYTES=1024` and
|
|
31
|
+
`BORG_SERVER_MAX_LOG_ENTRY_BYTES=4096`. Valid non-default thresholds are carried
|
|
32
|
+
on the wire; the server enforces its configured values. At the defaults, log
|
|
33
|
+
text up to 1,024 UTF-8 bytes is accepted silently. Text through 4,096 bytes is
|
|
34
|
+
accepted with the `STORE_AS_DOCUMENT` advisory. Larger log text is rejected;
|
|
35
|
+
the caller stores the detail as a document and cites it from a shorter entry.
|
|
@@ -185,5 +185,20 @@
|
|
|
185
185
|
"verify_job_id": null,
|
|
186
186
|
"publish_job_id": null,
|
|
187
187
|
"artifact_integrity": "sha512-l459XEeqk0cSz1+Z8yk8cCVWik4/CX4OBTRZqj6n1SZYvDpzWJksUz82FA9k4taf//rs43Tfl1tpWXnRHAqxOQ=="
|
|
188
|
+
},
|
|
189
|
+
{
|
|
190
|
+
"outcome": "published",
|
|
191
|
+
"version": "0.12.3",
|
|
192
|
+
"tag": "v0.12.3",
|
|
193
|
+
"tag_object": "e410256092974dcef9619415ebdf74acde3e4484",
|
|
194
|
+
"commit": "9bfa43ced2af772d7a60f7be7828baaa146b4fd4",
|
|
195
|
+
"tree": "7605783c749615e13d9eb541c498856833ab690c",
|
|
196
|
+
"workflow_run_id": 31779837930,
|
|
197
|
+
"workflow_run_attempt": 1,
|
|
198
|
+
"workflow_conclusion": "success",
|
|
199
|
+
"verify_job_id": null,
|
|
200
|
+
"publish_job_id": null,
|
|
201
|
+
"artifact_integrity": "sha512-3GPQ1U7tBxg8Jp1Uac31CKKXQjMv4UNPhs5P6N83CyDXGJvkI88yowVJZGlLuo30eEk6jhERZ9AQWOjHst/sFA==",
|
|
202
|
+
"reconstructed": true
|
|
188
203
|
}
|
|
189
204
|
]
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
This release introduces the protocol v10 cube-document contract used by the
|
|
2
|
+
matching Borg MCP server and client releases.
|
|
3
|
+
|
|
4
|
+
- Cubes gain immutable Markdown and plain-text documents with bounded storage, linear supersession, and explicit removal semantics.
|
|
5
|
+
- Log entries can cite documents by full id, with current document metadata rendered consistently in reads and streams.
|
|
6
|
+
- Configurable log-entry thresholds provide a store-as-document advisory before oversized log text is rejected.
|
|
7
|
+
- Conformance vectors cover document lifecycle, authorization, isolation, citations, and protocol v10 SSE envelopes.
|
package/package.json
CHANGED
|
@@ -22,6 +22,10 @@ import {
|
|
|
22
22
|
decodeResolveRepositoryCubeResponseEnvelope,
|
|
23
23
|
decodeReassignDroneResultEnvelope,
|
|
24
24
|
decodeUpdateDroneRuntimeMetadataResponseEnvelope,
|
|
25
|
+
decodeGetDocumentResultEnvelope,
|
|
26
|
+
decodeListDocumentsResultEnvelope,
|
|
27
|
+
decodePutDocumentResultEnvelope,
|
|
28
|
+
decodeRemoveDocumentResultEnvelope,
|
|
25
29
|
decodeSseFrames,
|
|
26
30
|
PROTOCOL_LIMIT_CEILINGS,
|
|
27
31
|
PROTOCOL_HTTP_CONTRACT,
|
|
@@ -35,6 +39,7 @@ import {
|
|
|
35
39
|
type LogCursor,
|
|
36
40
|
type StreamEvent,
|
|
37
41
|
type DroneRuntimeMetadata,
|
|
42
|
+
type CubeDocument,
|
|
38
43
|
} from '../protocol/index.js';
|
|
39
44
|
import {
|
|
40
45
|
APPEND_LOG_IDEMPOTENCY_CONFORMANCE,
|
|
@@ -42,6 +47,7 @@ import {
|
|
|
42
47
|
CREATE_CUBE_RETRY_CONFORMANCE,
|
|
43
48
|
INVITATION_ARTIFACT_CONFORMANCE,
|
|
44
49
|
ENROLLMENT_RETRY_CONFORMANCE,
|
|
50
|
+
DOCUMENT_CONFORMANCE,
|
|
45
51
|
} from './index.js';
|
|
46
52
|
|
|
47
53
|
export interface ConformanceHttpResponse {
|
|
@@ -265,6 +271,10 @@ export interface ConformanceOperations {
|
|
|
265
271
|
cube: ConformanceCube,
|
|
266
272
|
request: unknown,
|
|
267
273
|
): Promise<ConformanceHttpResponse>;
|
|
274
|
+
putDocument(credential: string, cube: ConformanceCube, request: unknown): Promise<ConformanceHttpResponse>;
|
|
275
|
+
getDocument(credential: string, cube: ConformanceCube, request: unknown): Promise<ConformanceHttpResponse>;
|
|
276
|
+
listDocuments(credential: string, cube: ConformanceCube, request: unknown): Promise<ConformanceHttpResponse>;
|
|
277
|
+
removeDocument(credential: string, cube: ConformanceCube, request: unknown): Promise<ConformanceHttpResponse>;
|
|
268
278
|
resolveRepositoryCube(credential: string | null, request: unknown): Promise<ConformanceHttpResponse>;
|
|
269
279
|
associateRepositoryCube(credential: string | null, request: unknown): Promise<ConformanceHttpResponse>;
|
|
270
280
|
attach(credential: string, request: unknown): Promise<ConformanceHttpResponse>;
|
|
@@ -364,6 +374,7 @@ export const ADAPTER_CONFORMANCE_FIXTURES = [
|
|
|
364
374
|
{ id: 'enrollment.invitation-artifact', area: 'enrollment' },
|
|
365
375
|
{ id: 'enrollment.retry-authority', area: 'enrollment' },
|
|
366
376
|
{ id: 'repository.explicit-association', area: 'repository' },
|
|
377
|
+
{ id: 'documents.lifecycle', area: 'documents' },
|
|
367
378
|
{ id: 'security.adapter-boundary-injection', area: 'security' },
|
|
368
379
|
{ id: 'security.oversize-request', area: 'security' },
|
|
369
380
|
{ id: 'security.cross-cube-isolation', area: 'security' },
|
|
@@ -619,7 +630,7 @@ function expectSecretFreeError(
|
|
|
619
630
|
expectError(response, status, code, operation);
|
|
620
631
|
const diagnostic = JSON.stringify(response.body);
|
|
621
632
|
for (const secret of new Set(secrets)) {
|
|
622
|
-
invariant(!diagnostic.includes(secret), `${operation} exposed
|
|
633
|
+
invariant(!diagnostic.includes(secret), `${operation} exposed sensitive material.`);
|
|
623
634
|
}
|
|
624
635
|
}
|
|
625
636
|
|
|
@@ -1327,6 +1338,334 @@ export async function runAdapterConformance(
|
|
|
1327
1338
|
};
|
|
1328
1339
|
});
|
|
1329
1340
|
|
|
1341
|
+
await record('documents.lifecycle', async () => {
|
|
1342
|
+
const outcomes = new Map<string, string>();
|
|
1343
|
+
const documentPrincipal = await environment.admin.createPrincipal('document-lifecycle');
|
|
1344
|
+
const documentCube = await environment.admin.createCube('document-lifecycle');
|
|
1345
|
+
await environment.admin.grantCube(documentPrincipal, documentCube, 'write');
|
|
1346
|
+
const documentCredential = await environment.admin.issueDroneSession(documentPrincipal);
|
|
1347
|
+
const reader = await environment.admin.createPrincipal('document-reader');
|
|
1348
|
+
const peer = await environment.admin.createPrincipal('document-peer');
|
|
1349
|
+
const manager = await environment.admin.createPrincipal('document-manager');
|
|
1350
|
+
await environment.admin.grantCube(reader, documentCube, 'read');
|
|
1351
|
+
await environment.admin.grantCube(peer, documentCube, 'write');
|
|
1352
|
+
await environment.admin.grantCube(manager, documentCube, 'manage');
|
|
1353
|
+
const readerCredential = await environment.admin.issueDroneSession(reader);
|
|
1354
|
+
const peerCredential = await environment.admin.issueDroneSession(peer);
|
|
1355
|
+
const managerCredential = await environment.admin.issueDroneSession(manager);
|
|
1356
|
+
const foreignPrincipal = await environment.admin.createPrincipal('document-foreign');
|
|
1357
|
+
const foreignCube = await environment.admin.createCube('document-foreign');
|
|
1358
|
+
await environment.admin.grantCube(foreignPrincipal, foreignCube, 'write');
|
|
1359
|
+
const foreignCredential = await environment.admin.issueDroneSession(foreignPrincipal);
|
|
1360
|
+
const foreignPut = await environment.operations.putDocument(
|
|
1361
|
+
foreignCredential,
|
|
1362
|
+
foreignCube,
|
|
1363
|
+
createProtocolEnvelope('document-foreign-put', {
|
|
1364
|
+
title: 'Foreign evidence', content_type: 'text/plain', content: 'Foreign immutable content.',
|
|
1365
|
+
}),
|
|
1366
|
+
);
|
|
1367
|
+
expectStatus(foreignPut, 201, 'Foreign document put');
|
|
1368
|
+
const foreignDocument = decodePutDocumentResultEnvelope(foreignPut.body).payload.document;
|
|
1369
|
+
expectError(await environment.operations.putDocument(
|
|
1370
|
+
readerCredential,
|
|
1371
|
+
documentCube,
|
|
1372
|
+
createProtocolEnvelope('document-read-only-put', { title: 'Denied', content_type: 'text/plain', content: 'denied' }),
|
|
1373
|
+
), 403, ErrorCode.ACCESS_DENIED, 'Read-only document put');
|
|
1374
|
+
expectError(await environment.operations.putDocument(
|
|
1375
|
+
documentCredential,
|
|
1376
|
+
documentCube,
|
|
1377
|
+
createProtocolEnvelope('document-bad-type', { title: 'Binary', content_type: 'application/octet-stream', content: 'x' }),
|
|
1378
|
+
), 400, ErrorCode.DOCUMENT_CONTENT_TYPE_UNSUPPORTED, 'Unsupported document type');
|
|
1379
|
+
outcomes.set('unsupported-content-type', ErrorCode.DOCUMENT_CONTENT_TYPE_UNSUPPORTED);
|
|
1380
|
+
expectError(await environment.operations.putDocument(
|
|
1381
|
+
documentCredential,
|
|
1382
|
+
documentCube,
|
|
1383
|
+
createProtocolEnvelope('document-bad-title', { title: 'x'.repeat(121), content_type: 'text/plain', content: 'x' }),
|
|
1384
|
+
), 400, ErrorCode.INVALID_INPUT, 'Oversized document title');
|
|
1385
|
+
outcomes.set('oversize-title', ErrorCode.INVALID_INPUT);
|
|
1386
|
+
expectError(await environment.operations.putDocument(
|
|
1387
|
+
documentCredential,
|
|
1388
|
+
documentCube,
|
|
1389
|
+
createProtocolEnvelope('document-oversize', { title: 'Too large', content_type: 'text/plain', content: 'x'.repeat(65_537) }),
|
|
1390
|
+
), 413, ErrorCode.DOCUMENT_BUDGET_EXCEEDED, 'Oversized document');
|
|
1391
|
+
const budgetCube = await environment.admin.createCube('document-budget');
|
|
1392
|
+
await environment.admin.grantCube(documentPrincipal, budgetCube, 'write');
|
|
1393
|
+
let budgetPredecessor: CubeDocument | undefined;
|
|
1394
|
+
for (let index = 0; index < 8; index++) {
|
|
1395
|
+
const response = await environment.operations.putDocument(
|
|
1396
|
+
documentCredential,
|
|
1397
|
+
budgetCube,
|
|
1398
|
+
createProtocolEnvelope(`document-budget-${index}`, {
|
|
1399
|
+
title: `Budget ${index}`, content_type: 'text/plain', content: 'x'.repeat(65_536),
|
|
1400
|
+
}),
|
|
1401
|
+
);
|
|
1402
|
+
expectStatus(response, 201, `Document budget fill ${index}`);
|
|
1403
|
+
if (index === 0) budgetPredecessor = decodePutDocumentResultEnvelope(response.body).payload.document;
|
|
1404
|
+
}
|
|
1405
|
+
invariant(budgetPredecessor !== undefined, 'Budget predecessor was not created.');
|
|
1406
|
+
expectError(await environment.operations.putDocument(
|
|
1407
|
+
documentCredential,
|
|
1408
|
+
budgetCube,
|
|
1409
|
+
createProtocolEnvelope('document-budget-overflow', {
|
|
1410
|
+
title: 'Overflow', content_type: 'text/plain', content: 'x', supersedes: budgetPredecessor.id,
|
|
1411
|
+
}),
|
|
1412
|
+
), 413, ErrorCode.DOCUMENT_BUDGET_EXCEEDED, 'Active document budget');
|
|
1413
|
+
const budgetList = decodeListDocumentsResultEnvelope((await environment.operations.listDocuments(
|
|
1414
|
+
documentCredential, budgetCube, createProtocolEnvelope('document-budget-list', {}),
|
|
1415
|
+
)).body).payload.documents;
|
|
1416
|
+
invariant(budgetList.length === 8, 'Budget refusal partially mutated the document set.');
|
|
1417
|
+
const budgetRetained = decodeGetDocumentResultEnvelope((await environment.operations.getDocument(
|
|
1418
|
+
documentCredential,
|
|
1419
|
+
budgetCube,
|
|
1420
|
+
createProtocolEnvelope('document-budget-get-predecessor', { id: budgetPredecessor.id }),
|
|
1421
|
+
)).body).payload.document;
|
|
1422
|
+
invariant(
|
|
1423
|
+
budgetRetained.state === 'active' && budgetRetained.superseded_by === null &&
|
|
1424
|
+
budgetRetained.content === budgetPredecessor.content,
|
|
1425
|
+
'Budget refusal partially mutated its predecessor.',
|
|
1426
|
+
);
|
|
1427
|
+
expectError(await environment.operations.putDocument(
|
|
1428
|
+
documentCredential,
|
|
1429
|
+
documentCube,
|
|
1430
|
+
createProtocolEnvelope('document-unknown-supersedes', { title: 'Unknown', content_type: 'text/plain', content: 'x', supersedes: 'unknown_full_id' }),
|
|
1431
|
+
), 409, ErrorCode.DOCUMENT_SUPERSESSION_INVALID, 'Unknown document supersession');
|
|
1432
|
+
outcomes.set('foreign-supersedes', ErrorCode.DOCUMENT_SUPERSESSION_INVALID);
|
|
1433
|
+
const put = await environment.operations.putDocument(
|
|
1434
|
+
documentCredential,
|
|
1435
|
+
documentCube,
|
|
1436
|
+
createProtocolEnvelope('document-put', {
|
|
1437
|
+
title: 'Review evidence',
|
|
1438
|
+
content_type: 'text/markdown',
|
|
1439
|
+
content: '# Evidence\n\nPortable UTF-8: €\n',
|
|
1440
|
+
}),
|
|
1441
|
+
);
|
|
1442
|
+
expectStatus(put, 201, 'Document put');
|
|
1443
|
+
const created = decodePutDocumentResultEnvelope(put.body).payload.document;
|
|
1444
|
+
outcomes.set('markdown', 'created');
|
|
1445
|
+
invariant(created.size_bytes === utf8ByteLength(created.content), 'Document size is not its UTF-8 byte size.');
|
|
1446
|
+
expectSecretFreeError(await environment.operations.getDocument(
|
|
1447
|
+
documentCredential,
|
|
1448
|
+
documentCube,
|
|
1449
|
+
createProtocolEnvelope('document-foreign-get', { id: foreignDocument.id }),
|
|
1450
|
+
), 404, ErrorCode.DOCUMENT_NOT_FOUND, 'Foreign document get', [foreignDocument.title, foreignDocument.content]);
|
|
1451
|
+
expectSecretFreeError(await environment.operations.listDocuments(
|
|
1452
|
+
documentCredential,
|
|
1453
|
+
foreignCube,
|
|
1454
|
+
createProtocolEnvelope('document-foreign-list', {}),
|
|
1455
|
+
), 404, ErrorCode.NOT_FOUND, 'Foreign document list', [foreignDocument.title, foreignDocument.content]);
|
|
1456
|
+
expectSecretFreeError(await environment.operations.removeDocument(
|
|
1457
|
+
documentCredential,
|
|
1458
|
+
documentCube,
|
|
1459
|
+
createProtocolEnvelope('document-foreign-remove', { id: foreignDocument.id }),
|
|
1460
|
+
), 404, ErrorCode.DOCUMENT_NOT_FOUND, 'Foreign document remove', [foreignDocument.title, foreignDocument.content]);
|
|
1461
|
+
const entriesBeforeForeignCitation = decodeReadLogResultEnvelope((await environment.operations.read(
|
|
1462
|
+
documentCredential,
|
|
1463
|
+
documentCube,
|
|
1464
|
+
createProtocolEnvelope('document-read-before-foreign-cite', { cursor: null, limit: 100 }),
|
|
1465
|
+
)).body).payload.entries.length;
|
|
1466
|
+
expectSecretFreeError(await environment.operations.append(
|
|
1467
|
+
documentCredential,
|
|
1468
|
+
documentCube,
|
|
1469
|
+
createProtocolEnvelope('document-foreign-citation', {
|
|
1470
|
+
post_id: '00000000-0000-4000-8000-00000000031f',
|
|
1471
|
+
message: 'Foreign evidence must remain hidden.',
|
|
1472
|
+
documents: [foreignDocument.id],
|
|
1473
|
+
}),
|
|
1474
|
+
), 404, ErrorCode.DOCUMENT_NOT_FOUND, 'Foreign document citation', [foreignDocument.title, foreignDocument.content]);
|
|
1475
|
+
const entriesAfterForeignCitation = decodeReadLogResultEnvelope((await environment.operations.read(
|
|
1476
|
+
documentCredential,
|
|
1477
|
+
documentCube,
|
|
1478
|
+
createProtocolEnvelope('document-read-after-foreign-cite', { cursor: null, limit: 100 }),
|
|
1479
|
+
)).body).payload.entries.length;
|
|
1480
|
+
invariant(entriesAfterForeignCitation === entriesBeforeForeignCitation, 'Denied foreign citation mutated the log.');
|
|
1481
|
+
|
|
1482
|
+
const successorResponse = await environment.operations.putDocument(
|
|
1483
|
+
documentCredential,
|
|
1484
|
+
documentCube,
|
|
1485
|
+
createProtocolEnvelope('document-put-successor', {
|
|
1486
|
+
title: 'Review evidence revision 2',
|
|
1487
|
+
content_type: 'text/markdown',
|
|
1488
|
+
content: '# Revised evidence\n',
|
|
1489
|
+
supersedes: created.id,
|
|
1490
|
+
}),
|
|
1491
|
+
);
|
|
1492
|
+
expectStatus(successorResponse, 201, 'Document successor put');
|
|
1493
|
+
const successor = decodePutDocumentResultEnvelope(successorResponse.body).payload.document;
|
|
1494
|
+
invariant(successor.supersedes === created.id, 'Document successor did not bind the full prior id.');
|
|
1495
|
+
expectError(await environment.operations.putDocument(
|
|
1496
|
+
documentCredential,
|
|
1497
|
+
documentCube,
|
|
1498
|
+
createProtocolEnvelope('document-branch', { title: 'Branch', content_type: 'text/plain', content: 'x', supersedes: created.id }),
|
|
1499
|
+
), 409, ErrorCode.DOCUMENT_SUPERSESSION_INVALID, 'Branched document supersession');
|
|
1500
|
+
outcomes.set('branched-supersedes', ErrorCode.DOCUMENT_SUPERSESSION_INVALID);
|
|
1501
|
+
expectError(await environment.operations.putDocument(
|
|
1502
|
+
credentialA,
|
|
1503
|
+
cubeA,
|
|
1504
|
+
createProtocolEnvelope('document-cross-cube', { title: 'Cross cube', content_type: 'text/plain', content: 'x', supersedes: created.id }),
|
|
1505
|
+
), 409, ErrorCode.DOCUMENT_SUPERSESSION_INVALID, 'Cross-cube document supersession');
|
|
1506
|
+
|
|
1507
|
+
const citationResponse = await environment.operations.append(
|
|
1508
|
+
documentCredential,
|
|
1509
|
+
documentCube,
|
|
1510
|
+
createProtocolEnvelope('document-citation', {
|
|
1511
|
+
post_id: '00000000-0000-4000-8000-000000000320',
|
|
1512
|
+
message: 'See the document evidence.',
|
|
1513
|
+
documents: [created.id, successor.id],
|
|
1514
|
+
}),
|
|
1515
|
+
);
|
|
1516
|
+
expectStatus(citationResponse, 201, 'Document citation append');
|
|
1517
|
+
const cited = decodeAppendLogResultEnvelope(citationResponse.body).payload.entry.documents;
|
|
1518
|
+
invariant(cited?.length === 2 && cited[0].state === 'superseded', 'Log citations did not render document metadata and state.');
|
|
1519
|
+
expectError(await environment.operations.append(
|
|
1520
|
+
documentCredential,
|
|
1521
|
+
documentCube,
|
|
1522
|
+
createProtocolEnvelope('document-citation-conflict', {
|
|
1523
|
+
post_id: '00000000-0000-4000-8000-000000000320',
|
|
1524
|
+
message: 'See the document evidence.',
|
|
1525
|
+
documents: [successor.id],
|
|
1526
|
+
}),
|
|
1527
|
+
), 409, ErrorCode.POST_ID_CONFLICT, 'Document citation post-id conflict');
|
|
1528
|
+
|
|
1529
|
+
const listed = await environment.operations.listDocuments(
|
|
1530
|
+
readerCredential,
|
|
1531
|
+
documentCube,
|
|
1532
|
+
createProtocolEnvelope('document-list', {}),
|
|
1533
|
+
);
|
|
1534
|
+
expectStatus(listed, 200, 'Document list');
|
|
1535
|
+
const list = decodeListDocumentsResultEnvelope(listed.body).payload.documents;
|
|
1536
|
+
invariant(
|
|
1537
|
+
list.length === 2 && list.some(({ id, state, superseded_by }) =>
|
|
1538
|
+
id === created.id && state === 'superseded' && superseded_by === successor.id),
|
|
1539
|
+
'Linear document supersession was not listed by full ids.',
|
|
1540
|
+
);
|
|
1541
|
+
invariant(!('content' in list[0]), 'Document list exposed immutable content.');
|
|
1542
|
+
invariant(!list.some(({ id }) => id === foreignDocument.id), 'Document list leaked a foreign document.');
|
|
1543
|
+
expectStatus(await environment.operations.getDocument(
|
|
1544
|
+
readerCredential,
|
|
1545
|
+
documentCube,
|
|
1546
|
+
createProtocolEnvelope('document-reader-get', { id: created.id }),
|
|
1547
|
+
), 200, 'Read-only document get');
|
|
1548
|
+
expectError(await environment.operations.removeDocument(
|
|
1549
|
+
readerCredential,
|
|
1550
|
+
documentCube,
|
|
1551
|
+
createProtocolEnvelope('document-reader-remove', { id: created.id }),
|
|
1552
|
+
), 403, ErrorCode.DOCUMENT_REMOVE_DENIED, 'Read-only document remove');
|
|
1553
|
+
expectError(await environment.operations.removeDocument(
|
|
1554
|
+
peerCredential,
|
|
1555
|
+
documentCube,
|
|
1556
|
+
createProtocolEnvelope('document-peer-remove', { id: created.id }),
|
|
1557
|
+
), 403, ErrorCode.DOCUMENT_REMOVE_DENIED, 'Peer document remove');
|
|
1558
|
+
outcomes.set('peer-remove', ErrorCode.DOCUMENT_REMOVE_DENIED);
|
|
1559
|
+
|
|
1560
|
+
const authorDocumentResponse = await environment.operations.putDocument(
|
|
1561
|
+
documentCredential,
|
|
1562
|
+
documentCube,
|
|
1563
|
+
createProtocolEnvelope('document-author-put', {
|
|
1564
|
+
title: 'Author removable', content_type: 'text/plain', content: 'Author content.',
|
|
1565
|
+
}),
|
|
1566
|
+
);
|
|
1567
|
+
expectStatus(authorDocumentResponse, 201, 'Author document put');
|
|
1568
|
+
outcomes.set('plain', 'created');
|
|
1569
|
+
const authorDocument = decodePutDocumentResultEnvelope(authorDocumentResponse.body).payload.document;
|
|
1570
|
+
expectStatus(await environment.operations.removeDocument(
|
|
1571
|
+
documentCredential,
|
|
1572
|
+
documentCube,
|
|
1573
|
+
createProtocolEnvelope('document-author-remove', { id: authorDocument.id }),
|
|
1574
|
+
), 200, 'Author document remove');
|
|
1575
|
+
|
|
1576
|
+
const got = await environment.operations.getDocument(
|
|
1577
|
+
documentCredential,
|
|
1578
|
+
documentCube,
|
|
1579
|
+
createProtocolEnvelope('document-get', { id: created.id }),
|
|
1580
|
+
);
|
|
1581
|
+
expectStatus(got, 200, 'Document get');
|
|
1582
|
+
invariant(
|
|
1583
|
+
decodeGetDocumentResultEnvelope(got.body).payload.document.content === created.content,
|
|
1584
|
+
'Document get did not preserve immutable content and metadata.',
|
|
1585
|
+
);
|
|
1586
|
+
|
|
1587
|
+
const removed = await environment.operations.removeDocument(
|
|
1588
|
+
managerCredential,
|
|
1589
|
+
documentCube,
|
|
1590
|
+
createProtocolEnvelope('document-remove', { id: created.id }),
|
|
1591
|
+
);
|
|
1592
|
+
expectStatus(removed, 200, 'Document remove');
|
|
1593
|
+
invariant(
|
|
1594
|
+
decodeRemoveDocumentResultEnvelope(removed.body).payload.document.state === 'removed',
|
|
1595
|
+
'Document removal did not produce audit-retained removed state.',
|
|
1596
|
+
);
|
|
1597
|
+
const afterResponse = await environment.operations.listDocuments(
|
|
1598
|
+
readerCredential,
|
|
1599
|
+
documentCube,
|
|
1600
|
+
createProtocolEnvelope('document-list-after', {}),
|
|
1601
|
+
);
|
|
1602
|
+
expectStatus(afterResponse, 200, 'Read-only document list after removal');
|
|
1603
|
+
const after = decodeListDocumentsResultEnvelope(afterResponse.body).payload.documents;
|
|
1604
|
+
invariant(after.length === 1 && after[0].id === successor.id, 'Removed document remained in active listing.');
|
|
1605
|
+
const retainedResponse = await environment.operations.getDocument(
|
|
1606
|
+
readerCredential,
|
|
1607
|
+
documentCube,
|
|
1608
|
+
createProtocolEnvelope('document-get-removed', { id: created.id }),
|
|
1609
|
+
);
|
|
1610
|
+
expectStatus(retainedResponse, 200, 'Read-only audit-retained document get');
|
|
1611
|
+
const retained = decodeGetDocumentResultEnvelope(retainedResponse.body).payload.document;
|
|
1612
|
+
invariant(retained.state === 'removed' && retained.content === created.content, 'Removed content was not forensically resolvable.');
|
|
1613
|
+
const foreignRetained = decodeGetDocumentResultEnvelope((await environment.operations.getDocument(
|
|
1614
|
+
foreignCredential,
|
|
1615
|
+
foreignCube,
|
|
1616
|
+
createProtocolEnvelope('document-foreign-retained', { id: foreignDocument.id }),
|
|
1617
|
+
)).body).payload.document;
|
|
1618
|
+
invariant(
|
|
1619
|
+
foreignRetained.state === 'active' && foreignRetained.content === foreignDocument.content,
|
|
1620
|
+
'Denied foreign operations leaked mutation into the foreign document.',
|
|
1621
|
+
);
|
|
1622
|
+
outcomes.set('removed', 'audit-resolvable');
|
|
1623
|
+
const entriesBeforeUnknownCitation = (await environment.operations.read(
|
|
1624
|
+
documentCredential, documentCube, createProtocolEnvelope('document-read-before-unknown-cite', { cursor: null, limit: 100 }),
|
|
1625
|
+
));
|
|
1626
|
+
const beforeCount = decodeReadLogResultEnvelope(entriesBeforeUnknownCitation.body).payload.entries.length;
|
|
1627
|
+
expectError(await environment.operations.append(
|
|
1628
|
+
documentCredential,
|
|
1629
|
+
documentCube,
|
|
1630
|
+
createProtocolEnvelope('document-unknown-citation', {
|
|
1631
|
+
post_id: '00000000-0000-4000-8000-000000000321', message: 'Unknown.', documents: ['unknown_full_id'],
|
|
1632
|
+
}),
|
|
1633
|
+
), 404, ErrorCode.DOCUMENT_NOT_FOUND, 'Unknown document citation');
|
|
1634
|
+
const longAccepted = await environment.operations.append(
|
|
1635
|
+
documentCredential,
|
|
1636
|
+
documentCube,
|
|
1637
|
+
createProtocolEnvelope('document-advisory', {
|
|
1638
|
+
post_id: '00000000-0000-4000-8000-000000000322', message: 'x'.repeat(1025),
|
|
1639
|
+
}),
|
|
1640
|
+
);
|
|
1641
|
+
expectStatus(longAccepted, 201, 'Advisory log append');
|
|
1642
|
+
invariant(decodeAppendLogResultEnvelope(longAccepted.body).payload.advisory?.threshold_bytes === 1024, 'Configured advisory was not returned.');
|
|
1643
|
+
expectError(await environment.operations.append(
|
|
1644
|
+
documentCredential,
|
|
1645
|
+
documentCube,
|
|
1646
|
+
createProtocolEnvelope('document-hard-cap', {
|
|
1647
|
+
post_id: '00000000-0000-4000-8000-000000000323', message: 'x'.repeat(4097),
|
|
1648
|
+
}),
|
|
1649
|
+
), 413, ErrorCode.CONTENT_TOO_LARGE, 'Hard-cap log append');
|
|
1650
|
+
const citedRead = decodeReadLogResultEnvelope((await environment.operations.read(
|
|
1651
|
+
documentCredential,
|
|
1652
|
+
documentCube,
|
|
1653
|
+
createProtocolEnvelope('document-citation-read', { cursor: null, limit: 10 }),
|
|
1654
|
+
)).body).payload.entries[0].documents;
|
|
1655
|
+
invariant(citedRead?.[0].state === 'removed', 'Log citation did not resolve the audit-retained removed state.');
|
|
1656
|
+
const finalCount = decodeReadLogResultEnvelope((await environment.operations.read(
|
|
1657
|
+
documentCredential, documentCube, createProtocolEnvelope('document-final-read', { cursor: null, limit: 100 }),
|
|
1658
|
+
)).body).payload.entries.length;
|
|
1659
|
+
invariant(finalCount === beforeCount + 1, 'Rejected document or log operations partially mutated state.');
|
|
1660
|
+
for (const vector of DOCUMENT_CONFORMANCE) {
|
|
1661
|
+
invariant(
|
|
1662
|
+
outcomes.get(vector.fixture) === vector.expected,
|
|
1663
|
+
`${vector.name} produced ${outcomes.get(vector.fixture) ?? 'no outcome'}; expected ${vector.expected}.`,
|
|
1664
|
+
);
|
|
1665
|
+
}
|
|
1666
|
+
return { refusals: true, put: 201, cited: true, active_listed: true, removed_hidden: true, audit_resolvable: true };
|
|
1667
|
+
});
|
|
1668
|
+
|
|
1330
1669
|
await record('security.adapter-boundary-injection', async () => {
|
|
1331
1670
|
const injectedMessage = "'); DROP TABLE log_entries; --\r\ndata: forged-sse-frame";
|
|
1332
1671
|
const injectedBody = JSON.stringify(
|
package/src/conformance/index.ts
CHANGED
|
@@ -23,6 +23,17 @@ export interface ConformanceVector<Input, Output> {
|
|
|
23
23
|
expected: Output;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
export const DOCUMENT_CONFORMANCE = [
|
|
27
|
+
{ name: 'accepts markdown UTF-8 content', fixture: 'markdown', expected: 'created' },
|
|
28
|
+
{ name: 'accepts plain UTF-8 content', fixture: 'plain', expected: 'created' },
|
|
29
|
+
{ name: 'rejects unsupported content types', fixture: 'unsupported-content-type', expected: 'DOCUMENT_CONTENT_TYPE_UNSUPPORTED' },
|
|
30
|
+
{ name: 'requires a title of at most 120 characters', fixture: 'oversize-title', expected: 'INVALID_INPUT' },
|
|
31
|
+
{ name: 'rejects an unknown or cross-cube supersedes id', fixture: 'foreign-supersedes', expected: 'DOCUMENT_SUPERSESSION_INVALID' },
|
|
32
|
+
{ name: 'rejects a branch in the linear supersession chain', fixture: 'branched-supersedes', expected: 'DOCUMENT_SUPERSESSION_INVALID' },
|
|
33
|
+
{ name: 'delists removed content while retaining exact-id resolution', fixture: 'removed', expected: 'audit-resolvable' },
|
|
34
|
+
{ name: 'allows only the author or a cube manager to remove', fixture: 'peer-remove', expected: 'DOCUMENT_REMOVE_DENIED' },
|
|
35
|
+
] as const;
|
|
36
|
+
|
|
26
37
|
export interface BroadcastHwmComparisonInput {
|
|
27
38
|
a: BroadcastHwm;
|
|
28
39
|
b: BroadcastHwm;
|
package/src/protocol/contract.ts
CHANGED
|
@@ -13,11 +13,15 @@ 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.
|
|
16
|
+
export const SHARED_PACKAGE_VERSION = '0.13.0' as const;
|
|
17
17
|
/** Maximum UTF-8 payload for each newly recorded decision text field. */
|
|
18
18
|
export const DECISION_TEXT_MAX_BYTES = 512 as const;
|
|
19
19
|
/** Maximum UTF-8 size of role detailed-description text and any returned section slice. */
|
|
20
20
|
export const ROLE_TEXT_MAX_BYTES = 51_200 as const;
|
|
21
|
+
export const DEFAULT_LOG_ENTRY_ADVISORY_BYTES = 1024 as const;
|
|
22
|
+
export const DEFAULT_MAX_LOG_ENTRY_BYTES = 4096 as const;
|
|
23
|
+
export const LOG_ENTRY_ADVISORY_ENV = 'BORG_SERVER_LOG_ENTRY_ADVISORY_BYTES' as const;
|
|
24
|
+
export const MAX_LOG_ENTRY_ENV = 'BORG_SERVER_MAX_LOG_ENTRY_BYTES' as const;
|
|
21
25
|
|
|
22
26
|
export const HEALTH_PATH = '/healthz' as const;
|
|
23
27
|
export const PROTOCOL_INFO_PATH = '/api/protocol' as const;
|
|
@@ -30,12 +34,18 @@ export const REPOSITORY_CUBE_RESOLVE_PATH = '/api/repository-cubes/resolve' as c
|
|
|
30
34
|
export const REPOSITORY_CUBE_ASSOCIATION_PATH = '/api/repository-cubes/association' as const;
|
|
31
35
|
export const ATTACH_PATH = '/api/client/attach' as const;
|
|
32
36
|
export const SELF_RUNTIME_METADATA_PATH = '/api/cubes/:cubeId/drones/self/metadata' as const;
|
|
37
|
+
export const DOCUMENTS_PATH = '/api/cubes/:cubeId/documents' as const;
|
|
38
|
+
export const DOCUMENT_PATH = '/api/cubes/:cubeId/documents/:documentId' as const;
|
|
33
39
|
|
|
34
40
|
export const PROTOCOL_HTTP_CONTRACT = {
|
|
35
41
|
health: { method: 'GET', path: HEALTH_PATH, authenticated: false, success_status: 204, bodyless: true },
|
|
36
42
|
protocol: { method: 'GET', path: PROTOCOL_INFO_PATH, authenticated: false, success_status: 200 },
|
|
37
43
|
enrollment: { method: 'POST', path: ENROLLMENT_EXCHANGE_PATH, authenticated: 'invitation', success_status: 201 },
|
|
38
44
|
cubes: { method: 'POST', path: CUBES_PATH, authenticated: true, success_status: 201 },
|
|
45
|
+
document_put: { method: 'PUT', path: DOCUMENTS_PATH, authenticated: true, success_status: 201, mutation: true },
|
|
46
|
+
document_list: { method: 'GET', path: DOCUMENTS_PATH, authenticated: true, success_status: 200, mutation: false },
|
|
47
|
+
document_get: { method: 'GET', path: DOCUMENT_PATH, authenticated: true, success_status: 200, mutation: false },
|
|
48
|
+
document_remove: { method: 'DELETE', path: DOCUMENT_PATH, authenticated: true, success_status: 200, mutation: true },
|
|
39
49
|
cube_delete: {
|
|
40
50
|
method: 'DELETE',
|
|
41
51
|
path: CUBE_PATH,
|
|
@@ -104,7 +114,7 @@ export const PROTOCOL_HTTP_CONTRACT = {
|
|
|
104
114
|
|
|
105
115
|
export const PROTOCOL_LIMIT_CEILINGS = {
|
|
106
116
|
max_request_bytes: 10 * 1024 * 1024,
|
|
107
|
-
max_log_message_bytes:
|
|
117
|
+
max_log_message_bytes: 65_536,
|
|
108
118
|
max_read_page_size: 500,
|
|
109
119
|
max_replay_page_size: 1000,
|
|
110
120
|
} as const;
|
|
@@ -580,7 +590,7 @@ export function decodeProtocolTagPreflight(value: unknown): ProtocolTagPreflight
|
|
|
580
590
|
exactKeys(input, ['protocol_version'], ['protocol_version']);
|
|
581
591
|
if (input.protocol_version !== PROTOCOL_VERSION) {
|
|
582
592
|
throw new ProtocolContractError(
|
|
583
|
-
'This client requires protocol
|
|
593
|
+
'This client requires protocol v10. The peer presents a different version. Update `borgmcp-server` and `borgmcp` to matching releases — server first, then client.',
|
|
584
594
|
ErrorCode.UNSUPPORTED_PROTOCOL_VERSION,
|
|
585
595
|
['protocol_version'],
|
|
586
596
|
);
|
|
@@ -997,12 +1007,12 @@ export function decodeAppendLogRequest(value: unknown): import('./types.js').App
|
|
|
997
1007
|
const input = record(value);
|
|
998
1008
|
exactKeys(
|
|
999
1009
|
input,
|
|
1000
|
-
['post_id', 'message', 'visibility', 'recipientDroneIds', 'class', 'to'],
|
|
1010
|
+
['post_id', 'message', 'visibility', 'recipientDroneIds', 'class', 'to', 'documents'],
|
|
1001
1011
|
['post_id', 'message'],
|
|
1002
1012
|
);
|
|
1003
1013
|
const output: import('./types.js').AppendLogRequest = {
|
|
1004
1014
|
post_id: decodeUuid(input.post_id, ['post_id']),
|
|
1005
|
-
message: boundedString(input.message, 1,
|
|
1015
|
+
message: boundedString(input.message, 1, PROTOCOL_LIMIT_CEILINGS.max_log_message_bytes, ['message']),
|
|
1006
1016
|
};
|
|
1007
1017
|
if (input.visibility !== undefined) {
|
|
1008
1018
|
if (input.visibility !== 'broadcast' && input.visibility !== 'direct') {
|
|
@@ -1024,6 +1034,10 @@ export function decodeAppendLogRequest(value: unknown): import('./types.js').App
|
|
|
1024
1034
|
if (input.to !== undefined) {
|
|
1025
1035
|
output.to = decodeStringArray(input.to, 'to', 100, 120);
|
|
1026
1036
|
}
|
|
1037
|
+
if (input.documents !== undefined) {
|
|
1038
|
+
output.documents = decodeStringArray(input.documents, 'documents', 100, 128)
|
|
1039
|
+
.map((id, index) => decodeOpaqueIdentifier(id, ['documents', index]));
|
|
1040
|
+
}
|
|
1027
1041
|
return output;
|
|
1028
1042
|
}
|
|
1029
1043
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
+
PROTOCOL_LIMIT_CEILINGS,
|
|
2
3
|
ProtocolContractError,
|
|
3
4
|
compareLogCursor,
|
|
4
5
|
decodeCanonicalTimestamp,
|
|
@@ -376,7 +377,7 @@ function decodeUnreachableRecipient(
|
|
|
376
377
|
|
|
377
378
|
export function decodeAppendLogResult(value: unknown): AppendLogResult {
|
|
378
379
|
const input = object(value);
|
|
379
|
-
exact(input, ['entry', 'deduplicated', 'routing', 'unreachableRecipients'], ['entry', 'deduplicated']);
|
|
380
|
+
exact(input, ['entry', 'deduplicated', 'routing', 'unreachableRecipients', 'advisory'], ['entry', 'deduplicated']);
|
|
380
381
|
if (typeof input.deduplicated !== 'boolean') {
|
|
381
382
|
throw new ProtocolContractError('Invalid append-log deduplicated flag.');
|
|
382
383
|
}
|
|
@@ -393,6 +394,19 @@ export function decodeAppendLogResult(value: unknown): AppendLogResult {
|
|
|
393
394
|
}
|
|
394
395
|
output.unreachableRecipients = input.unreachableRecipients.map(decodeUnreachableRecipient);
|
|
395
396
|
}
|
|
397
|
+
if (input.advisory !== undefined) {
|
|
398
|
+
const advisory = object(input.advisory);
|
|
399
|
+
exact(advisory, ['code', 'threshold_bytes'], ['code', 'threshold_bytes']);
|
|
400
|
+
if (advisory.code !== 'STORE_AS_DOCUMENT') {
|
|
401
|
+
throw new ProtocolContractError('Invalid append-log document advisory.');
|
|
402
|
+
}
|
|
403
|
+
const threshold = positiveInteger(
|
|
404
|
+
advisory.threshold_bytes,
|
|
405
|
+
'advisory.threshold_bytes',
|
|
406
|
+
PROTOCOL_LIMIT_CEILINGS.max_log_message_bytes,
|
|
407
|
+
);
|
|
408
|
+
output.advisory = { code: 'STORE_AS_DOCUMENT', threshold_bytes: threshold };
|
|
409
|
+
}
|
|
396
410
|
return output;
|
|
397
411
|
}
|
|
398
412
|
|