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/docs/enrollment.md
CHANGED
|
@@ -5,15 +5,22 @@ shared by clients and servers. It does not define offline operator commands,
|
|
|
5
5
|
database transactions, or keychain APIs, but implementations must preserve the
|
|
6
6
|
security properties below.
|
|
7
7
|
|
|
8
|
-
This contract replaces the server-generated bearer response used by
|
|
9
|
-
`borgmcp-shared@0.
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
8
|
+
This contract replaces the server-generated bearer response used by the
|
|
9
|
+
published `borgmcp-shared@0.3.0` v1 baseline. It is a breaking clean-slate v2
|
|
10
|
+
wire change; this source now identifies the still-unpublished `0.4.0` candidate
|
|
11
|
+
and requires a coordinated client/server release. The version bump grants no tag
|
|
12
|
+
or publication authority — creating the `v0.4.0` tag and the registry
|
|
13
|
+
publication remain separate, independently gated steps. There is no
|
|
14
|
+
compatibility path that returns a bearer from the server.
|
|
15
|
+
|
|
16
|
+
A client verifies the credential-free `GET /api/protocol` tag preflight before
|
|
17
|
+
it creates or sends any enrollment secret; a peer that presents a different
|
|
18
|
+
protocol tag is rejected outright. The exact protocol tag is the sole acceptance
|
|
19
|
+
authority, so there is no capability a server advertises or a client negotiates
|
|
20
|
+
for this contract. The enrollment
|
|
21
|
+
response still carries the enrolled principal's `server_capabilities` (owner
|
|
22
|
+
enrollment grants `['create_cube']`; ordinary enrollment grants none) — that is
|
|
23
|
+
an authorization fact about the principal, not a negotiated protocol capability.
|
|
17
24
|
|
|
18
25
|
## Client Preconditions
|
|
19
26
|
|
package/docs/releasing.md
CHANGED
|
@@ -137,9 +137,10 @@ Recover out of band without rerunning the workflow:
|
|
|
137
137
|
2. With `GITHUB_SHA=508f2dc88658d8e00ff036b7ce6913fcfbef239b` and
|
|
138
138
|
`NPM_EXPECTED_OWNER=byteventures`, run
|
|
139
139
|
`node scripts/verify-registry-release.mjs postpublish <audited-tarball>`. The
|
|
140
|
-
verifier retries only transient HTTP 404 propagation responses
|
|
141
|
-
|
|
142
|
-
|
|
140
|
+
verifier retries only transient HTTP 404 propagation responses. Its production
|
|
141
|
+
envelope performs at most 18 reads over approximately three and a half minutes
|
|
142
|
+
(1, 2, 4, and 8 second waits, then a 15 second cap). Non-404 failures and
|
|
143
|
+
integrity, owner, or provenance mismatches remain immediate terminal failures.
|
|
143
144
|
3. Install exact `borgmcp-shared@0.2.2` from the registry into a clean temporary
|
|
144
145
|
prefix with scripts disabled and run `npm audit signatures` against that
|
|
145
146
|
prefix.
|
|
@@ -216,25 +217,30 @@ Immediately after a successful first publish:
|
|
|
216
217
|
|
|
217
218
|
## Later Releases
|
|
218
219
|
|
|
219
|
-
### 0.
|
|
220
|
+
### 0.4.0 Clean-Slate v2 Contract
|
|
220
221
|
|
|
221
|
-
The ratified `borgmcp-shared-enrollment-version` decision
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
222
|
+
The ratified `borgmcp-shared-enrollment-version` decision defined this breaking
|
|
223
|
+
retry-safe enrollment and idempotent multi-cube contract. `borgmcp-shared@0.3.0`
|
|
224
|
+
is now the published, immutable v1 baseline on the registry, so the clean-slate
|
|
225
|
+
v2 contract — credential-free tag preflight included — is versioned as the
|
|
226
|
+
still-unpublished `0.4.0` candidate (a breaking change on a `0.x` package bumps
|
|
227
|
+
the minor). This source now identifies `0.4.0`. That reviewed version bump
|
|
228
|
+
does not authorize creating `v0.4.0` or publishing the package, and the source
|
|
229
|
+
branch must not republish `0.3.0`.
|
|
225
230
|
|
|
226
|
-
Before a separately authorized `v0.
|
|
231
|
+
Before a separately authorized `v0.4.0` tag is created:
|
|
227
232
|
|
|
228
|
-
1. The exact `0.
|
|
233
|
+
1. The exact `0.4.0` source commit must be merged to protected `main` after Code
|
|
229
234
|
Review, Security, and Release Quality approve the package identity, public
|
|
230
235
|
API, conformance behavior, generated output, and release documentation.
|
|
231
|
-
2. The package and root lockfile, `SHARED_PACKAGE_VERSION`,
|
|
232
|
-
packed-artifact verifier, and version assertions
|
|
236
|
+
2. The package and root lockfile, `SHARED_PACKAGE_VERSION`, the exact protocol
|
|
237
|
+
version tag, packed-artifact verifier, and version assertions all identify
|
|
238
|
+
`0.4.0` (done in this source bump).
|
|
233
239
|
3. The protected workflow must build one exact tarball and pass its full source,
|
|
234
240
|
test, audit, public-export, source-map, install/import, dry-run, lock-derived
|
|
235
241
|
CycloneDX SBOM, integrity, and provenance gates. Security must approve that
|
|
236
242
|
exact workflow artifact.
|
|
237
|
-
4. The `0.
|
|
243
|
+
4. The `0.4.0` workflow must generate a CycloneDX SBOM, canonicalize npm's
|
|
238
244
|
checkout-derived root display name to the already-verified manifest name,
|
|
239
245
|
and validate every component's exact canonical registry tarball URL, single
|
|
240
246
|
matching CycloneDX distribution reference, purl, and lock SHA-512 plus the
|
|
@@ -246,9 +252,9 @@ Before a separately authorized `v0.3.0` tag is created:
|
|
|
246
252
|
|
|
247
253
|
After registry publication and independent integrity/provenance verification,
|
|
248
254
|
the first borgmcp client and borgmcp-server releases may replace their temporary
|
|
249
|
-
development dependency with the reviewed registry range `^0.
|
|
250
|
-
ship a Git dependency or fall back to the incompatible `0.
|
|
251
|
-
response.
|
|
255
|
+
development dependency with the reviewed registry range `^0.4.0`. They must not
|
|
256
|
+
ship a Git dependency or fall back to the incompatible published `0.3.0` v1
|
|
257
|
+
enrollment response.
|
|
252
258
|
|
|
253
259
|
Later releases follow the same source gates, annotated protected tag, exact
|
|
254
260
|
tarball audit, Queen authorization, and environment approval. The registry
|
package/package.json
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
2
|
ErrorCode,
|
|
3
|
-
ProtocolContractError,
|
|
4
3
|
compareLogCursor,
|
|
5
4
|
createProtocolEnvelope,
|
|
6
5
|
decodeCreateCubeResponseEnvelope,
|
|
@@ -9,15 +8,14 @@ import {
|
|
|
9
8
|
decodeDecisionsResultEnvelope,
|
|
10
9
|
decodeEnrollmentExchangeResponseEnvelope,
|
|
11
10
|
decodeProtocolErrorEnvelope,
|
|
12
|
-
|
|
11
|
+
decodeProtocolTagPreflight,
|
|
13
12
|
decodeReadLogResultEnvelope,
|
|
14
13
|
decodeSseFrames,
|
|
15
|
-
|
|
14
|
+
PROTOCOL_LIMIT_CEILINGS,
|
|
15
|
+
PROTOCOL_VERSION,
|
|
16
16
|
utf8ByteLength,
|
|
17
|
-
type Capability,
|
|
18
17
|
type CreateCubeResponse,
|
|
19
18
|
type LogCursor,
|
|
20
|
-
type ProtocolInfo,
|
|
21
19
|
type StreamEvent,
|
|
22
20
|
} from '../protocol/index.js';
|
|
23
21
|
import { ENROLLMENT_RETRY_CONFORMANCE } from './index.js';
|
|
@@ -57,6 +55,13 @@ export interface ConformanceCreatedCubeState {
|
|
|
57
55
|
export interface ConformanceEnrollmentPrincipalState {
|
|
58
56
|
response_client_matches: boolean;
|
|
59
57
|
active_credential_bindings: number;
|
|
58
|
+
/**
|
|
59
|
+
* The principal's currently-bound credential still equals the one it enrolled
|
|
60
|
+
* with. Since the credential-free tag preflight cannot probe credentials, this
|
|
61
|
+
* out-of-band authority check is what proves a rejected mismatch retry did not
|
|
62
|
+
* overwrite the good credential.
|
|
63
|
+
*/
|
|
64
|
+
bound_credential_matches_enrollment: boolean;
|
|
60
65
|
}
|
|
61
66
|
|
|
62
67
|
export interface ConformanceReplayBarrier {
|
|
@@ -147,7 +152,8 @@ export interface ConformanceEnvironment {
|
|
|
147
152
|
|
|
148
153
|
export const ADAPTER_CONFORMANCE_FIXTURES = [
|
|
149
154
|
{ id: 'http.unauthenticated-liveness', area: 'http' },
|
|
150
|
-
{ id: 'protocol.
|
|
155
|
+
{ id: 'protocol.credential-free-preflight', area: 'protocol' },
|
|
156
|
+
{ id: 'enrollment.retry-authority', area: 'enrollment' },
|
|
151
157
|
{ id: 'security.adapter-boundary-injection', area: 'security' },
|
|
152
158
|
{ id: 'security.oversize-request', area: 'security' },
|
|
153
159
|
{ id: 'security.cross-cube-isolation', area: 'security' },
|
|
@@ -157,7 +163,6 @@ export const ADAPTER_CONFORMANCE_FIXTURES = [
|
|
|
157
163
|
{ id: 'acks.idempotent', area: 'acks' },
|
|
158
164
|
{ id: 'claims.durable-noncursor', area: 'claims' },
|
|
159
165
|
{ id: 'decisions.topic-supersession', area: 'decisions' },
|
|
160
|
-
{ id: 'capabilities.unsupported-fails-closed', area: 'capabilities' },
|
|
161
166
|
{ id: 'security.active-stream-revocation', area: 'security' },
|
|
162
167
|
] as const;
|
|
163
168
|
|
|
@@ -410,8 +415,6 @@ export async function runAdapterConformance(
|
|
|
410
415
|
|
|
411
416
|
let credentialA = '';
|
|
412
417
|
let credentialB = '';
|
|
413
|
-
let protocolBody: unknown;
|
|
414
|
-
let protocolInfo: ProtocolInfo | null = null;
|
|
415
418
|
await record('http.unauthenticated-liveness', async () => {
|
|
416
419
|
const response = await environment.operations.health();
|
|
417
420
|
expectStatus(response, 204, 'Unauthenticated liveness');
|
|
@@ -419,9 +422,23 @@ export async function runAdapterConformance(
|
|
|
419
422
|
return { status: 204, bodyless: true };
|
|
420
423
|
});
|
|
421
424
|
|
|
422
|
-
await record('protocol.
|
|
423
|
-
|
|
425
|
+
await record('protocol.credential-free-preflight', async () => {
|
|
426
|
+
// The tag preflight is credential-free (no bearer) and mutation-free: a
|
|
427
|
+
// client verifies pinned TLS and the exact tag before it creates or sends any
|
|
428
|
+
// credential. The server must answer 200 with ONLY the exact tag.
|
|
429
|
+
const before = await environment.admin.observeAuthorityState();
|
|
430
|
+
const response = await environment.operations.protocol(null);
|
|
431
|
+
expectStatus(response, 200, 'Credential-free protocol-tag preflight');
|
|
432
|
+
const preflight = decodeProtocolTagPreflight(response.body);
|
|
433
|
+
invariant(
|
|
434
|
+
Object.keys(preflight).length === 1 && preflight.protocol_version === PROTOCOL_VERSION,
|
|
435
|
+
'Protocol-tag preflight exposed more than the exact tag.',
|
|
436
|
+
);
|
|
437
|
+
assertStateDelta(before, await environment.admin.observeAuthorityState(), {}, 'Protocol-tag preflight');
|
|
438
|
+
return { authenticated: false, mutation_free: true, protocol_version: preflight.protocol_version };
|
|
439
|
+
});
|
|
424
440
|
|
|
441
|
+
await record('enrollment.retry-authority', async () => {
|
|
425
442
|
const retryVectorErrors: string[] = [];
|
|
426
443
|
for (const [index, vector] of ENROLLMENT_RETRY_CONFORMANCE.entries()) {
|
|
427
444
|
for (const purpose of ['client', 'owner'] as const) {
|
|
@@ -474,23 +491,11 @@ export async function runAdapterConformance(
|
|
|
474
491
|
);
|
|
475
492
|
}
|
|
476
493
|
assertStateDelta(beforeRetry, await environment.admin.observeAuthorityState(), {}, `${purpose} ${vector.name} retry`);
|
|
477
|
-
expectStatus(
|
|
478
|
-
await environment.operations.protocol(vector.initial.client_credential),
|
|
479
|
-
200,
|
|
480
|
-
`${purpose} ${vector.name} original credential continuity`,
|
|
481
|
-
);
|
|
482
|
-
if (vector.retry.client_credential !== vector.initial.client_credential) {
|
|
483
|
-
expectError(
|
|
484
|
-
await environment.operations.protocol(vector.retry.client_credential),
|
|
485
|
-
401,
|
|
486
|
-
ErrorCode.AUTH_INVALID,
|
|
487
|
-
`${purpose} ${vector.name} mismatched credential rejection`,
|
|
488
|
-
);
|
|
489
|
-
}
|
|
490
494
|
invariant(
|
|
491
495
|
same(await environment.admin.inspectEnrollmentPrincipal(principal, initial.client_id), {
|
|
492
496
|
response_client_matches: true,
|
|
493
497
|
active_credential_bindings: 1,
|
|
498
|
+
bound_credential_matches_enrollment: true,
|
|
494
499
|
}),
|
|
495
500
|
`${purpose} ${vector.name} changed enrollment binding ownership.`,
|
|
496
501
|
);
|
|
@@ -710,37 +715,23 @@ export async function runAdapterConformance(
|
|
|
710
715
|
ErrorCode.AUTH_INVALID,
|
|
711
716
|
'Enrollment retry mismatch',
|
|
712
717
|
);
|
|
713
|
-
const protocolResponse = await environment.operations.protocol(credentialA);
|
|
714
|
-
expectStatus(protocolResponse, 200, 'Authenticated protocol request');
|
|
715
|
-
protocolBody = protocolResponse.body;
|
|
716
|
-
protocolInfo = negotiateProtocol(decodeProtocolInfoEnvelope(protocolBody).payload, [
|
|
717
|
-
'log.cursor',
|
|
718
|
-
'stream.sse',
|
|
719
|
-
'stream.replay',
|
|
720
|
-
'acks',
|
|
721
|
-
'claims',
|
|
722
|
-
'decisions',
|
|
723
|
-
]);
|
|
724
718
|
return {
|
|
725
|
-
unauthenticated: ErrorCode.AUTH_MISSING,
|
|
726
719
|
enrollment_status: 201,
|
|
727
720
|
exact_retry_status: 201,
|
|
728
721
|
mismatched_retry: ErrorCode.AUTH_INVALID,
|
|
729
722
|
response_secret_free: true,
|
|
730
|
-
protocol_version: protocolInfo.protocol_version,
|
|
731
723
|
};
|
|
732
724
|
});
|
|
733
725
|
|
|
734
726
|
await record('security.adapter-boundary-injection', async () => {
|
|
735
|
-
invariant(protocolInfo, 'Protocol fixture did not produce request limits.');
|
|
736
727
|
const injectedMessage = "'); DROP TABLE log_entries; --\r\ndata: forged-sse-frame";
|
|
737
728
|
const injectedBody = JSON.stringify(
|
|
738
729
|
createProtocolEnvelope('inject-b1', { message: injectedMessage }),
|
|
739
730
|
);
|
|
740
731
|
invariant(
|
|
741
|
-
utf8ByteLength(injectedBody) <=
|
|
742
|
-
utf8ByteLength(injectedMessage) <=
|
|
743
|
-
'Injection fixture exceeded
|
|
732
|
+
utf8ByteLength(injectedBody) <= PROTOCOL_LIMIT_CEILINGS.max_request_bytes &&
|
|
733
|
+
utf8ByteLength(injectedMessage) <= PROTOCOL_LIMIT_CEILINGS.max_log_message_bytes,
|
|
734
|
+
'Injection fixture exceeded the shared request-limit ceiling.',
|
|
744
735
|
);
|
|
745
736
|
const injected = await environment.operations.appendRaw(
|
|
746
737
|
credentialB,
|
|
@@ -800,15 +791,14 @@ export async function runAdapterConformance(
|
|
|
800
791
|
});
|
|
801
792
|
|
|
802
793
|
await record('security.oversize-request', async () => {
|
|
803
|
-
invariant(protocolInfo, 'Protocol fixture did not produce request limits.');
|
|
804
794
|
const baseBody = JSON.stringify(
|
|
805
795
|
createProtocolEnvelope('oversize-a1', { message: 'must-not-persist' }),
|
|
806
796
|
);
|
|
807
797
|
const oversizedBody = baseBody + ' '.repeat(
|
|
808
|
-
Math.max(0,
|
|
798
|
+
Math.max(0, PROTOCOL_LIMIT_CEILINGS.max_request_bytes - utf8ByteLength(baseBody) + 1),
|
|
809
799
|
);
|
|
810
800
|
invariant(
|
|
811
|
-
utf8ByteLength(oversizedBody) >
|
|
801
|
+
utf8ByteLength(oversizedBody) > PROTOCOL_LIMIT_CEILINGS.max_request_bytes,
|
|
812
802
|
'Oversize fixture did not exceed max_request_bytes.',
|
|
813
803
|
);
|
|
814
804
|
const response = await environment.operations.appendRaw(credentialA, cubeA, oversizedBody);
|
|
@@ -995,22 +985,6 @@ export async function runAdapterConformance(
|
|
|
995
985
|
return { active_count: 1, active_decision: 'second', supersedes_first: true };
|
|
996
986
|
});
|
|
997
987
|
|
|
998
|
-
await record('capabilities.unsupported-fails-closed', async () => {
|
|
999
|
-
invariant(protocolBody, 'Protocol fixture did not produce an envelope.');
|
|
1000
|
-
let code: ErrorCode | null = null;
|
|
1001
|
-
try {
|
|
1002
|
-
negotiateProtocol(
|
|
1003
|
-
decodeProtocolInfoEnvelope(protocolBody).payload,
|
|
1004
|
-
['future.required' as Capability],
|
|
1005
|
-
);
|
|
1006
|
-
} catch (error) {
|
|
1007
|
-
if (error instanceof ProtocolContractError) code = error.code;
|
|
1008
|
-
else throw error;
|
|
1009
|
-
}
|
|
1010
|
-
invariant(code === ErrorCode.UNSUPPORTED_CAPABILITY, 'Unsupported capability did not fail closed client-side.');
|
|
1011
|
-
return { code };
|
|
1012
|
-
});
|
|
1013
|
-
|
|
1014
988
|
await record('security.active-stream-revocation', async () => {
|
|
1015
989
|
invariant(liveCursor, 'Stream fixture did not produce a live cursor.');
|
|
1016
990
|
const opened = await environment.operations.openStream(credentialA, cubeA, liveCursor);
|