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.
@@ -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.2.2`. It is selected for `0.3.0` as a breaking wire change and
10
- requires a coordinated client/server migration. Version preparation does not
11
- authorize a tag or publication. There is no compatibility path that returns a
12
- bearer from the server.
13
-
14
- Servers implementing this contract advertise the required
15
- `auth.retry-safe-enrollment` capability. Negotiation fails closed when it is
16
- absent.
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 with bounded
141
- backoff; non-404 failures and integrity, owner, or provenance mismatches remain
142
- immediate terminal failures.
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.3.0 Enrollment Contract
220
+ ### 0.4.0 Clean-Slate v2 Contract
220
221
 
221
- The ratified `borgmcp-shared-enrollment-version` decision selects `0.3.0` for
222
- the breaking retry-safe enrollment and idempotent multi-cube contract. Updating
223
- package metadata, lockfiles, assertions, compatibility documentation, and this
224
- runbook does not authorize creating `v0.3.0` or publishing the package.
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.3.0` tag is created:
231
+ Before a separately authorized `v0.4.0` tag is created:
227
232
 
228
- 1. The exact `0.3.0` source commit must be merged to protected `main` after Code
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`, compatibility matrix,
232
- packed-artifact verifier, and version assertions must all identify `0.3.0`.
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.3.0` workflow must generate a CycloneDX SBOM, canonicalize npm's
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.3.0`. They must not
250
- ship a Git dependency or fall back to the incompatible `0.2.x` enrollment
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,6 @@
1
1
  {
2
2
  "name": "borgmcp-shared",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Shared protocol, domain, templates, and conformance contracts for Borg MCP implementations.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -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
- decodeProtocolInfoEnvelope,
11
+ decodeProtocolTagPreflight,
13
12
  decodeReadLogResultEnvelope,
14
13
  decodeSseFrames,
15
- negotiateProtocol,
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.enrollment-auth', area: '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.enrollment-auth', async () => {
423
- expectError(await environment.operations.protocol(null), 401, ErrorCode.AUTH_MISSING, 'Unauthenticated protocol request');
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) <= protocolInfo.limits.max_request_bytes &&
742
- utf8ByteLength(injectedMessage) <= protocolInfo.limits.max_log_message_bytes,
743
- 'Injection fixture exceeded an advertised request limit.',
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, protocolInfo.limits.max_request_bytes - utf8ByteLength(baseBody) + 1),
798
+ Math.max(0, PROTOCOL_LIMIT_CEILINGS.max_request_bytes - utf8ByteLength(baseBody) + 1),
809
799
  );
810
800
  invariant(
811
- utf8ByteLength(oversizedBody) > protocolInfo.limits.max_request_bytes,
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);