feat: add canonical cloud collaboration binding (#1132)

This commit is contained in:
Yishen Tu
2026-08-21 15:35:57 +08:00
committed by GitHub
parent d7e3d8a31c
commit c0522e04ff
29 changed files with 3466 additions and 39 deletions
+1 -1
View File
@@ -58,7 +58,7 @@ Scoped guides define the source of truth and allowed mutators for state in their
| Area | Responsibility |
| --- | --- |
| `src/main.ts` | Plugin lifecycle and concrete application composition |
| `packages/collab-protocol/` | Sole owner of the decision-complete shared Collab wire contract: opaque IDs, transport-neutral DTOs, executable codecs, the operation registry, shared safe errors and limits, Git ref semantics, and version/compatibility policy consumed by Claudian and Cloud Server; undecided Cloud snapshots and events remain excluded |
| `packages/collab-protocol/` | Sole owner of the decision-complete shared Collab wire contract: opaque IDs, transport-neutral DTOs, executable codecs, canonical operation semantics, Cloud binding routes/capabilities/bootstrap/snapshot/events, shared safe errors and limits, Git ref semantics, and independent package/wire/binding compatibility policy consumed by Claudian and Cloud Server |
| `src/app/` | Application conversation, settings, provider-host, and storage services |
| `src/core/` | Provider-neutral runtime, registry, storage, tool, and type contracts |
| `src/providers/acp/` | Shared ACP transport, interaction, and session primitives without provider policy |
+2 -2
View File
@@ -14,7 +14,7 @@
],
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.3.220",
"@claudian/collab-protocol": "0.3.0",
"@claudian/collab-protocol": "0.4.0",
"@codemirror/commands": "6.10.0",
"@codemirror/lang-markdown": "^6.5.2",
"@codemirror/language": "^6.12.4",
@@ -13225,7 +13225,7 @@
},
"packages/collab-protocol": {
"name": "@claudian/collab-protocol",
"version": "0.3.0",
"version": "0.4.0",
"license": "MIT",
"dependencies": {
"@lezer/markdown": "1.7.2"
+1 -1
View File
@@ -83,7 +83,7 @@
},
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.3.220",
"@claudian/collab-protocol": "0.3.0",
"@claudian/collab-protocol": "0.4.0",
"@codemirror/commands": "6.10.0",
"@codemirror/lang-markdown": "^6.5.2",
"@codemirror/language": "^6.12.4",
+5 -4
View File
@@ -4,7 +4,7 @@
## Ownership
- This package owns: opaque Collab IDs and their runtime validation predicates, decision-complete transport-neutral request/response DTOs, executable JSON codecs, the one canonical operation registry, decoder-defined compatibility behavior, safe shared wire error codes and sanitized context, shared Git ref semantics, shared limits, idempotency/expected-state field shapes, and protocol-version negotiation contracts.
- This package owns: opaque Collab IDs and their runtime validation predicates, decision-complete transport-neutral request/response DTOs, executable JSON codecs, the one canonical collaboration operation registry, Cloud binding versions and route builders, capability/bootstrap/snapshot/event contracts, decoder-defined compatibility behavior, safe shared wire error codes and sanitized context, shared Git ref semantics, shared limits, idempotency/expected-state field shapes, and protocol-version negotiation contracts.
- There is exactly one source of truth for every exported type, codec, operation, error, limit, ref rule, and compatibility rule. Do not copy, re-declare, or re-register any of them in Claudian `src/`, Cloud Server, or fixtures presented as editable source.
- LAN bindings (HTTP versions, methods, route templates, prefixes, authentication/admission, dispatch, invitation trust, mDNS/TLS/discovery, Host-transfer transports) are owned by the consuming application, never by this package. `IngressPrincipal` and ingress details are server deployment contracts and do not belong here.
@@ -17,18 +17,19 @@
## Public exports
- `src/index.ts` is the only public entry point; `package.json` `exports` exposes only `.`. Do not add subpath exports.
- Export only the contract vocabulary consumers bind to: IDs, DTOs, codecs, the operation registry, shared errors, shared limits, ref semantics, parsers, and version constants. Module-private helpers stay unexported. Application-only error codes, recovery actions, quotas, and diff/checkout limits remain outside the package.
- Export only the contract vocabulary consumers bind to: IDs, DTOs, codecs, the collaboration operation registry, Cloud binding routes/capabilities/bootstrap/snapshot/events, shared errors, shared limits, ref semantics, parsers, and independent version constants. Module-private helpers stay unexported. Application-only error codes, recovery actions, quotas, and diff/checkout limits remain outside the package.
- Consumers validate shared Project, Member, opaque-operation, and Git-object IDs through the exported predicates. Do not export regex objects or duplicate their grammar in a transport adapter; application-only request IDs, credentials, fingerprints, and path rules remain locally owned.
- Removing or renaming an export, or tightening a decoder, is a breaking package change; see versioning below.
## Compatibility and versioning
- Package SemVer (pre-1.0) is independent from the wire-protocol version. `COLLAB_PROTOCOL_VERSION` is currently `3`; the supported wire range is exactly `[3, 3]`. Existing application LAN control version `9` is independently owned under `src/app/collab/lan/`.
- Package SemVer, canonical wire version, and Cloud binding version are independent authorities. The accepted steps 58 producer revision advances package `0.3.0`/wire `3` to package `0.4.0`/wire `4` and introduces Cloud binding version `1`. Existing application LAN control/event version `9` remains independently owned under `src/app/collab/lan/` and must not change as a consequence.
- `contract-snapshot.json` is the committed public-contract baseline. Regenerate it with repository-root `npm run check:protocol-compatibility -- --write` only for an intentional contract change; any detected contract change requires monotonic increases to both package SemVer and `COLLAB_PROTOCOL_VERSION`. CI compares the proposed snapshot with the merge-base snapshot and rejects rollbacks or unversioned drift.
- Envelope decoders reject unknown fields and unsupported protocol versions (fail closed). Operation compatibility is decoder-defined and pinned by fixtures; decoded DTOs must not retain unrecognized input properties. Unknown operation kinds have no codec and fail at registry lookup.
- Page budgets measure final JSON serialization, including escaping. Every JSON transport adapter must accept `COLLAB_LIMITS.maxJsonPayloadUtf8Bytes`; a producer must fail closed if a single valid item cannot fit its protocol-owned page budget.
- Any change to envelope, DTO, or operation payload shape, or to the operation inventory, is a wire-breaking change requiring a new wire-protocol version; package SemVer alone never signals wire compatibility.
- The package currently owns only the decision-complete request/Ticket/Accept semantics. Do not add Project snapshot, event, onboarding, lifecycle, or HTTP binding contracts until their exact Cloud behavior is accepted. In particular, publish no shared event kind until its exact redacted payload codec and snapshot-fallback behavior are decision-complete.
- The accepted Cloud surface is limited to binding v1 capability discovery, six private-development bootstrap bindings, `getProjectSnapshot`, the canonical collaboration operations, six redacted durable event kinds, and `snapshot.required`. Do not add production onboarding, membership lifecycle, LAN lifecycle, or another HTTP binding without a separately accepted decision-complete contract.
- Unknown capability tokens are ignored. Unknown fields, discriminants, operation names, package-owned schema versions, Cloud binding versions, and canonical wire versions fail closed. A Cloud capability token expresses complete server support, not client availability or LAN behavior.
## Tests
+8 -13
View File
@@ -19,14 +19,11 @@ or compatibility rules.
`collabMemberRef`)
- Shared limits that client and server must agree on (`COLLAB_LIMITS`)
- Protocol-version negotiation (`COLLAB_PROTOCOL_VERSION`, envelope decoding)
- Cloud binding v1 route construction/matching, capability negotiation, strict success/error envelopes, private-development bootstrap, Project snapshot, and redacted event contracts
- Markdown-derived Ticket-reference and Member-mention semantics shared by
client rendering and authority derivation
The package is transport-neutral. It contains no HTTP routes, methods, or
dispatch; no LAN Host admission, invitation trust, mDNS/TLS/discovery, or
Host-transfer transport; no Obsidian, Vault, UI, Agent, provider, SQL, or
filesystem behavior; and no `IngressPrincipal` or other deployment-ingress
contracts.
The package owns the declarative Cloud HTTP/WebSocket/Git binding contract, but no transport runtime, request dispatch, trust policy, authentication, authorization, persistence, Git execution, or client-local recovery. It contains no LAN Host admission, invitation trust, mDNS/TLS/discovery, or Host-transfer transport; no Obsidian, Vault, UI, Agent, provider, SQL, or filesystem behavior; and no `IngressPrincipal` or other deployment-ingress contracts.
The current registry contains only the decision-complete, transport-neutral
request, Ticket, metadata, and Accept operations. Detail DTOs embed the first
@@ -40,11 +37,7 @@ fixed part of the detail so the whole detail stays within
has one compatible final-serialization limit. Request comments and Ticket
accepted relations also have shared authority-enforced total limits; summary
counts let complete consumers reject partial or cross-snapshot assembly.
Existing LAN v9 Join,
invitation, endpoint, membership
lifecycle, Host-transfer, retirement, Project snapshot, and HTTP bindings
remain application-owned. The exact Cloud snapshot and route catalog require
a separate product-level contract decision.
Cloud binding v1 adds the authority-neutral Project snapshot, six redacted durable event kinds plus `snapshot.required`, eight capability tokens, the exact ordinary Project/Git route catalog, and six private-development bootstrap bindings. Existing LAN v9 Join, invitation, endpoint, membership lifecycle, Host-transfer, retirement, snapshot, event, and HTTP bindings remain independently application-owned.
## Usage
@@ -67,16 +60,17 @@ part of the public surface.
## Versioning
Package SemVer and the wire-protocol version are independent concepts.
Package SemVer, canonical wire version, and Cloud binding version are independent concepts.
- **Package version** (this `package.json`): pre-1.0. Minor releases may add or
break TypeScript API; patch releases are behavior-preserving fixes. The
package version never signals wire compatibility by itself.
- **Wire version** (`COLLAB_PROTOCOL_VERSION`): currently `3`. The supported
range is exactly `[3, 3]`. This is independent from the existing application
- **Wire version** (`COLLAB_PROTOCOL_VERSION`): currently `4`. The supported
range is exactly `[4, 4]`. This is independent from Cloud binding version `1` and the existing application
LAN control version `9`. Any change to an envelope, DTO, or operation
payload shape, or to the operation inventory, is wire-breaking and requires
a new wire-protocol version.
- **Cloud binding version** (`COLLAB_CLOUD_BINDING_VERSION`): currently `1`. It versions Cloud routes and capabilities independently from package and wire versions.
### Compatibility behavior
@@ -87,6 +81,7 @@ Package SemVer and the wire-protocol version are independent concepts.
- An unsupported `protocolVersion` decodes to `unsupported-version` with the
received and supported versions in safe context.
- Unknown operation kinds have no codec and fail at registry lookup.
- Unknown Cloud capability tokens are accepted and ignored by older consumers; unknown document fields, route operations, discriminants, schema versions, binding versions, and wire versions fail closed.
- `CollabError.safeContext` is sanitized: credential-like keys are redacted
and filesystem paths are replaced with `[PATH]`.
+434 -4
View File
@@ -1,6 +1,18 @@
{
"contract": {
"decoderBehaviorDigests": [
{
"path": "src/CollabCloudBinding.ts",
"sha256": "77ec4def0bff108fc7bbd8e7051abcad927d9b62db89ab164cc9d5ddf8aa4d2c"
},
{
"path": "src/CollabCloudProjectEvent.ts",
"sha256": "631161802c5af5f946224cebc9d4b117d5474bd074a10ec912f4f3a417676c8f"
},
{
"path": "src/CollabCloudProjectSnapshot.ts",
"sha256": "28b8a540076d7b2ac07cc1b15ea60996da987978ab2d3094526687f23bcef36c"
},
{
"path": "src/CollabControlOperationCodecs.ts",
"sha256": "9297e2d88ec9081f8649ade4b06bea8a04a8a6fa5d157d1f9259616dbbd7f34f"
@@ -20,6 +32,10 @@
{
"path": "src/CollabValidation.ts",
"sha256": "cdc40841c8414633481f7a3018dd3aec374ccb97eea42a00dbac6c2c475dde76"
},
{
"path": "src/DevelopmentBootstrap.ts",
"sha256": "44e89952d02f79f244cead6ef732628d8ef6e2399bc82de2717d4714f5efd229"
}
],
"errorCodes": [
@@ -69,8 +85,10 @@
"maxCommentBytes": 16384,
"maxCommentPageSize": 100,
"maxJsonPayloadUtf8Bytes": 524288,
"maxMemberDisplayNameUtf16": 200,
"maxPageCursorUtf16": 512,
"maxPathSegmentUtf16": 120,
"maxProjectNameUtf16": 200,
"maxRelationsPerPage": 100,
"maxRepositoryPathUtf16": 240,
"maxRequestComments": 500,
@@ -113,11 +131,61 @@
"exportName": "AcceptResponse",
"source": "./CollabProtocol"
},
{
"declaration": "export interface ActivateDevelopmentBootstrapRequest {\n readonly attemptId: string;\n readonly manifestSha256: string;\n}",
"exportName": "ActivateDevelopmentBootstrapRequest",
"source": "./DevelopmentBootstrap"
},
{
"declaration": "export interface BeginDevelopmentBootstrapRequest {\n readonly manifest: DevelopmentBootstrapManifest;\n}",
"exportName": "BeginDevelopmentBootstrapRequest",
"source": "./DevelopmentBootstrap"
},
{
"declaration": "export interface CancelDevelopmentBootstrapRequest {\n readonly attemptId: string;\n}",
"exportName": "CancelDevelopmentBootstrapRequest",
"source": "./DevelopmentBootstrap"
},
{
"declaration": "export interface ChangeTicketStatusRequest extends CollabMutationContext {\n ticketId: CollabTicketId;\n expectedRevision: number;\n}",
"exportName": "ChangeTicketStatusRequest",
"source": "./CollabProtocol"
},
{
"declaration": "export declare const COLLAB_CLOUD_BINDING_LIMITS: Readonly<{\n readonly bootstrapAttemptTtlMs: number;\n readonly defaultMaxConcurrentBootstrapUploads: 1;\n readonly eventHeartbeatMs: 30000;\n readonly eventMissedHeartbeatLimit: 2;\n readonly maxCloudOpenRequests: 100;\n readonly maxCloudProjectMembers: 100;\n readonly maxCloudSnapshotUtf8Bytes: number;\n readonly maxCloudTicketHighlights: 5;\n readonly maxDevelopmentBootstrapGitBundleBytes: number;\n readonly maxDevelopmentBootstrapManifestUtf8Bytes: number;\n readonly maxDevelopmentBootstrapReportUtf8Bytes: number;\n readonly maxDevelopmentBootstrapRepositoryBytes: number;\n readonly maxDevelopmentBootstrapStagingBytes: number;\n readonly maxEventReplay: 500;\n readonly maxGitReceivePackBytes: number;\n readonly maxRepositoryBytes: number;\n readonly minRetainedEventCount: 10000;\n readonly maxUploadsPerBootstrapAttempt: 1;\n readonly minEventRetentionDays: 30;\n readonly uploadDeadlineMs: number;\n readonly uploadIdleTimeoutMs: 30000;\n}>;",
"exportName": "COLLAB_CLOUD_BINDING_LIMITS",
"source": "./CollabCloudBinding"
},
{
"declaration": "export declare const COLLAB_CLOUD_BINDING_VERSION: 1;",
"exportName": "COLLAB_CLOUD_BINDING_VERSION",
"source": "./CollabCloudBinding"
},
{
"declaration": "export declare const COLLAB_CLOUD_CAPABILITIES: readonly [\"accept\", \"development-bootstrap\", \"git-receive-pack-personal-ref\", \"git-upload-pack\", \"project-events\", \"project-snapshot\", \"requests\", \"tickets\"];",
"exportName": "COLLAB_CLOUD_CAPABILITIES",
"source": "./CollabCloudBinding"
},
{
"declaration": "export declare const COLLAB_CLOUD_CAPABILITY_DOCUMENT_SCHEMA_VERSION: 1;",
"exportName": "COLLAB_CLOUD_CAPABILITY_DOCUMENT_SCHEMA_VERSION",
"source": "./CollabCloudBinding"
},
{
"declaration": "export declare const COLLAB_CLOUD_EVENT_KINDS: readonly [\"membership.updated\", \"request.updated\", \"request.comment-added\", \"ticket.updated\", \"ticket.comment-added\", \"main.updated\"];",
"exportName": "COLLAB_CLOUD_EVENT_KINDS",
"source": "./CollabCloudProjectEvent"
},
{
"declaration": "export declare const COLLAB_CLOUD_JSON_OPERATIONS: readonly [\"getProjectSnapshot\", ...(keyof import(\"./CollabProtocol\").CollabControlOperationMap)[]];",
"exportName": "COLLAB_CLOUD_JSON_OPERATIONS",
"source": "./CollabCloudBinding"
},
{
"declaration": "export declare const COLLAB_CLOUD_PROJECT_SNAPSHOT_CODEC: Readonly<{\n readonly decodeRequest: typeof decodeSnapshotRequest;\n readonly decodeResponse: typeof decodeCollabCloudProjectSnapshot;\n}>;",
"exportName": "COLLAB_CLOUD_PROJECT_SNAPSHOT_CODEC",
"source": "./CollabCloudProjectSnapshot"
},
{
"declaration": "export declare const COLLAB_CONTROL_OPERATION_CODECS: Readonly<{\n readonly getRequest: CollabControlOperationCodec<import(\"./CollabProtocol\").GetRequestRequest, import(\"./types\").CollabRequestDetail>;\n readonly listRequestComments: CollabControlOperationCodec<import(\"./CollabProtocol\").ListRequestCommentsRequest, import(\"./types\").CollabCommentPage>;\n readonly ensureMyRequest: CollabControlOperationCodec<import(\"./CollabProtocol\").EnsureMyRequestRequest, import(\"./CollabProtocol\").EnsureMyRequestResponse>;\n readonly createComment: CollabControlOperationCodec<import(\"./CollabProtocol\").CreateCommentRequest, import(\"./CollabProtocol\").CreateCommentResponse>;\n readonly listTickets: CollabControlOperationCodec<import(\"./CollabProtocol\").ListTicketsRequest, import(\"./types\").CollabTicketPage>;\n readonly getTicket: CollabControlOperationCodec<import(\"./CollabProtocol\").GetTicketRequest, import(\"./types\").CollabTicketDetail>;\n readonly listTicketComments: CollabControlOperationCodec<import(\"./CollabProtocol\").ListTicketCommentsRequest, import(\"./types\").CollabTicketCommentPage>;\n readonly listTicketAcceptedRelations: CollabControlOperationCodec<import(\"./CollabProtocol\").ListTicketAcceptedRelationsRequest, import(\"./types\").CollabTicketAcceptedRelationPage>;\n readonly createTicket: CollabControlOperationCodec<import(\"./CollabProtocol\").CreateTicketRequest, import(\"./CollabProtocol\").CreateTicketResponse>;\n readonly updateTicketContent: CollabControlOperationCodec<import(\"./CollabProtocol\").UpdateTicketContentRequest, import(\"./CollabProtocol\").TicketMutationResponse>;\n readonly createTicketComment: CollabControlOperationCodec<import(\"./CollabProtocol\").CreateTicketCommentRequest, import(\"./CollabProtocol\").CreateTicketCommentResponse>;\n readonly closeTicket: CollabControlOperationCodec<import(\"./CollabProtocol\").ChangeTicketStatusRequest, import(\"./CollabProtocol\").TicketMutationResponse>;\n readonly reopenTicket: CollabControlOperationCodec<import(\"./CollabProtocol\").ChangeTicketStatusRequest, import(\"./CollabProtocol\").TicketMutationResponse>;\n readonly updateMyRequestMetadata: CollabControlOperationCodec<import(\"./CollabProtocol\").UpdateMyRequestMetadataRequest, import(\"./CollabProtocol\").UpdateMyRequestMetadataResponse>;\n readonly acceptRequest: CollabControlOperationCodec<import(\"./CollabProtocol\").AcceptRequest, import(\"./CollabProtocol\").AcceptResponse>;\n}>;",
"exportName": "COLLAB_CONTROL_OPERATION_CODECS",
@@ -129,7 +197,7 @@
"source": "./CollabError"
},
{
"declaration": "export declare const COLLAB_LIMITS: Readonly<{\n maxBlobBytes: number;\n maxChangedPaths: 2000;\n maxCommentBytes: number;\n maxRequestDescriptionBytes: number;\n maxTicketTitleUtf16: 200;\n maxTicketBodyBytes: number;\n maxTicketCommentBytes: number;\n maxRequestTicketRelations: 32;\n maxRequestComments: 500;\n defaultTicketPageSize: 50;\n maxTicketPageSize: 100;\n maxTicketComments: 500;\n maxTicketAcceptedRelations: 2000;\n defaultCommentPageSize: 50;\n maxCommentPageSize: 100;\n commentPageMaxUtf8Bytes: number;\n maxRelationsPerPage: 100;\n relationPageMaxUtf8Bytes: number;\n ticketPageMaxUtf8Bytes: number;\n detailMaxUtf8Bytes: number;\n maxJsonPayloadUtf8Bytes: number;\n maxPageCursorUtf16: 512;\n maxPathSegmentUtf16: 120;\n maxRepositoryPathUtf16: 240;\n}>;",
"declaration": "export declare const COLLAB_LIMITS: Readonly<{\n maxBlobBytes: number;\n maxChangedPaths: 2000;\n maxCommentBytes: number;\n maxMemberDisplayNameUtf16: 200;\n maxRequestDescriptionBytes: number;\n maxProjectNameUtf16: 200;\n maxTicketTitleUtf16: 200;\n maxTicketBodyBytes: number;\n maxTicketCommentBytes: number;\n maxRequestTicketRelations: 32;\n maxRequestComments: 500;\n defaultTicketPageSize: 50;\n maxTicketPageSize: 100;\n maxTicketComments: 500;\n maxTicketAcceptedRelations: 2000;\n defaultCommentPageSize: 50;\n maxCommentPageSize: 100;\n commentPageMaxUtf8Bytes: number;\n maxRelationsPerPage: 100;\n relationPageMaxUtf8Bytes: number;\n ticketPageMaxUtf8Bytes: number;\n detailMaxUtf8Bytes: number;\n maxJsonPayloadUtf8Bytes: number;\n maxPageCursorUtf16: 512;\n maxPathSegmentUtf16: 120;\n maxRepositoryPathUtf16: 240;\n}>;",
"exportName": "COLLAB_LIMITS",
"source": "./CollabConstants"
},
@@ -144,7 +212,7 @@
"source": "./CollabConstants"
},
{
"declaration": "export declare const COLLAB_PROTOCOL_VERSION: 3;",
"declaration": "export declare const COLLAB_PROTOCOL_VERSION: 4;",
"exportName": "COLLAB_PROTOCOL_VERSION",
"source": "./CollabConstants"
},
@@ -158,6 +226,141 @@
"exportName": "CollabChangeRequest",
"source": "./types"
},
{
"declaration": "export declare function collabCloudCapabilitiesRoute(): CollabCloudRoute;",
"exportName": "collabCloudCapabilitiesRoute",
"source": "./CollabCloudBinding"
},
{
"declaration": "export type CollabCloudCapability = typeof COLLAB_CLOUD_CAPABILITIES[number];",
"exportName": "CollabCloudCapability",
"source": "./CollabCloudBinding"
},
{
"declaration": "export declare function collabCloudCapabilityDocument(capabilities: readonly CollabCloudCapability[], limits: CollabCloudCapabilityLimits): CollabCloudCapabilityDocument;",
"exportName": "collabCloudCapabilityDocument",
"source": "./CollabCloudBinding"
},
{
"declaration": "export interface CollabCloudCapabilityDocument {\n readonly bindingVersions: readonly number[];\n readonly capabilities: readonly string[];\n readonly limits: CollabCloudCapabilityLimits;\n readonly protocolVersions: readonly number[];\n readonly schemaVersion: typeof COLLAB_CLOUD_CAPABILITY_DOCUMENT_SCHEMA_VERSION;\n}",
"exportName": "CollabCloudCapabilityDocument",
"source": "./CollabCloudBinding"
},
{
"declaration": "export interface CollabCloudCapabilityLimits {\n readonly maxDevelopmentBootstrapGitBundleBytes: number;\n readonly maxDevelopmentBootstrapManifestUtf8Bytes: number;\n readonly maxDevelopmentBootstrapReportUtf8Bytes: number;\n readonly maxEventReplay: number;\n readonly maxGitReceivePackBytes: number;\n readonly maxJsonPayloadUtf8Bytes: number;\n readonly maxRepositoryBytes: number;\n}",
"exportName": "CollabCloudCapabilityLimits",
"source": "./CollabCloudBinding"
},
{
"declaration": "export declare function collabCloudCapabilitySupported(document: CollabCloudCapabilityDocument, capability: CollabCloudCapability): boolean;",
"exportName": "collabCloudCapabilitySupported",
"source": "./CollabCloudBinding"
},
{
"declaration": "export declare function collabCloudErrorEnvelope(requestId: string, error: CollabError): CollabCloudErrorEnvelope;",
"exportName": "collabCloudErrorEnvelope",
"source": "./CollabCloudBinding"
},
{
"declaration": "export interface CollabCloudErrorEnvelope {\n readonly error: CollabCloudWireError;\n readonly protocolVersion: typeof COLLAB_PROTOCOL_VERSION;\n readonly requestId: string;\n}",
"exportName": "CollabCloudErrorEnvelope",
"source": "./CollabCloudBinding"
},
{
"declaration": "export type CollabCloudEventKind = typeof COLLAB_CLOUD_EVENT_KINDS[number];",
"exportName": "CollabCloudEventKind",
"source": "./CollabCloudProjectEvent"
},
{
"declaration": "export interface CollabCloudEventPayloadMap {\n readonly 'main.updated': {\n readonly mainOid: CollabGitOid;\n readonly requestId: CollabRequestId;\n };\n readonly 'membership.updated': {\n readonly memberId: CollabMemberId;\n };\n readonly 'request.comment-added': {\n readonly requestId: CollabRequestId;\n };\n readonly 'request.updated': {\n readonly requestId: CollabRequestId;\n };\n readonly 'ticket.comment-added': {\n readonly ticketId: CollabTicketId;\n };\n readonly 'ticket.updated': {\n readonly ticketId: CollabTicketId;\n };\n}",
"exportName": "CollabCloudEventPayloadMap",
"source": "./CollabCloudProjectEvent"
},
{
"declaration": "export declare function collabCloudGitRoute(projectId: string, routeKind: 'info-refs', service: CollabCloudGitService): CollabCloudRoute;\nexport declare function collabCloudGitRoute(projectId: string, routeKind: CollabCloudGitService): CollabCloudRoute;",
"exportName": "collabCloudGitRoute",
"source": "./CollabCloudBinding"
},
{
"declaration": "export type CollabCloudGitService = 'git-upload-pack' | 'git-receive-pack';",
"exportName": "CollabCloudGitService",
"source": "./CollabCloudBinding"
},
{
"declaration": "export type CollabCloudJsonOperation = typeof COLLAB_CLOUD_JSON_OPERATIONS[number];",
"exportName": "CollabCloudJsonOperation",
"source": "./CollabCloudBinding"
},
{
"declaration": "export type CollabCloudProjectEvent = {\n readonly [Kind in CollabCloudEventKind]: {\n readonly kind: Kind;\n readonly occurredAt: CollabIsoTimestamp;\n readonly payload: CollabCloudEventPayloadMap[Kind];\n readonly projectId: CollabProjectId;\n readonly protocolVersion: typeof COLLAB_PROTOCOL_VERSION;\n readonly sequence: number;\n };\n}[CollabCloudEventKind];",
"exportName": "CollabCloudProjectEvent",
"source": "./CollabCloudProjectEvent"
},
{
"declaration": "export type CollabCloudProjectEventMessage = CollabCloudProjectEvent | CollabCloudSnapshotRequired;",
"exportName": "CollabCloudProjectEventMessage",
"source": "./CollabCloudProjectEvent"
},
{
"declaration": "export declare function collabCloudProjectEventsRoute(projectId: string, afterSequence: number): CollabCloudRoute;",
"exportName": "collabCloudProjectEventsRoute",
"source": "./CollabCloudBinding"
},
{
"declaration": "export interface CollabCloudProjectMember {\n readonly activatedAt: CollabIsoTimestamp;\n readonly createdAt: CollabIsoTimestamp;\n readonly displayName: string;\n readonly id: CollabMemberId;\n readonly personalRef: string;\n readonly role: CollabRole;\n readonly status: 'active';\n}",
"exportName": "CollabCloudProjectMember",
"source": "./CollabCloudProjectSnapshot"
},
{
"declaration": "export declare function collabCloudProjectOperationRoute(projectId: string, operation: CollabCloudJsonOperation): CollabCloudRoute;",
"exportName": "collabCloudProjectOperationRoute",
"source": "./CollabCloudBinding"
},
{
"declaration": "export interface CollabCloudProjectSnapshot {\n readonly currentMember: CollabCloudProjectMember;\n readonly eventSequence: number;\n readonly members: readonly CollabCloudProjectMember[];\n readonly openRequests: readonly CollabChangeRequest[];\n readonly openTicketCount: number;\n readonly project: CollabCloudProjectSummary;\n readonly ticketHighlights: readonly CollabTicketSummary[];\n}",
"exportName": "CollabCloudProjectSnapshot",
"source": "./CollabCloudProjectSnapshot"
},
{
"declaration": "export interface CollabCloudProjectSnapshotCodec {\n readonly decodeRequest: (value: unknown) => CollabDecodeResult<GetCollabCloudProjectSnapshotRequest>;\n readonly decodeResponse: (value: unknown) => CollabCloudProjectSnapshot;\n}",
"exportName": "CollabCloudProjectSnapshotCodec",
"source": "./CollabCloudProjectSnapshot"
},
{
"declaration": "export interface CollabCloudProjectSummary {\n readonly createdAt: CollabIsoTimestamp;\n readonly expectedMainOid: CollabGitOid;\n readonly id: CollabProjectId;\n readonly mainRef: typeof COLLAB_MAIN_REF;\n readonly name: string;\n}",
"exportName": "CollabCloudProjectSummary",
"source": "./CollabCloudProjectSnapshot"
},
{
"declaration": "export interface CollabCloudRoute {\n readonly match: CollabCloudRouteMatch;\n readonly method: 'GET' | 'POST' | 'PUT';\n readonly target: string;\n}",
"exportName": "CollabCloudRoute",
"source": "./CollabCloudBinding"
},
{
"declaration": "export type CollabCloudRouteMatch = {\n readonly kind: 'capabilities';\n} | {\n readonly kind: 'project-operation';\n readonly operation: CollabCloudJsonOperation;\n readonly projectId: string;\n} | {\n readonly afterSequence: number;\n readonly kind: 'project-events';\n readonly projectId: string;\n} | {\n readonly kind: 'git-info-refs';\n readonly projectId: string;\n readonly service: CollabCloudGitService;\n} | {\n readonly kind: 'git-upload-pack' | 'git-receive-pack';\n readonly projectId: string;\n} | {\n readonly attemptId?: string;\n readonly kind: 'development-bootstrap';\n readonly operation: DevelopmentBootstrapOperation;\n};",
"exportName": "CollabCloudRouteMatch",
"source": "./CollabCloudBinding"
},
{
"declaration": "export interface CollabCloudSnapshotRequired {\n readonly kind: 'snapshot.required';\n readonly latestSequence: number;\n}",
"exportName": "CollabCloudSnapshotRequired",
"source": "./CollabCloudProjectEvent"
},
{
"declaration": "export declare function collabCloudSuccessEnvelope<T>(requestId: string, data: T): CollabCloudSuccessEnvelope<T>;",
"exportName": "collabCloudSuccessEnvelope",
"source": "./CollabCloudBinding"
},
{
"declaration": "export interface CollabCloudSuccessEnvelope<T> {\n readonly data: T;\n readonly protocolVersion: typeof COLLAB_PROTOCOL_VERSION;\n readonly requestId: string;\n}",
"exportName": "CollabCloudSuccessEnvelope",
"source": "./CollabCloudBinding"
},
{
"declaration": "export interface CollabCloudWireError {\n readonly code: CollabErrorCode;\n readonly recoveryActions: readonly CollabRecoveryAction[];\n readonly safeContext: CollabDiagnosticContext;\n}",
"exportName": "CollabCloudWireError",
"source": "./CollabCloudBinding"
},
{
"declaration": "export interface CollabComment {\n id: CollabCommentId;\n requestId: CollabRequestId;\n authorMemberId: CollabMemberId;\n body: string;\n createdAt: CollabIsoTimestamp;\n}",
"exportName": "CollabComment",
@@ -208,6 +411,11 @@
"exportName": "CollabDecodeResult",
"source": "./CollabProtocol"
},
{
"declaration": "export declare function collabDevelopmentBootstrapRoute(operation: 'beginDevelopmentBootstrap'): CollabCloudRoute;\nexport declare function collabDevelopmentBootstrapRoute(operation: Exclude<DevelopmentBootstrapOperation, 'beginDevelopmentBootstrap'>, attemptId: string): CollabCloudRoute;",
"exportName": "collabDevelopmentBootstrapRoute",
"source": "./CollabCloudBinding"
},
{
"declaration": "export type CollabDiagnosticContext = Readonly<Record<string, CollabDiagnosticValue>>;",
"exportName": "CollabDiagnosticContext",
@@ -478,11 +686,176 @@
"exportName": "CreateTicketResponse",
"source": "./CollabProtocol"
},
{
"declaration": "export declare function decodeCollabCloudCapabilityDocument(value: unknown): CollabCloudCapabilityDocument;",
"exportName": "decodeCollabCloudCapabilityDocument",
"source": "./CollabCloudBinding"
},
{
"declaration": "export declare function decodeCollabCloudErrorEnvelope(value: unknown): CollabCloudErrorEnvelope;",
"exportName": "decodeCollabCloudErrorEnvelope",
"source": "./CollabCloudBinding"
},
{
"declaration": "export declare function decodeCollabCloudProjectEventMessage(value: unknown): CollabCloudProjectEventMessage;",
"exportName": "decodeCollabCloudProjectEventMessage",
"source": "./CollabCloudProjectEvent"
},
{
"declaration": "export declare function decodeCollabCloudProjectSnapshot(value: unknown): CollabCloudProjectSnapshot;",
"exportName": "decodeCollabCloudProjectSnapshot",
"source": "./CollabCloudProjectSnapshot"
},
{
"declaration": "export declare function decodeCollabCloudSuccessEnvelope<T = unknown>(value: unknown): CollabCloudSuccessEnvelope<T>;",
"exportName": "decodeCollabCloudSuccessEnvelope",
"source": "./CollabCloudBinding"
},
{
"declaration": "export declare function decodeCollabProtocolEnvelope<T = unknown>(input: unknown): CollabDecodeResult<CollabProtocolEnvelope<T>>;",
"exportName": "decodeCollabProtocolEnvelope",
"source": "./CollabProtocol"
},
{
"declaration": "export declare function decodeDevelopmentBootstrapManifest(value: unknown): DevelopmentBootstrapManifest;",
"exportName": "decodeDevelopmentBootstrapManifest",
"source": "./DevelopmentBootstrap"
},
{
"declaration": "export declare function decodeDevelopmentBootstrapReport(value: unknown): DevelopmentBootstrapReport;",
"exportName": "decodeDevelopmentBootstrapReport",
"source": "./DevelopmentBootstrap"
},
{
"declaration": "export declare const DEVELOPMENT_BOOTSTRAP_ACTIVATION_PHASES: readonly [\"publish-intent\", \"repository-published\", \"activated\", \"completed\"];",
"exportName": "DEVELOPMENT_BOOTSTRAP_ACTIVATION_PHASES",
"source": "./DevelopmentBootstrap"
},
{
"declaration": "export declare const DEVELOPMENT_BOOTSTRAP_ATTEMPT_STATES: readonly [\"collecting\", \"validating\", \"ready\", \"activating\", \"rejected\", \"cancelled\", \"recovery-required\", \"activated\"];",
"exportName": "DEVELOPMENT_BOOTSTRAP_ATTEMPT_STATES",
"source": "./DevelopmentBootstrap"
},
{
"declaration": "export declare const DEVELOPMENT_BOOTSTRAP_CANCELLATION_PHASES: readonly [\"cancel-intent\", \"cancelled\", \"recovery-required\"];",
"exportName": "DEVELOPMENT_BOOTSTRAP_CANCELLATION_PHASES",
"source": "./DevelopmentBootstrap"
},
{
"declaration": "export declare const DEVELOPMENT_BOOTSTRAP_MANIFEST_SCHEMA_VERSION: 1;",
"exportName": "DEVELOPMENT_BOOTSTRAP_MANIFEST_SCHEMA_VERSION",
"source": "./DevelopmentBootstrap"
},
{
"declaration": "export declare const DEVELOPMENT_BOOTSTRAP_OPERATION_CODECS: Readonly<{\n readonly beginDevelopmentBootstrap: DevelopmentBootstrapOperationCodec<BeginDevelopmentBootstrapRequest, DevelopmentBootstrapAttemptStatus>;\n readonly submitDevelopmentBootstrapReport: DevelopmentBootstrapOperationCodec<SubmitDevelopmentBootstrapReportRequest, DevelopmentBootstrapAttemptStatus>;\n readonly getDevelopmentBootstrap: DevelopmentBootstrapOperationCodec<GetDevelopmentBootstrapRequest, DevelopmentBootstrapAttemptStatus>;\n readonly activateDevelopmentBootstrap: DevelopmentBootstrapOperationCodec<ActivateDevelopmentBootstrapRequest, DevelopmentBootstrapAttemptStatus>;\n readonly cancelDevelopmentBootstrap: DevelopmentBootstrapOperationCodec<CancelDevelopmentBootstrapRequest, DevelopmentBootstrapAttemptStatus>;\n readonly putDevelopmentBootstrapGitBundle: DevelopmentBootstrapOperationCodec<PutDevelopmentBootstrapGitBundleRequest, DevelopmentBootstrapAttemptStatus>;\n}>;",
"exportName": "DEVELOPMENT_BOOTSTRAP_OPERATION_CODECS",
"source": "./DevelopmentBootstrap"
},
{
"declaration": "export declare const DEVELOPMENT_BOOTSTRAP_OPERATIONS: readonly [\"beginDevelopmentBootstrap\", \"submitDevelopmentBootstrapReport\", \"getDevelopmentBootstrap\", \"activateDevelopmentBootstrap\", \"cancelDevelopmentBootstrap\", \"putDevelopmentBootstrapGitBundle\"];",
"exportName": "DEVELOPMENT_BOOTSTRAP_OPERATIONS",
"source": "./DevelopmentBootstrap"
},
{
"declaration": "export type DevelopmentBootstrapActivationPhase = typeof DEVELOPMENT_BOOTSTRAP_ACTIVATION_PHASES[number];",
"exportName": "DevelopmentBootstrapActivationPhase",
"source": "./DevelopmentBootstrap"
},
{
"declaration": "export interface DevelopmentBootstrapActivationResult {\n readonly activatedAt: CollabIsoTimestamp;\n readonly activationOperationId: CollabOperationId;\n readonly placementGeneration: number;\n readonly projectId: CollabProjectId;\n}",
"exportName": "DevelopmentBootstrapActivationResult",
"source": "./DevelopmentBootstrap"
},
{
"declaration": "export type DevelopmentBootstrapAttemptState = typeof DEVELOPMENT_BOOTSTRAP_ATTEMPT_STATES[number];",
"exportName": "DevelopmentBootstrapAttemptState",
"source": "./DevelopmentBootstrap"
},
{
"declaration": "export interface DevelopmentBootstrapAttemptStatus {\n readonly activationPhase?: DevelopmentBootstrapActivationPhase;\n readonly activationResult?: DevelopmentBootstrapActivationResult;\n readonly attemptId: string;\n readonly bundleState: DevelopmentBootstrapBundleState;\n readonly cancellationPhase?: DevelopmentBootstrapCancellationPhase;\n readonly createdAt: CollabIsoTimestamp;\n readonly expiresAt: CollabIsoTimestamp;\n readonly manifestSha256: string;\n readonly projectId: CollabProjectId;\n readonly reporterMemberIds: readonly CollabMemberId[];\n readonly state: DevelopmentBootstrapAttemptState;\n}",
"exportName": "DevelopmentBootstrapAttemptStatus",
"source": "./DevelopmentBootstrap"
},
{
"declaration": "export type DevelopmentBootstrapBundleState = 'missing' | 'uploaded' | 'validated';",
"exportName": "DevelopmentBootstrapBundleState",
"source": "./DevelopmentBootstrap"
},
{
"declaration": "export type DevelopmentBootstrapCancellationPhase = typeof DEVELOPMENT_BOOTSTRAP_CANCELLATION_PHASES[number];",
"exportName": "DevelopmentBootstrapCancellationPhase",
"source": "./DevelopmentBootstrap"
},
{
"declaration": "export interface DevelopmentBootstrapClientReadiness {\n readonly cleanupSettled: true;\n readonly collabGitChildrenDrained: true;\n readonly conflictRecoverySettled: true;\n readonly hostTransferSettled: true;\n readonly joinSettled: true;\n readonly leaveSettled: true;\n readonly managerResponsibilitySettled: true;\n readonly projectOperationQueueDrained: true;\n readonly projectSetupSettled: true;\n readonly projectWorkSessionClosed: true;\n readonly publishSettled: true;\n readonly reconciliationSettled: true;\n readonly reconnectSettled: true;\n readonly repositoryIdentityExact: true;\n readonly retirementSettled: true;\n}",
"exportName": "DevelopmentBootstrapClientReadiness",
"source": "./DevelopmentBootstrap"
},
{
"declaration": "export interface DevelopmentBootstrapComparison {\n readonly mainOid: CollabGitOid;\n readonly mainRef: typeof COLLAB_MAIN_REF;\n readonly managerSetGeneration: number;\n readonly members: readonly [\n DevelopmentBootstrapComparisonMember,\n DevelopmentBootstrapComparisonMember\n ];\n readonly projectCreatedAt: CollabIsoTimestamp;\n readonly projectId: CollabProjectId;\n readonly projectName: string;\n readonly sourceCaFingerprint: string;\n readonly sourceEventSequence: number;\n readonly sourceHostMemberId: CollabMemberId;\n}",
"exportName": "DevelopmentBootstrapComparison",
"source": "./DevelopmentBootstrap"
},
{
"declaration": "export interface DevelopmentBootstrapComparisonMember {\n readonly activatedAt: CollabIsoTimestamp;\n readonly createdAt: CollabIsoTimestamp;\n readonly displayName: string;\n readonly memberId: CollabMemberId;\n readonly personalRef: string;\n readonly role: CollabRole;\n readonly status: 'active';\n}",
"exportName": "DevelopmentBootstrapComparisonMember",
"source": "./DevelopmentBootstrap"
},
{
"declaration": "export interface DevelopmentBootstrapGitRef {\n readonly name: string;\n readonly oid: CollabGitOid;\n}",
"exportName": "DevelopmentBootstrapGitRef",
"source": "./DevelopmentBootstrap"
},
{
"declaration": "export interface DevelopmentBootstrapManifest {\n readonly attemptId: string;\n readonly comparison: DevelopmentBootstrapComparison;\n readonly createdAt: CollabIsoTimestamp;\n readonly git: {\n readonly bundle: {\n readonly byteCount: number;\n readonly sha256: string;\n };\n readonly objectFormat: DevelopmentBootstrapObjectFormat;\n readonly refs: readonly DevelopmentBootstrapGitRef[];\n };\n readonly manifestSchemaVersion: typeof DEVELOPMENT_BOOTSTRAP_MANIFEST_SCHEMA_VERSION;\n readonly protocolVersion: CollabProtocolVersion;\n readonly sourceEligibility: DevelopmentBootstrapSourceEligibility;\n}",
"exportName": "DevelopmentBootstrapManifest",
"source": "./DevelopmentBootstrap"
},
{
"declaration": "export type DevelopmentBootstrapObjectFormat = 'sha1' | 'sha256';",
"exportName": "DevelopmentBootstrapObjectFormat",
"source": "./DevelopmentBootstrap"
},
{
"declaration": "export type DevelopmentBootstrapOperation = 'beginDevelopmentBootstrap' | 'submitDevelopmentBootstrapReport' | 'getDevelopmentBootstrap' | 'activateDevelopmentBootstrap' | 'cancelDevelopmentBootstrap' | 'putDevelopmentBootstrapGitBundle';",
"exportName": "DevelopmentBootstrapOperation",
"source": "./CollabCloudBinding"
},
{
"declaration": "export declare function developmentBootstrapOperationCodec<Operation extends DevelopmentBootstrapOperation>(operation: Operation): DevelopmentBootstrapCodecMap[Operation];",
"exportName": "developmentBootstrapOperationCodec",
"source": "./DevelopmentBootstrap"
},
{
"declaration": "export interface DevelopmentBootstrapOperationCodec<Request, Response> {\n readonly decodeRequest: (value: unknown) => CollabDecodeResult<Request>;\n readonly decodeResponse: (value: unknown) => Response;\n}",
"exportName": "DevelopmentBootstrapOperationCodec",
"source": "./DevelopmentBootstrap"
},
{
"declaration": "export interface DevelopmentBootstrapOperationMap {\n readonly activateDevelopmentBootstrap: {\n readonly request: ActivateDevelopmentBootstrapRequest;\n readonly response: DevelopmentBootstrapAttemptStatus;\n };\n readonly beginDevelopmentBootstrap: {\n readonly request: BeginDevelopmentBootstrapRequest;\n readonly response: DevelopmentBootstrapAttemptStatus;\n };\n readonly cancelDevelopmentBootstrap: {\n readonly request: CancelDevelopmentBootstrapRequest;\n readonly response: DevelopmentBootstrapAttemptStatus;\n };\n readonly getDevelopmentBootstrap: {\n readonly request: GetDevelopmentBootstrapRequest;\n readonly response: DevelopmentBootstrapAttemptStatus;\n };\n readonly putDevelopmentBootstrapGitBundle: {\n readonly request: PutDevelopmentBootstrapGitBundleRequest;\n readonly response: DevelopmentBootstrapAttemptStatus;\n };\n readonly submitDevelopmentBootstrapReport: {\n readonly request: SubmitDevelopmentBootstrapReportRequest;\n readonly response: DevelopmentBootstrapAttemptStatus;\n };\n}",
"exportName": "DevelopmentBootstrapOperationMap",
"source": "./DevelopmentBootstrap"
},
{
"declaration": "export interface DevelopmentBootstrapReport {\n readonly attemptId: string;\n readonly capturedAt: CollabIsoTimestamp;\n readonly clientReadiness: DevelopmentBootstrapClientReadiness;\n readonly comparison: DevelopmentBootstrapComparison;\n readonly hostStopAttestation?: DevelopmentHostStopAttestation;\n readonly observedPersonalRefOid: CollabGitOid;\n readonly reporterMemberId: CollabMemberId;\n}",
"exportName": "DevelopmentBootstrapReport",
"source": "./DevelopmentBootstrap"
},
{
"declaration": "export interface DevelopmentBootstrapSourceEligibility {\n readonly liveInvitations: 0;\n readonly nonActiveMemberships: 0;\n readonly nonterminalAcceptOperations: 0;\n readonly nonterminalHostTransfers: 0;\n readonly nonterminalManagerOffers: 0;\n readonly requestComments: 0;\n readonly requests: 0;\n readonly terminalProjectTransitions: 0;\n readonly ticketComments: 0;\n readonly ticketMentions: 0;\n readonly ticketRelations: 0;\n readonly tickets: 0;\n}",
"exportName": "DevelopmentBootstrapSourceEligibility",
"source": "./DevelopmentBootstrap"
},
{
"declaration": "export interface DevelopmentHostStopAttestation {\n readonly attemptId: string;\n readonly autoStartDisabled: true;\n readonly fenceDurable: true;\n readonly fenceId: string;\n readonly hostStopped: true;\n readonly manifestSha256: string;\n readonly projectId: CollabProjectId;\n readonly resourcesDrained: true;\n readonly routeUnregistered: true;\n readonly stoppedAt: CollabIsoTimestamp;\n}",
"exportName": "DevelopmentHostStopAttestation",
"source": "./DevelopmentBootstrap"
},
{
"declaration": "export declare function encodeDevelopmentBootstrapManifestCanonicalJson(value: DevelopmentBootstrapManifest): string;",
"exportName": "encodeDevelopmentBootstrapManifestCanonicalJson",
"source": "./DevelopmentBootstrap"
},
{
"declaration": "export interface EnsureMyRequestRequest extends CollabMutationContext {\n expectedMainOid: CollabGitOid;\n headOid: CollabGitOid;\n description: string;\n}",
"exportName": "EnsureMyRequestRequest",
@@ -493,6 +866,16 @@
"exportName": "EnsureMyRequestResponse",
"source": "./CollabProtocol"
},
{
"declaration": "export interface GetCollabCloudProjectSnapshotRequest {\n readonly projectId: CollabProjectId;\n}",
"exportName": "GetCollabCloudProjectSnapshotRequest",
"source": "./CollabCloudProjectSnapshot"
},
{
"declaration": "export interface GetDevelopmentBootstrapRequest {\n readonly attemptId: string;\n}",
"exportName": "GetDevelopmentBootstrapRequest",
"source": "./DevelopmentBootstrap"
},
{
"declaration": "export interface GetRequestRequest {\n projectId: CollabProjectId;\n requestId: CollabRequestId;\n}",
"exportName": "GetRequestRequest",
@@ -543,6 +926,11 @@
"exportName": "ListTicketsRequest",
"source": "./CollabProtocol"
},
{
"declaration": "export declare function matchCollabCloudRoute(method: string, target: string): CollabCloudRouteMatch | null;",
"exportName": "matchCollabCloudRoute",
"source": "./CollabCloudBinding"
},
{
"declaration": "export declare function parseCollabMemberMentions(markdown: string, targets: readonly CollabMemberMentionTarget[]): readonly CollabMemberId[];",
"exportName": "parseCollabMemberMentions",
@@ -553,6 +941,11 @@
"exportName": "parseCollabTicketReferences",
"source": "./CollabTicketReferenceParser"
},
{
"declaration": "export interface PutDevelopmentBootstrapGitBundleRequest {\n readonly attemptId: string;\n readonly byteCount: number;\n readonly contentEncoding: 'identity';\n readonly contentType: 'application/x-git-bundle';\n readonly sha256: string;\n}",
"exportName": "PutDevelopmentBootstrapGitBundleRequest",
"source": "./DevelopmentBootstrap"
},
{
"declaration": "export declare function sanitizeCollabDiagnosticContext(context?: Readonly<Record<string, unknown>>): CollabDiagnosticContext;",
"exportName": "sanitizeCollabDiagnosticContext",
@@ -563,6 +956,11 @@
"exportName": "scanCollabTicketReferences",
"source": "./CollabTicketReferenceParser"
},
{
"declaration": "export interface SubmitDevelopmentBootstrapReportRequest {\n readonly attemptId: string;\n readonly report: DevelopmentBootstrapReport;\n}",
"exportName": "SubmitDevelopmentBootstrapReportRequest",
"source": "./DevelopmentBootstrap"
},
{
"declaration": "export interface TicketMutationResponse {\n ticket: CollabTicketSummary;\n}",
"exportName": "TicketMutationResponse",
@@ -585,6 +983,13 @@
}
],
"publicRuntimeExports": [
"COLLAB_CLOUD_BINDING_LIMITS",
"COLLAB_CLOUD_BINDING_VERSION",
"COLLAB_CLOUD_CAPABILITIES",
"COLLAB_CLOUD_CAPABILITY_DOCUMENT_SCHEMA_VERSION",
"COLLAB_CLOUD_EVENT_KINDS",
"COLLAB_CLOUD_JSON_OPERATIONS",
"COLLAB_CLOUD_PROJECT_SNAPSHOT_CODEC",
"COLLAB_CONTROL_OPERATION_CODECS",
"COLLAB_ERROR_CODES",
"COLLAB_LIMITS",
@@ -592,21 +997,46 @@
"COLLAB_MEMBER_REF_PREFIX",
"COLLAB_PROTOCOL_VERSION",
"CollabError",
"DEVELOPMENT_BOOTSTRAP_ACTIVATION_PHASES",
"DEVELOPMENT_BOOTSTRAP_ATTEMPT_STATES",
"DEVELOPMENT_BOOTSTRAP_CANCELLATION_PHASES",
"DEVELOPMENT_BOOTSTRAP_MANIFEST_SCHEMA_VERSION",
"DEVELOPMENT_BOOTSTRAP_OPERATIONS",
"DEVELOPMENT_BOOTSTRAP_OPERATION_CODECS",
"collabCloudCapabilitiesRoute",
"collabCloudCapabilityDocument",
"collabCloudCapabilitySupported",
"collabCloudErrorEnvelope",
"collabCloudGitRoute",
"collabCloudProjectEventsRoute",
"collabCloudProjectOperationRoute",
"collabCloudSuccessEnvelope",
"collabControlOperationCodec",
"collabDevelopmentBootstrapRoute",
"collabErrorGroup",
"collabMemberRef",
"decodeCollabCloudCapabilityDocument",
"decodeCollabCloudErrorEnvelope",
"decodeCollabCloudProjectEventMessage",
"decodeCollabCloudProjectSnapshot",
"decodeCollabCloudSuccessEnvelope",
"decodeCollabProtocolEnvelope",
"decodeDevelopmentBootstrapManifest",
"decodeDevelopmentBootstrapReport",
"developmentBootstrapOperationCodec",
"encodeDevelopmentBootstrapManifestCanonicalJson",
"isCollabGitOid",
"isCollabMemberId",
"isCollabOpaqueId",
"isCollabProjectId",
"matchCollabCloudRoute",
"parseCollabMemberMentions",
"parseCollabTicketReferences",
"sanitizeCollabDiagnosticContext",
"scanCollabTicketReferences"
]
},
"packageVersion": "0.3.0",
"protocolVersion": 3,
"packageVersion": "0.4.0",
"protocolVersion": 4,
"schemaVersion": 1
}
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "@claudian/collab-protocol",
"version": "0.3.0",
"description": "Canonical shared Collab wire contract: opaque IDs, transport-neutral DTOs, executable codecs, operation registry, safe errors, shared limits, Git ref semantics, and version policy.",
"version": "0.4.0",
"description": "Canonical Collab wire and Cloud binding contract for Claudian clients and servers.",
"license": "MIT",
"type": "commonjs",
"main": "./dist/index.js",
@@ -122,7 +122,8 @@ const assert = require('node:assert/strict');
const protocol = require('@claudian/collab-protocol');
const packageVersion = ${JSON.stringify(installedManifest.version)};
assert.equal(protocol.COLLAB_PROTOCOL_VERSION, 3);
assert.equal(protocol.COLLAB_PROTOCOL_VERSION, 4);
assert.equal(protocol.COLLAB_CLOUD_BINDING_VERSION, 1);
assert.notEqual(packageVersion, String(protocol.COLLAB_PROTOCOL_VERSION));
assert.match(packageVersion, /^0\\./);
@@ -187,8 +188,8 @@ console.log(`cjs ${cjsOutput}`);
const esmOutput = run(process.execPath, [
'--input-type=module',
'-e',
"import { COLLAB_PROTOCOL_VERSION, collabMemberRef } from '@claudian/collab-protocol';"
+ " if (COLLAB_PROTOCOL_VERSION !== 3 || collabMemberRef('member_1') !== 'refs/heads/members/member_1') process.exit(1);"
"import { COLLAB_CLOUD_BINDING_VERSION, COLLAB_PROTOCOL_VERSION, collabMemberRef } from '@claudian/collab-protocol';"
+ " if (COLLAB_PROTOCOL_VERSION !== 4 || COLLAB_CLOUD_BINDING_VERSION !== 1 || collabMemberRef('member_1') !== 'refs/heads/members/member_1') process.exit(1);"
+ " console.log('esm import OK');",
], { cwd: consumerRoot });
console.log(esmOutput);
@@ -0,0 +1,633 @@
import { COLLAB_LIMITS, COLLAB_PROTOCOL_VERSION } from './CollabConstants';
import {
COLLAB_CONTROL_OPERATION_CODECS,
type CollabControlOperation,
} from './CollabControlOperationCodecs';
import {
COLLAB_ERROR_CODES,
CollabError,
sanitizeCollabDiagnosticContext,
type CollabDiagnosticContext,
type CollabErrorCode,
type CollabRecoveryAction,
} from './CollabError';
import {
isCollabOpaqueId,
isCollabProjectId,
} from './CollabValidation';
export const COLLAB_CLOUD_BINDING_VERSION = 1 as const;
export const COLLAB_CLOUD_CAPABILITY_DOCUMENT_SCHEMA_VERSION = 1 as const;
export const COLLAB_CLOUD_CAPABILITIES = Object.freeze([
'accept',
'development-bootstrap',
'git-receive-pack-personal-ref',
'git-upload-pack',
'project-events',
'project-snapshot',
'requests',
'tickets',
] as const);
export type CollabCloudCapability = typeof COLLAB_CLOUD_CAPABILITIES[number];
export const COLLAB_CLOUD_JSON_OPERATIONS = Object.freeze([
'getProjectSnapshot',
...Object.keys(COLLAB_CONTROL_OPERATION_CODECS) as CollabControlOperation[],
] as const);
export type CollabCloudJsonOperation = typeof COLLAB_CLOUD_JSON_OPERATIONS[number];
export const COLLAB_CLOUD_BINDING_LIMITS = Object.freeze({
bootstrapAttemptTtlMs: 24 * 60 * 60 * 1_000,
defaultMaxConcurrentBootstrapUploads: 1,
eventHeartbeatMs: 30_000,
eventMissedHeartbeatLimit: 2,
maxCloudOpenRequests: 100,
maxCloudProjectMembers: 100,
maxCloudSnapshotUtf8Bytes: 448 * 1024,
maxCloudTicketHighlights: 5,
maxDevelopmentBootstrapGitBundleBytes: 1024 * 1024 * 1024,
maxDevelopmentBootstrapManifestUtf8Bytes: 64 * 1024,
maxDevelopmentBootstrapReportUtf8Bytes: 64 * 1024,
maxDevelopmentBootstrapRepositoryBytes: 1024 * 1024 * 1024,
maxDevelopmentBootstrapStagingBytes: 2 * 1024 * 1024 * 1024,
maxEventReplay: 500,
maxGitReceivePackBytes: 256 * 1024 * 1024,
maxRepositoryBytes: 1024 * 1024 * 1024,
minRetainedEventCount: 10_000,
maxUploadsPerBootstrapAttempt: 1,
minEventRetentionDays: 30,
uploadDeadlineMs: 15 * 60 * 1_000,
uploadIdleTimeoutMs: 30_000,
} as const);
export interface CollabCloudCapabilityLimits {
readonly maxDevelopmentBootstrapGitBundleBytes: number;
readonly maxDevelopmentBootstrapManifestUtf8Bytes: number;
readonly maxDevelopmentBootstrapReportUtf8Bytes: number;
readonly maxEventReplay: number;
readonly maxGitReceivePackBytes: number;
readonly maxJsonPayloadUtf8Bytes: number;
readonly maxRepositoryBytes: number;
}
export interface CollabCloudCapabilityDocument {
readonly bindingVersions: readonly number[];
readonly capabilities: readonly string[];
readonly limits: CollabCloudCapabilityLimits;
readonly protocolVersions: readonly number[];
readonly schemaVersion: typeof COLLAB_CLOUD_CAPABILITY_DOCUMENT_SCHEMA_VERSION;
}
export type DevelopmentBootstrapOperation =
| 'beginDevelopmentBootstrap'
| 'submitDevelopmentBootstrapReport'
| 'getDevelopmentBootstrap'
| 'activateDevelopmentBootstrap'
| 'cancelDevelopmentBootstrap'
| 'putDevelopmentBootstrapGitBundle';
export type CollabCloudGitService = 'git-upload-pack' | 'git-receive-pack';
export type CollabCloudRouteMatch =
| { readonly kind: 'capabilities' }
| {
readonly kind: 'project-operation';
readonly operation: CollabCloudJsonOperation;
readonly projectId: string;
}
| {
readonly afterSequence: number;
readonly kind: 'project-events';
readonly projectId: string;
}
| {
readonly kind: 'git-info-refs';
readonly projectId: string;
readonly service: CollabCloudGitService;
}
| {
readonly kind: 'git-upload-pack' | 'git-receive-pack';
readonly projectId: string;
}
| {
readonly attemptId?: string;
readonly kind: 'development-bootstrap';
readonly operation: DevelopmentBootstrapOperation;
};
export interface CollabCloudRoute {
readonly match: CollabCloudRouteMatch;
readonly method: 'GET' | 'POST' | 'PUT';
readonly target: string;
}
export interface CollabCloudSuccessEnvelope<T> {
readonly data: T;
readonly protocolVersion: typeof COLLAB_PROTOCOL_VERSION;
readonly requestId: string;
}
export interface CollabCloudWireError {
readonly code: CollabErrorCode;
readonly recoveryActions: readonly CollabRecoveryAction[];
readonly safeContext: CollabDiagnosticContext;
}
export interface CollabCloudErrorEnvelope {
readonly error: CollabCloudWireError;
readonly protocolVersion: typeof COLLAB_PROTOCOL_VERSION;
readonly requestId: string;
}
type UnknownRecord = Readonly<Record<string, unknown>>;
const CLOUD_CAPABILITY_SET: ReadonlySet<string> = new Set(COLLAB_CLOUD_CAPABILITIES);
const CLOUD_JSON_OPERATION_SET: ReadonlySet<string> = new Set(COLLAB_CLOUD_JSON_OPERATIONS);
const COLLAB_ERROR_CODE_SET: ReadonlySet<string> = new Set(COLLAB_ERROR_CODES);
const RECOVERY_ACTION_SET: ReadonlySet<string> = new Set([
'retry',
'review-conflicts',
'request-access',
]);
const CAPABILITY_TOKEN_PATTERN = /^[a-z][a-z0-9-]{0,63}$/u;
function invalidPayload(field: string): CollabError {
return new CollabError({
code: 'protocol-payload-invalid',
safeContext: { field },
});
}
function invalidRoute(): never {
throw new RangeError('Invalid Collab Cloud route input');
}
function record(value: unknown, field: string): UnknownRecord {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
throw invalidPayload(field);
}
return value as UnknownRecord;
}
function hasExactKeys(value: UnknownRecord, keys: readonly string[]): boolean {
const expected = new Set(keys);
return keys.every(key => Object.hasOwn(value, key))
&& Object.keys(value).every(key => expected.has(key));
}
function exactRecord(value: unknown, field: string, keys: readonly string[]): UnknownRecord {
const source = record(value, field);
if (!hasExactKeys(source, keys)) throw invalidPayload(field);
return source;
}
function positiveSafeInteger(value: unknown, field: string, maximum?: number): number {
if (
typeof value !== 'number'
|| !Number.isSafeInteger(value)
|| value < 1
|| (maximum !== undefined && value > maximum)
) {
throw invalidPayload(field);
}
return value;
}
function sortedUniqueIntegers(value: unknown, field: string): readonly number[] {
if (!Array.isArray(value) || value.length === 0) throw invalidPayload(field);
const decoded = value.map(item => positiveSafeInteger(item, field));
if (decoded.some((item, index) => index > 0 && decoded[index - 1] >= item)) {
throw invalidPayload(field);
}
return Object.freeze(decoded);
}
function sortedUniqueTokens(value: unknown, field: string): readonly string[] {
if (!Array.isArray(value)) throw invalidPayload(field);
const decoded = value.map((item) => {
if (typeof item !== 'string' || !CAPABILITY_TOKEN_PATTERN.test(item)) {
throw invalidPayload(field);
}
return item;
});
if (decoded.some((item, index) => index > 0 && decoded[index - 1].localeCompare(item, 'en-US') >= 0)) {
throw invalidPayload(field);
}
return Object.freeze(decoded);
}
function requireSupportedVersion(
versions: readonly number[],
supportedVersion: number,
kind: string,
): void {
if (versions.length === 1 && versions[0] === supportedVersion) return;
throw new CollabError({
code: 'protocol-version-unsupported',
safeContext: {
kind,
receivedVersion: versions.find(version => version !== supportedVersion)
?? versions[0]
?? 0,
supportedVersion,
},
});
}
function route(method: CollabCloudRoute['method'], target: string, match: CollabCloudRouteMatch) {
return Object.freeze({ match: Object.freeze(match), method, target });
}
function assertProjectId(projectId: string): void {
if (!isCollabProjectId(projectId)) invalidRoute();
}
function assertAttemptId(attemptId: string): void {
if (!isCollabOpaqueId(attemptId)) invalidRoute();
}
function isCloudJsonOperation(value: string): value is CollabCloudJsonOperation {
return CLOUD_JSON_OPERATION_SET.has(value);
}
export function collabCloudCapabilitiesRoute(): CollabCloudRoute {
return route('GET', '/collab/capabilities', { kind: 'capabilities' });
}
export function collabCloudProjectOperationRoute(
projectId: string,
operation: CollabCloudJsonOperation,
): CollabCloudRoute {
assertProjectId(projectId);
if (!isCloudJsonOperation(operation)) invalidRoute();
return route('POST', `/v1/projects/${projectId}/operations/${operation}`, {
kind: 'project-operation',
operation,
projectId,
});
}
export function collabCloudProjectEventsRoute(
projectId: string,
afterSequence: number,
): CollabCloudRoute {
assertProjectId(projectId);
if (!Number.isSafeInteger(afterSequence) || afterSequence < 0) invalidRoute();
return route('GET', `/v1/projects/${projectId}/events?afterSequence=${afterSequence}`, {
afterSequence,
kind: 'project-events',
projectId,
});
}
export function collabCloudGitRoute(
projectId: string,
routeKind: 'info-refs',
service: CollabCloudGitService,
): CollabCloudRoute;
export function collabCloudGitRoute(
projectId: string,
routeKind: CollabCloudGitService,
): CollabCloudRoute;
export function collabCloudGitRoute(
projectId: string,
routeKind: 'info-refs' | CollabCloudGitService,
service?: CollabCloudGitService,
): CollabCloudRoute {
assertProjectId(projectId);
if (routeKind === 'info-refs') {
if (service !== 'git-upload-pack' && service !== 'git-receive-pack') invalidRoute();
return route(
'GET',
`/v1/projects/${projectId}/repository.git/info/refs?service=${service}`,
{ kind: 'git-info-refs', projectId, service },
);
}
if (service !== undefined) invalidRoute();
return route('POST', `/v1/projects/${projectId}/repository.git/${routeKind}`, {
kind: routeKind,
projectId,
});
}
export function collabDevelopmentBootstrapRoute(
operation: 'beginDevelopmentBootstrap',
): CollabCloudRoute;
export function collabDevelopmentBootstrapRoute(
operation: Exclude<DevelopmentBootstrapOperation, 'beginDevelopmentBootstrap'>,
attemptId: string,
): CollabCloudRoute;
export function collabDevelopmentBootstrapRoute(
operation: DevelopmentBootstrapOperation,
attemptId?: string,
): CollabCloudRoute {
const base = '/v1/development/bootstrap/attempts';
if (operation === 'beginDevelopmentBootstrap') {
if (attemptId !== undefined) invalidRoute();
return route('POST', base, { kind: 'development-bootstrap', operation });
}
if (attemptId === undefined) invalidRoute();
assertAttemptId(attemptId);
const suffixes = {
activateDevelopmentBootstrap: ['POST', 'activate'],
cancelDevelopmentBootstrap: ['POST', 'cancel'],
getDevelopmentBootstrap: ['GET', ''],
putDevelopmentBootstrapGitBundle: ['PUT', 'git-bundle'],
submitDevelopmentBootstrapReport: ['POST', 'reports'],
} as const;
const binding = suffixes[operation];
if (!binding) invalidRoute();
const [method, suffix] = binding;
const target = suffix ? `${base}/${attemptId}/${suffix}` : `${base}/${attemptId}`;
return route(method, target, {
attemptId,
kind: 'development-bootstrap',
operation,
});
}
function exactQuery(url: URL, expectedKeys: readonly string[]): boolean {
const keys = [...url.searchParams.keys()];
return keys.length === expectedKeys.length
&& expectedKeys.every(key => keys.filter(candidate => candidate === key).length === 1)
&& keys.every(key => expectedKeys.includes(key))
&& url.search === (expectedKeys.length === 0 ? '' : `?${url.searchParams.toString()}`);
}
function parseCanonicalNonNegativeInteger(value: string | null): number | null {
if (value === null || !/^(?:0|[1-9][0-9]*)$/u.test(value)) return null;
const parsed = Number(value);
return Number.isSafeInteger(parsed) ? parsed : null;
}
export function matchCollabCloudRoute(
method: string,
target: string,
): CollabCloudRouteMatch | null {
if (!target.startsWith('/') || target.includes('#')) return null;
let url: URL;
try {
url = new URL(target, 'http://collab.invalid');
} catch {
return null;
}
const segments = url.pathname.split('/').filter(Boolean);
if (url.pathname !== `/${segments.join('/')}` || target.endsWith('?')) return null;
if (
method === 'GET'
&& url.pathname === '/collab/capabilities'
&& exactQuery(url, [])
) return { kind: 'capabilities' };
if (
segments[0] === 'v1'
&& segments[1] === 'projects'
&& isCollabProjectId(segments[2])
) {
const projectId = segments[2];
if (
method === 'POST'
&& segments.length === 5
&& segments[3] === 'operations'
&& isCloudJsonOperation(segments[4])
&& exactQuery(url, [])
) {
return { kind: 'project-operation', operation: segments[4], projectId };
}
if (
method === 'GET'
&& segments.length === 4
&& segments[3] === 'events'
&& exactQuery(url, ['afterSequence'])
) {
const afterSequence = parseCanonicalNonNegativeInteger(
url.searchParams.get('afterSequence'),
);
return afterSequence === null
? null
: { afterSequence, kind: 'project-events', projectId };
}
if (
method === 'GET'
&& segments.length === 6
&& segments[3] === 'repository.git'
&& segments[4] === 'info'
&& segments[5] === 'refs'
&& exactQuery(url, ['service'])
) {
const service = url.searchParams.get('service');
return service === 'git-upload-pack' || service === 'git-receive-pack'
? { kind: 'git-info-refs', projectId, service }
: null;
}
if (
method === 'POST'
&& segments.length === 5
&& segments[3] === 'repository.git'
&& (segments[4] === 'git-upload-pack' || segments[4] === 'git-receive-pack')
&& exactQuery(url, [])
) return { kind: segments[4], projectId };
}
if (
segments[0] !== 'v1'
|| segments[1] !== 'development'
|| segments[2] !== 'bootstrap'
|| segments[3] !== 'attempts'
|| !exactQuery(url, [])
) return null;
if (method === 'POST' && segments.length === 4) {
return { kind: 'development-bootstrap', operation: 'beginDevelopmentBootstrap' };
}
const attemptId = segments[4];
if (!isCollabOpaqueId(attemptId)) return null;
if (method === 'GET' && segments.length === 5) {
return { attemptId, kind: 'development-bootstrap', operation: 'getDevelopmentBootstrap' };
}
const operation = (segments.length === 6
? {
activate: method === 'POST' ? 'activateDevelopmentBootstrap' : null,
cancel: method === 'POST' ? 'cancelDevelopmentBootstrap' : null,
'git-bundle': method === 'PUT' ? 'putDevelopmentBootstrapGitBundle' : null,
reports: method === 'POST' ? 'submitDevelopmentBootstrapReport' : null,
}[segments[5]]
: null) as Exclude<DevelopmentBootstrapOperation, 'beginDevelopmentBootstrap' | 'getDevelopmentBootstrap'>
| null
| undefined;
return operation
? { attemptId, kind: 'development-bootstrap', operation }
: null;
}
export function decodeCollabCloudCapabilityDocument(
value: unknown,
): CollabCloudCapabilityDocument {
const source = exactRecord(value, 'capabilities', [
'bindingVersions',
'capabilities',
'limits',
'protocolVersions',
'schemaVersion',
]);
if (source.schemaVersion !== COLLAB_CLOUD_CAPABILITY_DOCUMENT_SCHEMA_VERSION) {
throw invalidPayload('schemaVersion');
}
const bindingVersions = sortedUniqueIntegers(source.bindingVersions, 'bindingVersions');
const protocolVersions = sortedUniqueIntegers(source.protocolVersions, 'protocolVersions');
requireSupportedVersion(bindingVersions, COLLAB_CLOUD_BINDING_VERSION, 'cloud-binding');
requireSupportedVersion(protocolVersions, COLLAB_PROTOCOL_VERSION, 'canonical-wire');
const capabilities = sortedUniqueTokens(source.capabilities, 'capabilities');
const limitsSource = exactRecord(source.limits, 'limits', [
'maxDevelopmentBootstrapGitBundleBytes',
'maxDevelopmentBootstrapManifestUtf8Bytes',
'maxDevelopmentBootstrapReportUtf8Bytes',
'maxEventReplay',
'maxGitReceivePackBytes',
'maxJsonPayloadUtf8Bytes',
'maxRepositoryBytes',
]);
const limits = Object.freeze({
maxDevelopmentBootstrapGitBundleBytes: positiveSafeInteger(
limitsSource.maxDevelopmentBootstrapGitBundleBytes,
'maxDevelopmentBootstrapGitBundleBytes',
COLLAB_CLOUD_BINDING_LIMITS.maxDevelopmentBootstrapGitBundleBytes,
),
maxDevelopmentBootstrapManifestUtf8Bytes: positiveSafeInteger(
limitsSource.maxDevelopmentBootstrapManifestUtf8Bytes,
'maxDevelopmentBootstrapManifestUtf8Bytes',
COLLAB_CLOUD_BINDING_LIMITS.maxDevelopmentBootstrapManifestUtf8Bytes,
),
maxDevelopmentBootstrapReportUtf8Bytes: positiveSafeInteger(
limitsSource.maxDevelopmentBootstrapReportUtf8Bytes,
'maxDevelopmentBootstrapReportUtf8Bytes',
COLLAB_CLOUD_BINDING_LIMITS.maxDevelopmentBootstrapReportUtf8Bytes,
),
maxEventReplay: positiveSafeInteger(
limitsSource.maxEventReplay,
'maxEventReplay',
COLLAB_CLOUD_BINDING_LIMITS.maxEventReplay,
),
maxGitReceivePackBytes: positiveSafeInteger(
limitsSource.maxGitReceivePackBytes,
'maxGitReceivePackBytes',
COLLAB_CLOUD_BINDING_LIMITS.maxGitReceivePackBytes,
),
maxJsonPayloadUtf8Bytes: positiveSafeInteger(
limitsSource.maxJsonPayloadUtf8Bytes,
'maxJsonPayloadUtf8Bytes',
COLLAB_LIMITS.maxJsonPayloadUtf8Bytes,
),
maxRepositoryBytes: positiveSafeInteger(
limitsSource.maxRepositoryBytes,
'maxRepositoryBytes',
COLLAB_CLOUD_BINDING_LIMITS.maxRepositoryBytes,
),
});
return Object.freeze({
bindingVersions,
capabilities,
limits,
protocolVersions,
schemaVersion: COLLAB_CLOUD_CAPABILITY_DOCUMENT_SCHEMA_VERSION,
});
}
export function collabCloudCapabilityDocument(
capabilities: readonly CollabCloudCapability[],
limits: CollabCloudCapabilityLimits,
): CollabCloudCapabilityDocument {
if (!capabilities.every(capability => CLOUD_CAPABILITY_SET.has(capability))) {
throw invalidPayload('capabilities');
}
return decodeCollabCloudCapabilityDocument({
bindingVersions: [COLLAB_CLOUD_BINDING_VERSION],
capabilities: [...capabilities].sort((left, right) => left.localeCompare(right, 'en-US')),
limits,
protocolVersions: [COLLAB_PROTOCOL_VERSION],
schemaVersion: COLLAB_CLOUD_CAPABILITY_DOCUMENT_SCHEMA_VERSION,
});
}
export function collabCloudCapabilitySupported(
document: CollabCloudCapabilityDocument,
capability: CollabCloudCapability,
): boolean {
return CLOUD_CAPABILITY_SET.has(capability) && document.capabilities.includes(capability);
}
export function collabCloudSuccessEnvelope<T>(
requestId: string,
data: T,
): CollabCloudSuccessEnvelope<T> {
if (!isCollabOpaqueId(requestId)) throw invalidPayload('requestId');
return Object.freeze({ data, protocolVersion: COLLAB_PROTOCOL_VERSION, requestId });
}
export function decodeCollabCloudSuccessEnvelope<T = unknown>(
value: unknown,
): CollabCloudSuccessEnvelope<T> {
const source = exactRecord(value, 'envelope', ['data', 'protocolVersion', 'requestId']);
if (source.protocolVersion !== COLLAB_PROTOCOL_VERSION || !isCollabOpaqueId(source.requestId)) {
throw invalidPayload('envelope');
}
return Object.freeze({
data: source.data as T,
protocolVersion: COLLAB_PROTOCOL_VERSION,
requestId: source.requestId,
});
}
export function collabCloudErrorEnvelope(
requestId: string,
error: CollabError,
): CollabCloudErrorEnvelope {
if (!isCollabOpaqueId(requestId)) throw invalidPayload('requestId');
return Object.freeze({
error: Object.freeze({
code: error.code,
recoveryActions: Object.freeze([...error.recoveryActions]),
safeContext: error.safeContext,
}),
protocolVersion: COLLAB_PROTOCOL_VERSION,
requestId,
});
}
export function decodeCollabCloudErrorEnvelope(value: unknown): CollabCloudErrorEnvelope {
const source = exactRecord(value, 'envelope', ['error', 'protocolVersion', 'requestId']);
const wireError = exactRecord(source.error, 'error', [
'code',
'recoveryActions',
'safeContext',
]);
if (
source.protocolVersion !== COLLAB_PROTOCOL_VERSION
|| !isCollabOpaqueId(source.requestId)
|| typeof wireError.code !== 'string'
|| !COLLAB_ERROR_CODE_SET.has(wireError.code)
|| !Array.isArray(wireError.recoveryActions)
|| !wireError.recoveryActions.every(action => (
typeof action === 'string' && RECOVERY_ACTION_SET.has(action)
))
) throw invalidPayload('error');
const safeContextSource = record(wireError.safeContext, 'safeContext');
const safeContext = sanitizeCollabDiagnosticContext(safeContextSource);
if (
Object.keys(safeContextSource).length !== Object.keys(safeContext).length
|| Object.entries(safeContextSource).some(([key, item]) => safeContext[key] !== item)
) throw invalidPayload('safeContext');
return Object.freeze({
error: Object.freeze({
code: wireError.code as CollabErrorCode,
recoveryActions: Object.freeze([
...wireError.recoveryActions as CollabRecoveryAction[],
]),
safeContext,
}),
protocolVersion: COLLAB_PROTOCOL_VERSION,
requestId: source.requestId,
});
}
@@ -0,0 +1,199 @@
import { COLLAB_LIMITS, COLLAB_PROTOCOL_VERSION } from './CollabConstants';
import { CollabError } from './CollabError';
import type {
CollabGitOid,
CollabIsoTimestamp,
CollabMemberId,
CollabProjectId,
CollabRequestId,
CollabTicketId,
} from './types';
import {
hasUtf8ByteLengthAtMost,
isCollabGitOid,
isCollabMemberId,
isCollabOpaqueId,
isCollabProjectId,
} from './CollabValidation';
export const COLLAB_CLOUD_EVENT_KINDS = Object.freeze([
'membership.updated',
'request.updated',
'request.comment-added',
'ticket.updated',
'ticket.comment-added',
'main.updated',
] as const);
export type CollabCloudEventKind = typeof COLLAB_CLOUD_EVENT_KINDS[number];
export interface CollabCloudEventPayloadMap {
readonly 'main.updated': {
readonly mainOid: CollabGitOid;
readonly requestId: CollabRequestId;
};
readonly 'membership.updated': { readonly memberId: CollabMemberId };
readonly 'request.comment-added': { readonly requestId: CollabRequestId };
readonly 'request.updated': { readonly requestId: CollabRequestId };
readonly 'ticket.comment-added': { readonly ticketId: CollabTicketId };
readonly 'ticket.updated': { readonly ticketId: CollabTicketId };
}
export type CollabCloudProjectEvent = {
readonly [Kind in CollabCloudEventKind]: {
readonly kind: Kind;
readonly occurredAt: CollabIsoTimestamp;
readonly payload: CollabCloudEventPayloadMap[Kind];
readonly projectId: CollabProjectId;
readonly protocolVersion: typeof COLLAB_PROTOCOL_VERSION;
readonly sequence: number;
}
}[CollabCloudEventKind];
export interface CollabCloudSnapshotRequired {
readonly kind: 'snapshot.required';
readonly latestSequence: number;
}
export type CollabCloudProjectEventMessage =
| CollabCloudProjectEvent
| CollabCloudSnapshotRequired;
type UnknownRecord = Readonly<Record<string, unknown>>;
const EVENT_KIND_SET: ReadonlySet<string> = new Set(COLLAB_CLOUD_EVENT_KINDS);
function invalidPayload(field: string): CollabError {
return new CollabError({
code: 'protocol-payload-invalid',
safeContext: { field },
});
}
function record(value: unknown, field: string): UnknownRecord {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
throw invalidPayload(field);
}
return value as UnknownRecord;
}
function exactRecord(value: unknown, field: string, keys: readonly string[]): UnknownRecord {
const source = record(value, field);
const expected = new Set(keys);
if (
!keys.every(key => Object.hasOwn(source, key))
|| Object.keys(source).some(key => !expected.has(key))
) throw invalidPayload(field);
return source;
}
function stringField(
source: UnknownRecord,
field: string,
maximum: number,
validate: (value: string) => boolean,
): string {
const value = source[field];
if (
typeof value !== 'string'
|| value.length === 0
|| value.length > maximum
|| !validate(value)
) throw invalidPayload(field);
return value;
}
function nonNegativeInteger(source: UnknownRecord, field: string): number {
const value = source[field];
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) {
throw invalidPayload(field);
}
return value;
}
function positiveInteger(source: UnknownRecord, field: string): number {
const value = nonNegativeInteger(source, field);
if (value < 1) throw invalidPayload(field);
return value;
}
function timestamp(source: UnknownRecord, field: string): CollabIsoTimestamp {
const value = source[field];
if (
typeof value !== 'string'
|| value.length > 64
|| Number.isNaN(Date.parse(value))
|| new Date(value).toISOString() !== value
) throw invalidPayload(field);
return value;
}
function decodePayload(kind: CollabCloudEventKind, value: unknown): unknown {
switch (kind) {
case 'membership.updated': {
const source = exactRecord(value, 'payload', ['memberId']);
return { memberId: stringField(source, 'memberId', 64, isCollabMemberId) };
}
case 'request.updated':
case 'request.comment-added': {
const source = exactRecord(value, 'payload', ['requestId']);
return { requestId: stringField(source, 'requestId', 128, isCollabOpaqueId) };
}
case 'ticket.updated':
case 'ticket.comment-added': {
const source = exactRecord(value, 'payload', ['ticketId']);
return { ticketId: stringField(source, 'ticketId', 128, isCollabOpaqueId) };
}
case 'main.updated': {
const source = exactRecord(value, 'payload', ['mainOid', 'requestId']);
return {
mainOid: stringField(source, 'mainOid', 64, isCollabGitOid),
requestId: stringField(source, 'requestId', 128, isCollabOpaqueId),
};
}
}
}
export function decodeCollabCloudProjectEventMessage(
value: unknown,
): CollabCloudProjectEventMessage {
let serialized: string;
try {
serialized = JSON.stringify(value);
} catch {
throw invalidPayload('event');
}
if (!hasUtf8ByteLengthAtMost(serialized, COLLAB_LIMITS.maxJsonPayloadUtf8Bytes)) {
throw invalidPayload('event');
}
const candidate = record(value, 'event');
if (candidate.kind === 'snapshot.required') {
const source = exactRecord(candidate, 'snapshot.required', ['kind', 'latestSequence']);
return {
kind: 'snapshot.required',
latestSequence: nonNegativeInteger(source, 'latestSequence'),
};
}
const source = exactRecord(candidate, 'event', [
'kind',
'occurredAt',
'payload',
'projectId',
'protocolVersion',
'sequence',
]);
if (
source.protocolVersion !== COLLAB_PROTOCOL_VERSION
|| typeof source.kind !== 'string'
|| !EVENT_KIND_SET.has(source.kind)
) throw invalidPayload('event');
const kind = source.kind as CollabCloudEventKind;
return {
kind,
occurredAt: timestamp(source, 'occurredAt'),
payload: decodePayload(kind, source.payload),
projectId: stringField(source, 'projectId', 64, isCollabProjectId),
protocolVersion: COLLAB_PROTOCOL_VERSION,
sequence: positiveInteger(source, 'sequence'),
} as CollabCloudProjectEvent;
}
@@ -0,0 +1,385 @@
import {
COLLAB_LIMITS,
COLLAB_MAIN_REF,
} from './CollabConstants';
import { COLLAB_CLOUD_BINDING_LIMITS } from './CollabCloudBinding';
import { CollabError } from './CollabError';
import type { CollabDecodeResult } from './CollabProtocol';
import type {
CollabChangeRequest,
CollabGitOid,
CollabIsoTimestamp,
CollabMemberId,
CollabProjectId,
CollabRequestTicketRelation,
CollabRole,
CollabTicketSummary,
} from './types';
import { collabMemberRef } from './types';
import {
hasUtf8ByteLengthAtMost,
isCollabGitOid,
isCollabMemberId,
isCollabOpaqueId,
isCollabProjectId,
} from './CollabValidation';
export interface CollabCloudProjectSummary {
readonly createdAt: CollabIsoTimestamp;
readonly expectedMainOid: CollabGitOid;
readonly id: CollabProjectId;
readonly mainRef: typeof COLLAB_MAIN_REF;
readonly name: string;
}
export interface CollabCloudProjectMember {
readonly activatedAt: CollabIsoTimestamp;
readonly createdAt: CollabIsoTimestamp;
readonly displayName: string;
readonly id: CollabMemberId;
readonly personalRef: string;
readonly role: CollabRole;
readonly status: 'active';
}
export interface CollabCloudProjectSnapshot {
readonly currentMember: CollabCloudProjectMember;
readonly eventSequence: number;
readonly members: readonly CollabCloudProjectMember[];
readonly openRequests: readonly CollabChangeRequest[];
readonly openTicketCount: number;
readonly project: CollabCloudProjectSummary;
readonly ticketHighlights: readonly CollabTicketSummary[];
}
export interface GetCollabCloudProjectSnapshotRequest {
readonly projectId: CollabProjectId;
}
export interface CollabCloudProjectSnapshotCodec {
readonly decodeRequest: (
value: unknown,
) => CollabDecodeResult<GetCollabCloudProjectSnapshotRequest>;
readonly decodeResponse: (value: unknown) => CollabCloudProjectSnapshot;
}
type UnknownRecord = Readonly<Record<string, unknown>>;
function invalidPayload(field: string): CollabError {
return new CollabError({
code: 'protocol-payload-invalid',
safeContext: { field },
});
}
function record(value: unknown, field: string): UnknownRecord {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
throw invalidPayload(field);
}
return value as UnknownRecord;
}
function exactRecord(
value: unknown,
field: string,
required: readonly string[],
optional: readonly string[] = [],
): UnknownRecord {
const source = record(value, field);
const allowed = new Set([...required, ...optional]);
if (
!required.every(key => Object.hasOwn(source, key))
|| Object.keys(source).some(key => !allowed.has(key))
) throw invalidPayload(field);
return source;
}
function stringField(
source: UnknownRecord,
field: string,
maximum: number,
validate?: (value: string) => boolean,
allowEmpty = false,
): string {
const value = source[field];
if (
typeof value !== 'string'
|| (!allowEmpty && value.length === 0)
|| value.length > maximum
|| (validate && !validate(value))
) throw invalidPayload(field);
return value;
}
function textUtf8(source: UnknownRecord, field: string, maximum: number): string {
const value = source[field];
if (
typeof value !== 'string'
|| !hasUtf8ByteLengthAtMost(value, maximum)
) throw invalidPayload(field);
return value;
}
function timestamp(source: UnknownRecord, field: string): CollabIsoTimestamp {
const value = stringField(source, field, 64);
if (Number.isNaN(Date.parse(value)) || new Date(value).toISOString() !== value) {
throw invalidPayload(field);
}
return value;
}
function nonNegativeInteger(source: UnknownRecord, field: string, maximum?: number): number {
const value = source[field];
if (
typeof value !== 'number'
|| !Number.isSafeInteger(value)
|| value < 0
|| (maximum !== undefined && value > maximum)
) throw invalidPayload(field);
return value;
}
function positiveInteger(source: UnknownRecord, field: string): number {
const value = nonNegativeInteger(source, field);
if (value < 1) throw invalidPayload(field);
return value;
}
function decodeProject(value: unknown): CollabCloudProjectSummary {
const source = exactRecord(value, 'project', [
'createdAt',
'expectedMainOid',
'id',
'mainRef',
'name',
]);
if (source.mainRef !== COLLAB_MAIN_REF) throw invalidPayload('mainRef');
return {
createdAt: timestamp(source, 'createdAt'),
expectedMainOid: stringField(source, 'expectedMainOid', 64, isCollabGitOid),
id: stringField(source, 'id', 64, isCollabProjectId),
mainRef: COLLAB_MAIN_REF,
name: stringField(source, 'name', COLLAB_LIMITS.maxProjectNameUtf16),
};
}
function decodeMember(value: unknown): CollabCloudProjectMember {
const source = exactRecord(value, 'member', [
'activatedAt',
'createdAt',
'displayName',
'id',
'personalRef',
'role',
'status',
]);
const id = stringField(source, 'id', 64, isCollabMemberId);
if (
source.status !== 'active'
|| (source.role !== 'manager' && source.role !== 'member')
) throw invalidPayload('member');
const personalRef = stringField(source, 'personalRef', COLLAB_LIMITS.maxRepositoryPathUtf16);
if (personalRef !== collabMemberRef(id)) throw invalidPayload('personalRef');
return {
activatedAt: timestamp(source, 'activatedAt'),
createdAt: timestamp(source, 'createdAt'),
displayName: stringField(source, 'displayName', COLLAB_LIMITS.maxMemberDisplayNameUtf16),
id,
personalRef,
role: source.role,
status: 'active',
};
}
function decodeRequestTicketRelation(value: unknown): CollabRequestTicketRelation {
const source = exactRecord(value, 'request.ticketRelations', [
'commitOid',
'id',
'kind',
'state',
'ticketId',
'ticketNumber',
'ticketRevision',
'ticketTitle',
]);
if (
(source.kind !== 'references' && source.kind !== 'resolves')
|| (source.state !== 'pending' && source.state !== 'accepted')
) throw invalidPayload('request.ticketRelations');
return {
commitOid: stringField(source, 'commitOid', 64, isCollabGitOid),
id: stringField(source, 'id', 128, isCollabOpaqueId),
kind: source.kind,
state: source.state,
ticketId: stringField(source, 'ticketId', 128, isCollabOpaqueId),
ticketNumber: positiveInteger(source, 'ticketNumber'),
ticketRevision: positiveInteger(source, 'ticketRevision'),
ticketTitle: stringField(source, 'ticketTitle', COLLAB_LIMITS.maxTicketTitleUtf16),
};
}
function decodeOpenRequest(value: unknown): CollabChangeRequest {
const source = exactRecord(value, 'openRequest', [
'commentCount',
'createdAt',
'description',
'firstBaseOid',
'id',
'latestHeadOid',
'memberId',
'revision',
'status',
'ticketRelations',
'updatedAt',
]);
if (
source.status !== 'open'
|| !Array.isArray(source.ticketRelations)
|| source.ticketRelations.length > COLLAB_LIMITS.maxRequestTicketRelations
) throw invalidPayload('openRequest');
return {
commentCount: nonNegativeInteger(
source,
'commentCount',
COLLAB_LIMITS.maxRequestComments,
),
createdAt: timestamp(source, 'createdAt'),
description: textUtf8(source, 'description', COLLAB_LIMITS.maxRequestDescriptionBytes),
firstBaseOid: stringField(source, 'firstBaseOid', 64, isCollabGitOid),
id: stringField(source, 'id', 128, isCollabOpaqueId),
latestHeadOid: stringField(source, 'latestHeadOid', 64, isCollabGitOid),
memberId: stringField(source, 'memberId', 64, isCollabMemberId),
revision: nonNegativeInteger(source, 'revision'),
status: 'open',
ticketRelations: source.ticketRelations.map(decodeRequestTicketRelation),
updatedAt: timestamp(source, 'updatedAt'),
};
}
function decodeOpenTicket(value: unknown): CollabTicketSummary {
const source = exactRecord(value, 'ticketHighlight', [
'acceptedRelationCount',
'authorMemberId',
'commentCount',
'createdAt',
'id',
'number',
'revision',
'status',
'title',
'updatedAt',
]);
if (source.status !== 'open') throw invalidPayload('ticketHighlight');
return {
acceptedRelationCount: nonNegativeInteger(
source,
'acceptedRelationCount',
COLLAB_LIMITS.maxTicketAcceptedRelations,
),
authorMemberId: stringField(source, 'authorMemberId', 64, isCollabMemberId),
commentCount: nonNegativeInteger(
source,
'commentCount',
COLLAB_LIMITS.maxTicketComments,
),
createdAt: timestamp(source, 'createdAt'),
id: stringField(source, 'id', 128, isCollabOpaqueId),
number: positiveInteger(source, 'number'),
revision: nonNegativeInteger(source, 'revision'),
status: 'open',
title: stringField(source, 'title', COLLAB_LIMITS.maxTicketTitleUtf16),
updatedAt: timestamp(source, 'updatedAt'),
};
}
function assertSnapshotSize(value: unknown): void {
let serialized: string;
try {
serialized = JSON.stringify(value);
} catch {
throw invalidPayload('snapshot');
}
if (!hasUtf8ByteLengthAtMost(
serialized,
COLLAB_CLOUD_BINDING_LIMITS.maxCloudSnapshotUtf8Bytes,
)) throw invalidPayload('snapshot');
}
export function decodeCollabCloudProjectSnapshot(value: unknown): CollabCloudProjectSnapshot {
assertSnapshotSize(value);
const source = exactRecord(value, 'snapshot', [
'currentMember',
'eventSequence',
'members',
'openRequests',
'openTicketCount',
'project',
'ticketHighlights',
]);
if (
!Array.isArray(source.members)
|| source.members.length < 1
|| source.members.length > COLLAB_CLOUD_BINDING_LIMITS.maxCloudProjectMembers
|| !Array.isArray(source.openRequests)
|| source.openRequests.length > COLLAB_CLOUD_BINDING_LIMITS.maxCloudOpenRequests
|| !Array.isArray(source.ticketHighlights)
|| source.ticketHighlights.length > COLLAB_CLOUD_BINDING_LIMITS.maxCloudTicketHighlights
) throw invalidPayload('snapshotCollections');
const members = source.members.map(decodeMember);
if (members.some((item, index) => (
index > 0 && members[index - 1].id.localeCompare(item.id, 'en-US') >= 0
))) throw invalidPayload('members');
const currentMember = decodeMember(source.currentMember);
const matchingMember = members.find(member => member.id === currentMember.id);
if (!matchingMember || JSON.stringify(matchingMember) !== JSON.stringify(currentMember)) {
throw invalidPayload('currentMember');
}
const openRequests = source.openRequests.map(decodeOpenRequest);
if (
openRequests.some((item, index) => (
(index > 0 && openRequests[index - 1].id.localeCompare(item.id, 'en-US') >= 0)
|| !members.some(member => member.id === item.memberId)
))
) throw invalidPayload('openRequests');
const ticketHighlights = source.ticketHighlights.map(decodeOpenTicket);
if (ticketHighlights.some((item, index) => {
if (index === 0) return false;
const previous = ticketHighlights[index - 1];
return previous.updatedAt < item.updatedAt
|| (previous.updatedAt === item.updatedAt
&& previous.id.localeCompare(item.id, 'en-US') >= 0);
})) throw invalidPayload('ticketHighlights');
const openTicketCount = nonNegativeInteger(source, 'openTicketCount');
if (openTicketCount < ticketHighlights.length) throw invalidPayload('openTicketCount');
return {
currentMember,
eventSequence: nonNegativeInteger(source, 'eventSequence'),
members,
openRequests,
openTicketCount,
project: decodeProject(source.project),
ticketHighlights,
};
}
function decodeSnapshotRequest(
value: unknown,
): CollabDecodeResult<GetCollabCloudProjectSnapshotRequest> {
try {
const source = exactRecord(value, 'request', ['projectId']);
return {
status: 'ok',
value: { projectId: stringField(source, 'projectId', 64, isCollabProjectId) },
};
} catch (error) {
return {
status: 'invalid',
error: error instanceof CollabError ? error : invalidPayload('request'),
};
}
}
export const COLLAB_CLOUD_PROJECT_SNAPSHOT_CODEC = Object.freeze({
decodeRequest: decodeSnapshotRequest,
decodeResponse: decodeCollabCloudProjectSnapshot,
} as const satisfies CollabCloudProjectSnapshotCodec);
@@ -1,4 +1,4 @@
export const COLLAB_PROTOCOL_VERSION = 3 as const;
export const COLLAB_PROTOCOL_VERSION = 4 as const;
export const COLLAB_MAIN_REF = 'refs/heads/main' as const;
export const COLLAB_MEMBER_REF_PREFIX = 'refs/heads/members/' as const;
@@ -7,7 +7,9 @@ export const COLLAB_LIMITS = Object.freeze({
maxBlobBytes: 50 * 1024 * 1024,
maxChangedPaths: 2_000,
maxCommentBytes: 16 * 1024,
maxMemberDisplayNameUtf16: 200,
maxRequestDescriptionBytes: 16 * 1024,
maxProjectNameUtf16: 200,
maxTicketTitleUtf16: 200,
maxTicketBodyBytes: 32 * 1024,
maxTicketCommentBytes: 16 * 1024,
@@ -0,0 +1,879 @@
import {
COLLAB_LIMITS,
COLLAB_MAIN_REF,
COLLAB_PROTOCOL_VERSION,
type CollabProtocolVersion,
} from './CollabConstants';
import {
COLLAB_CLOUD_BINDING_LIMITS,
type DevelopmentBootstrapOperation,
} from './CollabCloudBinding';
import { CollabError } from './CollabError';
import type { CollabDecodeResult } from './CollabProtocol';
import type {
CollabGitOid,
CollabIsoTimestamp,
CollabMemberId,
CollabOperationId,
CollabProjectId,
CollabRole,
} from './types';
import { collabMemberRef } from './types';
import {
hasUtf8ByteLengthAtMost,
isCollabGitOid,
isCollabMemberId,
isCollabOpaqueId,
isCollabProjectId,
} from './CollabValidation';
export const DEVELOPMENT_BOOTSTRAP_MANIFEST_SCHEMA_VERSION = 1 as const;
export const DEVELOPMENT_BOOTSTRAP_ATTEMPT_STATES = Object.freeze([
'collecting',
'validating',
'ready',
'activating',
'rejected',
'cancelled',
'recovery-required',
'activated',
] as const);
export const DEVELOPMENT_BOOTSTRAP_ACTIVATION_PHASES = Object.freeze([
'publish-intent',
'repository-published',
'activated',
'completed',
] as const);
export const DEVELOPMENT_BOOTSTRAP_CANCELLATION_PHASES = Object.freeze([
'cancel-intent',
'cancelled',
'recovery-required',
] as const);
export const DEVELOPMENT_BOOTSTRAP_OPERATIONS = Object.freeze([
'beginDevelopmentBootstrap',
'submitDevelopmentBootstrapReport',
'getDevelopmentBootstrap',
'activateDevelopmentBootstrap',
'cancelDevelopmentBootstrap',
'putDevelopmentBootstrapGitBundle',
] as const satisfies readonly DevelopmentBootstrapOperation[]);
export type DevelopmentBootstrapAttemptState =
typeof DEVELOPMENT_BOOTSTRAP_ATTEMPT_STATES[number];
export type DevelopmentBootstrapActivationPhase =
typeof DEVELOPMENT_BOOTSTRAP_ACTIVATION_PHASES[number];
export type DevelopmentBootstrapCancellationPhase =
typeof DEVELOPMENT_BOOTSTRAP_CANCELLATION_PHASES[number];
export type DevelopmentBootstrapBundleState = 'missing' | 'uploaded' | 'validated';
export type DevelopmentBootstrapObjectFormat = 'sha1' | 'sha256';
export interface DevelopmentBootstrapComparisonMember {
readonly activatedAt: CollabIsoTimestamp;
readonly createdAt: CollabIsoTimestamp;
readonly displayName: string;
readonly memberId: CollabMemberId;
readonly personalRef: string;
readonly role: CollabRole;
readonly status: 'active';
}
export interface DevelopmentBootstrapComparison {
readonly mainOid: CollabGitOid;
readonly mainRef: typeof COLLAB_MAIN_REF;
readonly managerSetGeneration: number;
readonly members: readonly [
DevelopmentBootstrapComparisonMember,
DevelopmentBootstrapComparisonMember,
];
readonly projectCreatedAt: CollabIsoTimestamp;
readonly projectId: CollabProjectId;
readonly projectName: string;
readonly sourceCaFingerprint: string;
readonly sourceEventSequence: number;
readonly sourceHostMemberId: CollabMemberId;
}
export interface DevelopmentBootstrapSourceEligibility {
readonly liveInvitations: 0;
readonly nonActiveMemberships: 0;
readonly nonterminalAcceptOperations: 0;
readonly nonterminalHostTransfers: 0;
readonly nonterminalManagerOffers: 0;
readonly requestComments: 0;
readonly requests: 0;
readonly terminalProjectTransitions: 0;
readonly ticketComments: 0;
readonly ticketMentions: 0;
readonly ticketRelations: 0;
readonly tickets: 0;
}
export interface DevelopmentBootstrapGitRef {
readonly name: string;
readonly oid: CollabGitOid;
}
export interface DevelopmentBootstrapManifest {
readonly attemptId: string;
readonly comparison: DevelopmentBootstrapComparison;
readonly createdAt: CollabIsoTimestamp;
readonly git: {
readonly bundle: {
readonly byteCount: number;
readonly sha256: string;
};
readonly objectFormat: DevelopmentBootstrapObjectFormat;
readonly refs: readonly DevelopmentBootstrapGitRef[];
};
readonly manifestSchemaVersion: typeof DEVELOPMENT_BOOTSTRAP_MANIFEST_SCHEMA_VERSION;
readonly protocolVersion: CollabProtocolVersion;
readonly sourceEligibility: DevelopmentBootstrapSourceEligibility;
}
export interface DevelopmentBootstrapClientReadiness {
readonly cleanupSettled: true;
readonly collabGitChildrenDrained: true;
readonly conflictRecoverySettled: true;
readonly hostTransferSettled: true;
readonly joinSettled: true;
readonly leaveSettled: true;
readonly managerResponsibilitySettled: true;
readonly projectOperationQueueDrained: true;
readonly projectSetupSettled: true;
readonly projectWorkSessionClosed: true;
readonly publishSettled: true;
readonly reconciliationSettled: true;
readonly reconnectSettled: true;
readonly repositoryIdentityExact: true;
readonly retirementSettled: true;
}
export interface DevelopmentHostStopAttestation {
readonly attemptId: string;
readonly autoStartDisabled: true;
readonly fenceDurable: true;
readonly fenceId: string;
readonly hostStopped: true;
readonly manifestSha256: string;
readonly projectId: CollabProjectId;
readonly resourcesDrained: true;
readonly routeUnregistered: true;
readonly stoppedAt: CollabIsoTimestamp;
}
export interface DevelopmentBootstrapReport {
readonly attemptId: string;
readonly capturedAt: CollabIsoTimestamp;
readonly clientReadiness: DevelopmentBootstrapClientReadiness;
readonly comparison: DevelopmentBootstrapComparison;
readonly hostStopAttestation?: DevelopmentHostStopAttestation;
readonly observedPersonalRefOid: CollabGitOid;
readonly reporterMemberId: CollabMemberId;
}
export interface DevelopmentBootstrapActivationResult {
readonly activatedAt: CollabIsoTimestamp;
readonly activationOperationId: CollabOperationId;
readonly placementGeneration: number;
readonly projectId: CollabProjectId;
}
export interface DevelopmentBootstrapAttemptStatus {
readonly activationPhase?: DevelopmentBootstrapActivationPhase;
readonly activationResult?: DevelopmentBootstrapActivationResult;
readonly attemptId: string;
readonly bundleState: DevelopmentBootstrapBundleState;
readonly cancellationPhase?: DevelopmentBootstrapCancellationPhase;
readonly createdAt: CollabIsoTimestamp;
readonly expiresAt: CollabIsoTimestamp;
readonly manifestSha256: string;
readonly projectId: CollabProjectId;
readonly reporterMemberIds: readonly CollabMemberId[];
readonly state: DevelopmentBootstrapAttemptState;
}
export interface BeginDevelopmentBootstrapRequest {
readonly manifest: DevelopmentBootstrapManifest;
}
export interface SubmitDevelopmentBootstrapReportRequest {
readonly attemptId: string;
readonly report: DevelopmentBootstrapReport;
}
export interface GetDevelopmentBootstrapRequest {
readonly attemptId: string;
}
export interface ActivateDevelopmentBootstrapRequest {
readonly attemptId: string;
readonly manifestSha256: string;
}
export interface CancelDevelopmentBootstrapRequest {
readonly attemptId: string;
}
export interface PutDevelopmentBootstrapGitBundleRequest {
readonly attemptId: string;
readonly byteCount: number;
readonly contentEncoding: 'identity';
readonly contentType: 'application/x-git-bundle';
readonly sha256: string;
}
export interface DevelopmentBootstrapOperationMap {
readonly activateDevelopmentBootstrap: {
readonly request: ActivateDevelopmentBootstrapRequest;
readonly response: DevelopmentBootstrapAttemptStatus;
};
readonly beginDevelopmentBootstrap: {
readonly request: BeginDevelopmentBootstrapRequest;
readonly response: DevelopmentBootstrapAttemptStatus;
};
readonly cancelDevelopmentBootstrap: {
readonly request: CancelDevelopmentBootstrapRequest;
readonly response: DevelopmentBootstrapAttemptStatus;
};
readonly getDevelopmentBootstrap: {
readonly request: GetDevelopmentBootstrapRequest;
readonly response: DevelopmentBootstrapAttemptStatus;
};
readonly putDevelopmentBootstrapGitBundle: {
readonly request: PutDevelopmentBootstrapGitBundleRequest;
readonly response: DevelopmentBootstrapAttemptStatus;
};
readonly submitDevelopmentBootstrapReport: {
readonly request: SubmitDevelopmentBootstrapReportRequest;
readonly response: DevelopmentBootstrapAttemptStatus;
};
}
export interface DevelopmentBootstrapOperationCodec<Request, Response> {
readonly decodeRequest: (value: unknown) => CollabDecodeResult<Request>;
readonly decodeResponse: (value: unknown) => Response;
}
type UnknownRecord = Readonly<Record<string, unknown>>;
type DevelopmentBootstrapCodecMap = {
readonly [Operation in DevelopmentBootstrapOperation]:
DevelopmentBootstrapOperationCodec<
DevelopmentBootstrapOperationMap[Operation]['request'],
DevelopmentBootstrapOperationMap[Operation]['response']
>;
};
const ATTEMPT_STATE_SET: ReadonlySet<string> = new Set(DEVELOPMENT_BOOTSTRAP_ATTEMPT_STATES);
const ACTIVATION_PHASE_SET: ReadonlySet<string> = new Set(DEVELOPMENT_BOOTSTRAP_ACTIVATION_PHASES);
const CANCELLATION_PHASE_SET: ReadonlySet<string> = new Set(DEVELOPMENT_BOOTSTRAP_CANCELLATION_PHASES);
const SHA256_PATTERN = /^[0-9a-f]{64}$/u;
function invalidPayload(field: string): CollabError {
return new CollabError({
code: 'protocol-payload-invalid',
safeContext: { field },
});
}
function record(value: unknown, field: string): UnknownRecord {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
throw invalidPayload(field);
}
return value as UnknownRecord;
}
function exactRecord(value: unknown, field: string, keys: readonly string[]): UnknownRecord {
const source = record(value, field);
const expected = new Set(keys);
if (
!keys.every(key => Object.hasOwn(source, key))
|| Object.keys(source).some(key => !expected.has(key))
) throw invalidPayload(field);
return source;
}
function exactRecordWithOptional(
value: unknown,
field: string,
required: readonly string[],
optional: readonly string[],
): UnknownRecord {
const source = record(value, field);
const allowed = new Set([...required, ...optional]);
if (
!required.every(key => Object.hasOwn(source, key))
|| Object.keys(source).some(key => !allowed.has(key))
) throw invalidPayload(field);
return source;
}
function stringField(
source: UnknownRecord,
field: string,
maximum: number,
validate?: (value: string) => boolean,
): string {
const value = source[field];
if (
typeof value !== 'string'
|| value.length === 0
|| value.length > maximum
|| (validate && !validate(value))
) throw invalidPayload(field);
return value;
}
function timestamp(source: UnknownRecord, field: string): CollabIsoTimestamp {
const value = stringField(source, field, 64);
if (Number.isNaN(Date.parse(value)) || new Date(value).toISOString() !== value) {
throw invalidPayload(field);
}
return value;
}
function nonNegativeInteger(source: UnknownRecord, field: string): number {
const value = source[field];
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) {
throw invalidPayload(field);
}
return value;
}
function positiveInteger(source: UnknownRecord, field: string, maximum?: number): number {
const value = nonNegativeInteger(source, field);
if (value < 1 || (maximum !== undefined && value > maximum)) {
throw invalidPayload(field);
}
return value;
}
function exactTrue(source: UnknownRecord, field: string): true {
if (source[field] !== true) throw invalidPayload(field);
return true;
}
function sha256(source: UnknownRecord, field: string): string {
return stringField(source, field, 64, value => SHA256_PATTERN.test(value));
}
function assertSerializedLimit(value: unknown, maximum: number, field: string): void {
let serialized: string;
try {
serialized = JSON.stringify(value);
} catch {
throw invalidPayload(field);
}
if (!hasUtf8ByteLengthAtMost(serialized, maximum)) throw invalidPayload(field);
}
function decodeComparisonMember(value: unknown): DevelopmentBootstrapComparisonMember {
const source = exactRecord(value, 'comparison.members', [
'activatedAt',
'createdAt',
'displayName',
'memberId',
'personalRef',
'role',
'status',
]);
const memberId = stringField(source, 'memberId', 64, isCollabMemberId);
const role = source.role;
if (role !== 'manager' && role !== 'member') throw invalidPayload('role');
if (source.status !== 'active') throw invalidPayload('status');
const personalRef = stringField(source, 'personalRef', COLLAB_LIMITS.maxRepositoryPathUtf16);
if (personalRef !== collabMemberRef(memberId)) throw invalidPayload('personalRef');
return {
activatedAt: timestamp(source, 'activatedAt'),
createdAt: timestamp(source, 'createdAt'),
displayName: stringField(source, 'displayName', COLLAB_LIMITS.maxMemberDisplayNameUtf16),
memberId,
personalRef,
role,
status: 'active',
};
}
function decodeComparison(value: unknown): DevelopmentBootstrapComparison {
const source = exactRecord(value, 'comparison', [
'mainOid',
'mainRef',
'managerSetGeneration',
'members',
'projectCreatedAt',
'projectId',
'projectName',
'sourceCaFingerprint',
'sourceEventSequence',
'sourceHostMemberId',
]);
if (!Array.isArray(source.members) || source.members.length !== 2) {
throw invalidPayload('members');
}
const members = source.members.map(decodeComparisonMember) as [
DevelopmentBootstrapComparisonMember,
DevelopmentBootstrapComparisonMember,
];
if (
members[0].memberId.localeCompare(members[1].memberId, 'en-US') >= 0
|| !members.some(member => member.role === 'manager')
) throw invalidPayload('members');
const sourceHostMemberId = stringField(
source,
'sourceHostMemberId',
64,
isCollabMemberId,
);
if (!members.some(member => member.memberId === sourceHostMemberId)) {
throw invalidPayload('sourceHostMemberId');
}
if (source.mainRef !== COLLAB_MAIN_REF) throw invalidPayload('mainRef');
return {
mainOid: stringField(source, 'mainOid', 64, isCollabGitOid),
mainRef: COLLAB_MAIN_REF,
managerSetGeneration: nonNegativeInteger(source, 'managerSetGeneration'),
members,
projectCreatedAt: timestamp(source, 'projectCreatedAt'),
projectId: stringField(source, 'projectId', 64, isCollabProjectId),
projectName: stringField(source, 'projectName', COLLAB_LIMITS.maxProjectNameUtf16),
sourceCaFingerprint: stringField(
source,
'sourceCaFingerprint',
64,
candidate => SHA256_PATTERN.test(candidate),
),
sourceEventSequence: nonNegativeInteger(source, 'sourceEventSequence'),
sourceHostMemberId,
};
}
function decodeSourceEligibility(value: unknown): DevelopmentBootstrapSourceEligibility {
const keys = [
'liveInvitations',
'nonActiveMemberships',
'nonterminalAcceptOperations',
'nonterminalHostTransfers',
'nonterminalManagerOffers',
'requestComments',
'requests',
'terminalProjectTransitions',
'ticketComments',
'ticketMentions',
'ticketRelations',
'tickets',
] as const;
const source = exactRecord(value, 'sourceEligibility', keys);
if (keys.some(key => source[key] !== 0)) throw invalidPayload('sourceEligibility');
return {
liveInvitations: 0,
nonActiveMemberships: 0,
nonterminalAcceptOperations: 0,
nonterminalHostTransfers: 0,
nonterminalManagerOffers: 0,
requestComments: 0,
requests: 0,
terminalProjectTransitions: 0,
ticketComments: 0,
ticketMentions: 0,
ticketRelations: 0,
tickets: 0,
};
}
function decodeGit(
value: unknown,
comparison: DevelopmentBootstrapComparison,
): DevelopmentBootstrapManifest['git'] {
const source = exactRecord(value, 'git', ['bundle', 'objectFormat', 'refs']);
if (source.objectFormat !== 'sha1' && source.objectFormat !== 'sha256') {
throw invalidPayload('objectFormat');
}
const objectFormat = source.objectFormat;
const bundleSource = exactRecord(source.bundle, 'bundle', ['byteCount', 'sha256']);
if (!Array.isArray(source.refs) || source.refs.length !== 3) throw invalidPayload('refs');
const refs = source.refs.map((item): DevelopmentBootstrapGitRef => {
const ref = exactRecord(item, 'refs', ['name', 'oid']);
const oid = stringField(ref, 'oid', 64, isCollabGitOid);
if (oid.length !== (objectFormat === 'sha1' ? 40 : 64)) throw invalidPayload('oid');
return {
name: stringField(ref, 'name', COLLAB_LIMITS.maxRepositoryPathUtf16),
oid,
};
});
if (
refs.some((item, index) => (
index > 0 && refs[index - 1].name.localeCompare(item.name, 'en-US') >= 0
))
) throw invalidPayload('refs');
const expectedNames = [
COLLAB_MAIN_REF,
...comparison.members.map(member => member.personalRef),
].sort((left, right) => left.localeCompare(right, 'en-US'));
if (
refs.some((item, index) => item.name !== expectedNames[index])
|| refs.find(item => item.name === COLLAB_MAIN_REF)?.oid !== comparison.mainOid
) throw invalidPayload('refs');
return {
bundle: {
byteCount: positiveInteger(
bundleSource,
'byteCount',
COLLAB_CLOUD_BINDING_LIMITS.maxDevelopmentBootstrapGitBundleBytes,
),
sha256: sha256(bundleSource, 'sha256'),
},
objectFormat,
refs,
};
}
export function decodeDevelopmentBootstrapManifest(
value: unknown,
): DevelopmentBootstrapManifest {
assertSerializedLimit(
value,
COLLAB_CLOUD_BINDING_LIMITS.maxDevelopmentBootstrapManifestUtf8Bytes,
'manifest',
);
const source = exactRecord(value, 'manifest', [
'attemptId',
'comparison',
'createdAt',
'git',
'manifestSchemaVersion',
'protocolVersion',
'sourceEligibility',
]);
if (
source.manifestSchemaVersion !== DEVELOPMENT_BOOTSTRAP_MANIFEST_SCHEMA_VERSION
|| source.protocolVersion !== COLLAB_PROTOCOL_VERSION
) throw invalidPayload('manifestVersion');
const comparison = decodeComparison(source.comparison);
return {
attemptId: stringField(source, 'attemptId', 128, isCollabOpaqueId),
comparison,
createdAt: timestamp(source, 'createdAt'),
git: decodeGit(source.git, comparison),
manifestSchemaVersion: DEVELOPMENT_BOOTSTRAP_MANIFEST_SCHEMA_VERSION,
protocolVersion: COLLAB_PROTOCOL_VERSION,
sourceEligibility: decodeSourceEligibility(source.sourceEligibility),
};
}
export function encodeDevelopmentBootstrapManifestCanonicalJson(
value: DevelopmentBootstrapManifest,
): string {
return JSON.stringify(decodeDevelopmentBootstrapManifest(value));
}
function decodeClientReadiness(value: unknown): DevelopmentBootstrapClientReadiness {
const keys = [
'cleanupSettled',
'collabGitChildrenDrained',
'conflictRecoverySettled',
'hostTransferSettled',
'joinSettled',
'leaveSettled',
'managerResponsibilitySettled',
'projectOperationQueueDrained',
'projectSetupSettled',
'projectWorkSessionClosed',
'publishSettled',
'reconciliationSettled',
'reconnectSettled',
'repositoryIdentityExact',
'retirementSettled',
] as const;
const source = exactRecord(value, 'clientReadiness', keys);
const result = Object.fromEntries(keys.map(key => [key, exactTrue(source, key)]));
return result as unknown as DevelopmentBootstrapClientReadiness;
}
function decodeHostStopAttestation(
value: unknown,
attemptId: string,
projectId: string,
): DevelopmentHostStopAttestation {
const source = exactRecord(value, 'hostStopAttestation', [
'attemptId',
'autoStartDisabled',
'fenceDurable',
'fenceId',
'hostStopped',
'manifestSha256',
'projectId',
'resourcesDrained',
'routeUnregistered',
'stoppedAt',
]);
const decodedAttemptId = stringField(source, 'attemptId', 128, isCollabOpaqueId);
const decodedProjectId = stringField(source, 'projectId', 64, isCollabProjectId);
if (decodedAttemptId !== attemptId || decodedProjectId !== projectId) {
throw invalidPayload('hostStopAttestation');
}
return {
attemptId: decodedAttemptId,
autoStartDisabled: exactTrue(source, 'autoStartDisabled'),
fenceDurable: exactTrue(source, 'fenceDurable'),
fenceId: stringField(source, 'fenceId', 128, isCollabOpaqueId),
hostStopped: exactTrue(source, 'hostStopped'),
manifestSha256: sha256(source, 'manifestSha256'),
projectId: decodedProjectId,
resourcesDrained: exactTrue(source, 'resourcesDrained'),
routeUnregistered: exactTrue(source, 'routeUnregistered'),
stoppedAt: timestamp(source, 'stoppedAt'),
};
}
export function decodeDevelopmentBootstrapReport(value: unknown): DevelopmentBootstrapReport {
assertSerializedLimit(
value,
COLLAB_CLOUD_BINDING_LIMITS.maxDevelopmentBootstrapReportUtf8Bytes,
'report',
);
const source = exactRecordWithOptional(value, 'report', [
'attemptId',
'capturedAt',
'clientReadiness',
'comparison',
'observedPersonalRefOid',
'reporterMemberId',
], ['hostStopAttestation']);
const attemptId = stringField(source, 'attemptId', 128, isCollabOpaqueId);
const comparison = decodeComparison(source.comparison);
const reporterMemberId = stringField(source, 'reporterMemberId', 64, isCollabMemberId);
if (!comparison.members.some(member => member.memberId === reporterMemberId)) {
throw invalidPayload('reporterMemberId');
}
const isHost = reporterMemberId === comparison.sourceHostMemberId;
if (isHost !== Object.hasOwn(source, 'hostStopAttestation')) {
throw invalidPayload('hostStopAttestation');
}
const hostStopAttestation = isHost
? decodeHostStopAttestation(
source.hostStopAttestation,
attemptId,
comparison.projectId,
)
: undefined;
return {
attemptId,
capturedAt: timestamp(source, 'capturedAt'),
clientReadiness: decodeClientReadiness(source.clientReadiness),
comparison,
...(hostStopAttestation ? { hostStopAttestation } : {}),
observedPersonalRefOid: stringField(
source,
'observedPersonalRefOid',
64,
isCollabGitOid,
),
reporterMemberId,
};
}
function decodeActivationResult(value: unknown): DevelopmentBootstrapActivationResult {
const source = exactRecord(value, 'activationResult', [
'activatedAt',
'activationOperationId',
'placementGeneration',
'projectId',
]);
return {
activatedAt: timestamp(source, 'activatedAt'),
activationOperationId: stringField(
source,
'activationOperationId',
128,
isCollabOpaqueId,
),
placementGeneration: positiveInteger(source, 'placementGeneration'),
projectId: stringField(source, 'projectId', 64, isCollabProjectId),
};
}
function decodeAttemptStatus(value: unknown): DevelopmentBootstrapAttemptStatus {
const source = exactRecordWithOptional(value, 'attempt', [
'attemptId',
'bundleState',
'createdAt',
'expiresAt',
'manifestSha256',
'projectId',
'reporterMemberIds',
'state',
], ['activationPhase', 'activationResult', 'cancellationPhase']);
if (typeof source.state !== 'string' || !ATTEMPT_STATE_SET.has(source.state)) {
throw invalidPayload('state');
}
if (
source.bundleState !== 'missing'
&& source.bundleState !== 'uploaded'
&& source.bundleState !== 'validated'
) throw invalidPayload('bundleState');
if (!Array.isArray(source.reporterMemberIds) || source.reporterMemberIds.length > 2) {
throw invalidPayload('reporterMemberIds');
}
const reporterMemberIds = source.reporterMemberIds.map((item) => {
if (!isCollabMemberId(item)) throw invalidPayload('reporterMemberIds');
return item;
});
if (reporterMemberIds.some((item, index) => (
index > 0 && reporterMemberIds[index - 1].localeCompare(item, 'en-US') >= 0
))) throw invalidPayload('reporterMemberIds');
const activationPhase = source.activationPhase;
const cancellationPhase = source.cancellationPhase;
if (
activationPhase !== undefined
&& (typeof activationPhase !== 'string' || !ACTIVATION_PHASE_SET.has(activationPhase))
) throw invalidPayload('activationPhase');
if (
cancellationPhase !== undefined
&& (typeof cancellationPhase !== 'string' || !CANCELLATION_PHASE_SET.has(cancellationPhase))
) throw invalidPayload('cancellationPhase');
const activationResult = source.activationResult === undefined
? undefined
: decodeActivationResult(source.activationResult);
const projectId = stringField(source, 'projectId', 64, isCollabProjectId);
if (
source.state === 'activated'
&& (
(activationPhase !== 'activated' && activationPhase !== 'completed')
|| activationResult === undefined
)
) throw invalidPayload('activationResult');
if (
source.state !== 'activated'
&& activationResult !== undefined
) throw invalidPayload('activationResult');
if (activationPhase !== undefined && cancellationPhase !== undefined) {
throw invalidPayload('attemptPhase');
}
if (activationResult !== undefined && activationResult.projectId !== projectId) {
throw invalidPayload('activationResult');
}
return {
...(activationPhase
? { activationPhase: activationPhase as DevelopmentBootstrapActivationPhase }
: {}),
...(activationResult ? { activationResult } : {}),
attemptId: stringField(source, 'attemptId', 128, isCollabOpaqueId),
bundleState: source.bundleState,
...(cancellationPhase
? { cancellationPhase: cancellationPhase as DevelopmentBootstrapCancellationPhase }
: {}),
createdAt: timestamp(source, 'createdAt'),
expiresAt: timestamp(source, 'expiresAt'),
manifestSha256: sha256(source, 'manifestSha256'),
projectId,
reporterMemberIds,
state: source.state as DevelopmentBootstrapAttemptState,
};
}
function decodeAttemptOnlyRequest(value: unknown): GetDevelopmentBootstrapRequest {
const source = exactRecord(value, 'request', ['attemptId']);
return { attemptId: stringField(source, 'attemptId', 128, isCollabOpaqueId) };
}
function decodeOperationRequest(
operation: DevelopmentBootstrapOperation,
value: unknown,
): DevelopmentBootstrapOperationMap[DevelopmentBootstrapOperation]['request'] {
switch (operation) {
case 'beginDevelopmentBootstrap': {
const source = exactRecord(value, 'request', ['manifest']);
return { manifest: decodeDevelopmentBootstrapManifest(source.manifest) };
}
case 'submitDevelopmentBootstrapReport': {
const source = exactRecord(value, 'request', ['attemptId', 'report']);
const attemptId = stringField(source, 'attemptId', 128, isCollabOpaqueId);
const report = decodeDevelopmentBootstrapReport(source.report);
if (report.attemptId !== attemptId) throw invalidPayload('attemptId');
return { attemptId, report };
}
case 'getDevelopmentBootstrap':
case 'cancelDevelopmentBootstrap':
return decodeAttemptOnlyRequest(value);
case 'activateDevelopmentBootstrap': {
const source = exactRecord(value, 'request', ['attemptId', 'manifestSha256']);
return {
attemptId: stringField(source, 'attemptId', 128, isCollabOpaqueId),
manifestSha256: sha256(source, 'manifestSha256'),
};
}
case 'putDevelopmentBootstrapGitBundle': {
const source = exactRecord(value, 'request', [
'attemptId',
'byteCount',
'contentEncoding',
'contentType',
'sha256',
]);
if (
source.contentEncoding !== 'identity'
|| source.contentType !== 'application/x-git-bundle'
) throw invalidPayload('contentType');
return {
attemptId: stringField(source, 'attemptId', 128, isCollabOpaqueId),
byteCount: positiveInteger(
source,
'byteCount',
COLLAB_CLOUD_BINDING_LIMITS.maxDevelopmentBootstrapGitBundleBytes,
),
contentEncoding: 'identity',
contentType: 'application/x-git-bundle',
sha256: sha256(source, 'sha256'),
};
}
}
}
function decodeRequestResult(
operation: DevelopmentBootstrapOperation,
value: unknown,
): CollabDecodeResult<DevelopmentBootstrapOperationMap[DevelopmentBootstrapOperation]['request']> {
try {
return { status: 'ok', value: decodeOperationRequest(operation, value) };
} catch (error) {
return {
status: 'invalid',
error: error instanceof CollabError ? error : invalidPayload('request'),
};
}
}
function codec<Operation extends DevelopmentBootstrapOperation>(
operation: Operation,
): DevelopmentBootstrapCodecMap[Operation] {
return Object.freeze({
decodeRequest: (value: unknown) => decodeRequestResult(operation, value) as
CollabDecodeResult<DevelopmentBootstrapOperationMap[Operation]['request']>,
decodeResponse: decodeAttemptStatus,
}) as DevelopmentBootstrapCodecMap[Operation];
}
export const DEVELOPMENT_BOOTSTRAP_OPERATION_CODECS = Object.freeze({
beginDevelopmentBootstrap: codec('beginDevelopmentBootstrap'),
submitDevelopmentBootstrapReport: codec('submitDevelopmentBootstrapReport'),
getDevelopmentBootstrap: codec('getDevelopmentBootstrap'),
activateDevelopmentBootstrap: codec('activateDevelopmentBootstrap'),
cancelDevelopmentBootstrap: codec('cancelDevelopmentBootstrap'),
putDevelopmentBootstrapGitBundle: codec('putDevelopmentBootstrapGitBundle'),
} as const satisfies DevelopmentBootstrapCodecMap);
export function developmentBootstrapOperationCodec<
Operation extends DevelopmentBootstrapOperation,
>(operation: Operation): DevelopmentBootstrapCodecMap[Operation] {
const selected = DEVELOPMENT_BOOTSTRAP_OPERATION_CODECS[operation];
if (!selected) {
throw new CollabError({
code: 'operation-failed',
safeContext: { reason: 'bootstrap-operation-codec-missing' },
});
}
return selected;
}
+96
View File
@@ -6,6 +6,102 @@ export {
} from './CollabConstants';
export type { CollabProtocolVersion } from './CollabConstants';
export {
COLLAB_CLOUD_BINDING_LIMITS,
COLLAB_CLOUD_BINDING_VERSION,
COLLAB_CLOUD_CAPABILITIES,
COLLAB_CLOUD_CAPABILITY_DOCUMENT_SCHEMA_VERSION,
COLLAB_CLOUD_JSON_OPERATIONS,
collabCloudCapabilityDocument,
collabCloudCapabilitySupported,
collabCloudCapabilitiesRoute,
collabCloudErrorEnvelope,
collabCloudGitRoute,
collabCloudProjectEventsRoute,
collabCloudProjectOperationRoute,
collabCloudSuccessEnvelope,
collabDevelopmentBootstrapRoute,
decodeCollabCloudCapabilityDocument,
decodeCollabCloudErrorEnvelope,
decodeCollabCloudSuccessEnvelope,
matchCollabCloudRoute,
} from './CollabCloudBinding';
export type {
CollabCloudCapability,
CollabCloudCapabilityDocument,
CollabCloudCapabilityLimits,
CollabCloudErrorEnvelope,
CollabCloudGitService,
CollabCloudJsonOperation,
CollabCloudRoute,
CollabCloudRouteMatch,
CollabCloudSuccessEnvelope,
CollabCloudWireError,
DevelopmentBootstrapOperation,
} from './CollabCloudBinding';
export {
DEVELOPMENT_BOOTSTRAP_ACTIVATION_PHASES,
DEVELOPMENT_BOOTSTRAP_ATTEMPT_STATES,
DEVELOPMENT_BOOTSTRAP_CANCELLATION_PHASES,
DEVELOPMENT_BOOTSTRAP_MANIFEST_SCHEMA_VERSION,
DEVELOPMENT_BOOTSTRAP_OPERATIONS,
DEVELOPMENT_BOOTSTRAP_OPERATION_CODECS,
decodeDevelopmentBootstrapManifest,
decodeDevelopmentBootstrapReport,
developmentBootstrapOperationCodec,
encodeDevelopmentBootstrapManifestCanonicalJson,
} from './DevelopmentBootstrap';
export type {
ActivateDevelopmentBootstrapRequest,
BeginDevelopmentBootstrapRequest,
CancelDevelopmentBootstrapRequest,
DevelopmentBootstrapActivationPhase,
DevelopmentBootstrapActivationResult,
DevelopmentBootstrapAttemptState,
DevelopmentBootstrapAttemptStatus,
DevelopmentBootstrapBundleState,
DevelopmentBootstrapCancellationPhase,
DevelopmentBootstrapClientReadiness,
DevelopmentBootstrapComparison,
DevelopmentBootstrapComparisonMember,
DevelopmentBootstrapGitRef,
DevelopmentBootstrapManifest,
DevelopmentBootstrapObjectFormat,
DevelopmentBootstrapOperationCodec,
DevelopmentBootstrapOperationMap,
DevelopmentBootstrapReport,
DevelopmentBootstrapSourceEligibility,
DevelopmentHostStopAttestation,
GetDevelopmentBootstrapRequest,
PutDevelopmentBootstrapGitBundleRequest,
SubmitDevelopmentBootstrapReportRequest,
} from './DevelopmentBootstrap';
export {
COLLAB_CLOUD_PROJECT_SNAPSHOT_CODEC,
decodeCollabCloudProjectSnapshot,
} from './CollabCloudProjectSnapshot';
export type {
CollabCloudProjectMember,
CollabCloudProjectSnapshot,
CollabCloudProjectSnapshotCodec,
CollabCloudProjectSummary,
GetCollabCloudProjectSnapshotRequest,
} from './CollabCloudProjectSnapshot';
export {
COLLAB_CLOUD_EVENT_KINDS,
decodeCollabCloudProjectEventMessage,
} from './CollabCloudProjectEvent';
export type {
CollabCloudEventKind,
CollabCloudEventPayloadMap,
CollabCloudProjectEvent,
CollabCloudProjectEventMessage,
CollabCloudSnapshotRequired,
} from './CollabCloudProjectEvent';
export {
COLLAB_CONTROL_OPERATION_CODECS,
collabControlOperationCodec,
@@ -0,0 +1,179 @@
import {
COLLAB_CLOUD_BINDING_LIMITS,
COLLAB_CLOUD_BINDING_VERSION,
COLLAB_CLOUD_CAPABILITIES,
COLLAB_CLOUD_CAPABILITY_DOCUMENT_SCHEMA_VERSION,
COLLAB_CLOUD_JSON_OPERATIONS,
COLLAB_CONTROL_OPERATION_CODECS,
COLLAB_PROTOCOL_VERSION,
CollabError,
collabCloudCapabilitySupported,
collabCloudCapabilitiesRoute,
collabCloudCapabilityDocument,
collabCloudErrorEnvelope,
collabCloudGitRoute,
collabCloudProjectEventsRoute,
collabCloudProjectOperationRoute,
collabCloudSuccessEnvelope,
collabDevelopmentBootstrapRoute,
decodeCollabCloudCapabilityDocument,
decodeCollabCloudErrorEnvelope,
decodeCollabCloudSuccessEnvelope,
matchCollabCloudRoute,
} from '../src/index';
const PROJECT_ID = 'project_1';
function capabilityDocument(overrides: Record<string, unknown> = {}) {
return {
bindingVersions: [1],
capabilities: [...COLLAB_CLOUD_CAPABILITIES],
limits: {
maxDevelopmentBootstrapGitBundleBytes: 1024 * 1024 * 1024,
maxDevelopmentBootstrapManifestUtf8Bytes: 64 * 1024,
maxDevelopmentBootstrapReportUtf8Bytes: 64 * 1024,
maxEventReplay: 500,
maxGitReceivePackBytes: 256 * 1024 * 1024,
maxJsonPayloadUtf8Bytes: 512 * 1024,
maxRepositoryBytes: 1024 * 1024 * 1024,
},
protocolVersions: [4],
schemaVersion: 1,
...overrides,
};
}
describe('Cloud binding v1', () => {
it('keeps package, canonical wire, Cloud binding, and LAN binding independent', () => {
expect(COLLAB_PROTOCOL_VERSION).toBe(4);
expect(COLLAB_CLOUD_BINDING_VERSION).toBe(1);
expect(COLLAB_CLOUD_CAPABILITY_DOCUMENT_SCHEMA_VERSION).toBe(1);
expect(Object.keys(COLLAB_CONTROL_OPERATION_CODECS)).toHaveLength(15);
expect(COLLAB_CLOUD_JSON_OPERATIONS).toEqual([
'getProjectSnapshot',
...Object.keys(COLLAB_CONTROL_OPERATION_CODECS),
]);
});
it('constructs and matches the exact package-owned route catalog', () => {
const routes = [
collabCloudCapabilitiesRoute(),
collabCloudProjectOperationRoute(PROJECT_ID, 'getProjectSnapshot'),
collabCloudProjectEventsRoute(PROJECT_ID, 42),
collabCloudGitRoute(PROJECT_ID, 'info-refs', 'git-upload-pack'),
collabCloudGitRoute(PROJECT_ID, 'git-upload-pack'),
collabCloudGitRoute(PROJECT_ID, 'git-receive-pack'),
collabDevelopmentBootstrapRoute('beginDevelopmentBootstrap'),
collabDevelopmentBootstrapRoute('submitDevelopmentBootstrapReport', 'attempt_1'),
collabDevelopmentBootstrapRoute('getDevelopmentBootstrap', 'attempt_1'),
collabDevelopmentBootstrapRoute('activateDevelopmentBootstrap', 'attempt_1'),
collabDevelopmentBootstrapRoute('cancelDevelopmentBootstrap', 'attempt_1'),
collabDevelopmentBootstrapRoute('putDevelopmentBootstrapGitBundle', 'attempt_1'),
];
expect(routes.map(route => `${route.method} ${route.target}`)).toEqual([
'GET /collab/capabilities',
'POST /v1/projects/project_1/operations/getProjectSnapshot',
'GET /v1/projects/project_1/events?afterSequence=42',
'GET /v1/projects/project_1/repository.git/info/refs?service=git-upload-pack',
'POST /v1/projects/project_1/repository.git/git-upload-pack',
'POST /v1/projects/project_1/repository.git/git-receive-pack',
'POST /v1/development/bootstrap/attempts',
'POST /v1/development/bootstrap/attempts/attempt_1/reports',
'GET /v1/development/bootstrap/attempts/attempt_1',
'POST /v1/development/bootstrap/attempts/attempt_1/activate',
'POST /v1/development/bootstrap/attempts/attempt_1/cancel',
'PUT /v1/development/bootstrap/attempts/attempt_1/git-bundle',
]);
for (const route of routes) {
expect(matchCollabCloudRoute(route.method, route.target)).toEqual(route.match);
}
});
it('rejects unknown operations, non-canonical targets, and malformed identifiers', () => {
expect(matchCollabCloudRoute(
'POST',
'/v1/projects/project_1/operations/futureOperation',
)).toBeNull();
expect(matchCollabCloudRoute(
'GET',
'/v1/projects/project_1/events?afterSequence=1&afterSequence=2',
)).toBeNull();
expect(matchCollabCloudRoute(
'GET',
'/v1/projects/project_1/repository.git/info/refs?service=git-archive',
)).toBeNull();
expect(matchCollabCloudRoute(
'POST',
'/v1//projects/project_1/operations/getProjectSnapshot',
)).toBeNull();
expect(matchCollabCloudRoute(
'POST',
'/v1/projects/project_1/operations/getProjectSnapshot/',
)).toBeNull();
expect(matchCollabCloudRoute('GET', '/collab/capabilities?')).toBeNull();
expect(() => collabCloudProjectOperationRoute('../escape', 'getProjectSnapshot'))
.toThrow('Invalid Collab Cloud route input');
});
it('decodes an exact compatible capability document and tolerates unknown tokens', () => {
const capabilities = [
'accept',
'development-bootstrap',
'future-read-plane',
'git-receive-pack-personal-ref',
'git-upload-pack',
'project-events',
'project-snapshot',
'requests',
'tickets',
];
const decoded = decodeCollabCloudCapabilityDocument(capabilityDocument({ capabilities }));
expect(decoded.capabilities).toEqual(capabilities);
expect(collabCloudCapabilitySupported(decoded, 'project-snapshot')).toBe(true);
expect(collabCloudCapabilitySupported(decoded, 'tickets')).toBe(true);
expect(COLLAB_CLOUD_BINDING_LIMITS.maxEventReplay).toBe(500);
});
it('constructs the exact package-owned capability schema for server advertisement', () => {
expect(collabCloudCapabilityDocument(
[...COLLAB_CLOUD_CAPABILITIES],
capabilityDocument().limits,
)).toEqual(capabilityDocument());
});
it.each([
capabilityDocument({ futureField: true }),
capabilityDocument({ schemaVersion: 2 }),
capabilityDocument({ bindingVersions: [2] }),
capabilityDocument({ bindingVersions: [1, 2] }),
capabilityDocument({ protocolVersions: [3] }),
capabilityDocument({ protocolVersions: [4, 5] }),
capabilityDocument({ capabilities: ['tickets', 'accept'] }),
capabilityDocument({ capabilities: ['accept', 'accept'] }),
capabilityDocument({ limits: { maxJsonPayloadUtf8Bytes: 512 * 1024 } }),
])('rejects a strict or incompatible capability document %#', (input) => {
expect(() => decodeCollabCloudCapabilityDocument(input))
.toThrow(CollabError);
});
it('round-trips strict success and safe error envelopes', () => {
const success = collabCloudSuccessEnvelope('request_1', { ok: true });
expect(decodeCollabCloudSuccessEnvelope(success)).toEqual(success);
const error = collabCloudErrorEnvelope('request_1', new CollabError({
code: 'authority-not-synchronized',
recoveryActions: ['retry'],
safeContext: { projectId: PROJECT_ID },
}));
expect(decodeCollabCloudErrorEnvelope(error)).toEqual(error);
expect(() => decodeCollabCloudSuccessEnvelope({ ...success, extra: true }))
.toThrow('collab.error.protocol-payload-invalid');
expect(() => decodeCollabCloudErrorEnvelope({
...error,
error: { ...error.error, credential: 'secret' },
})).toThrow('collab.error.protocol-payload-invalid');
});
});
@@ -0,0 +1,197 @@
import {
COLLAB_CLOUD_EVENT_KINDS,
COLLAB_CLOUD_PROJECT_SNAPSHOT_CODEC,
COLLAB_MAIN_REF,
COLLAB_PROTOCOL_VERSION,
collabMemberRef,
decodeCollabCloudProjectEventMessage,
decodeCollabCloudProjectSnapshot,
} from '../src/index';
const NOW = '2026-08-21T00:00:00.000Z';
const EARLIER = '2026-08-20T00:00:00.000Z';
const MAIN = '1'.repeat(40);
const HEAD_ONE = '2'.repeat(40);
const HEAD_TWO = '3'.repeat(40);
function member(memberId: string, role: 'manager' | 'member') {
return {
activatedAt: NOW,
createdAt: EARLIER,
displayName: memberId === 'member_1' ? 'Alice' : 'Bob',
id: memberId,
personalRef: collabMemberRef(memberId),
role,
status: 'active',
};
}
function request(requestId: string, memberId: string, headOid: string) {
return {
commentCount: 0,
createdAt: EARLIER,
description: 'Ready',
firstBaseOid: MAIN,
id: requestId,
latestHeadOid: headOid,
memberId,
revision: 1,
status: 'open',
ticketRelations: [],
updatedAt: NOW,
};
}
function ticket(ticketId: string, number: number, updatedAt: string) {
return {
acceptedRelationCount: 0,
authorMemberId: 'member_1',
commentCount: 0,
createdAt: EARLIER,
id: ticketId,
number,
revision: 1,
status: 'open',
title: `Ticket ${number}`,
updatedAt,
};
}
function snapshot(overrides: Record<string, unknown> = {}) {
return {
currentMember: member('member_1', 'manager'),
eventSequence: 12,
members: [
member('member_1', 'manager'),
member('member_2', 'member'),
],
openRequests: [
request('request_1', 'member_1', HEAD_ONE),
request('request_2', 'member_2', HEAD_TWO),
],
openTicketCount: 2,
project: {
createdAt: EARLIER,
expectedMainOid: MAIN,
id: 'project_1',
mainRef: COLLAB_MAIN_REF,
name: 'Private project',
},
ticketHighlights: [
ticket('ticket_2', 2, NOW),
ticket('ticket_1', 1, EARLIER),
],
...overrides,
};
}
describe('Cloud Project snapshot', () => {
it('decodes the exact authority-neutral bounded projection', () => {
expect(decodeCollabCloudProjectSnapshot(snapshot())).toEqual(snapshot());
expect(JSON.stringify(decodeCollabCloudProjectSnapshot(snapshot())))
.not.toMatch(/host|credential|endpoint|placement|managerSetGeneration/i);
});
it.each([
snapshot({ futureField: true }),
snapshot({ project: { ...snapshot().project, hostMemberId: 'member_1' } }),
snapshot({ currentMember: member('member_3', 'member') }),
snapshot({ members: [member('member_2', 'member'), member('member_1', 'manager')] }),
snapshot({ openRequests: [
request('request_2', 'member_2', HEAD_TWO),
request('request_1', 'member_1', HEAD_ONE),
] }),
snapshot({ ticketHighlights: [
ticket('ticket_1', 1, EARLIER),
ticket('ticket_2', 2, NOW),
] }),
snapshot({ openTicketCount: 1 }),
])('rejects mixed, unsorted, or extended snapshot state %#', (input) => {
expect(() => decodeCollabCloudProjectSnapshot(input))
.toThrow('collab.error.protocol-payload-invalid');
});
it('owns the exact getProjectSnapshot request/response codec', () => {
expect(COLLAB_CLOUD_PROJECT_SNAPSHOT_CODEC.decodeRequest({ projectId: 'project_1' }))
.toEqual({ status: 'ok', value: { projectId: 'project_1' } });
expect(COLLAB_CLOUD_PROJECT_SNAPSHOT_CODEC.decodeRequest({
projectId: 'project_1',
role: 'manager',
}).status).toBe('invalid');
expect(COLLAB_CLOUD_PROJECT_SNAPSHOT_CODEC.decodeResponse(snapshot()))
.toEqual(snapshot());
});
});
describe('Cloud Project events', () => {
it('freezes six redacted durable kinds and decodes their exact payloads', () => {
expect(COLLAB_CLOUD_EVENT_KINDS).toEqual([
'membership.updated',
'request.updated',
'request.comment-added',
'ticket.updated',
'ticket.comment-added',
'main.updated',
]);
const events = [
{ kind: 'membership.updated', payload: { memberId: 'member_1' } },
{ kind: 'request.updated', payload: { requestId: 'request_1' } },
{ kind: 'request.comment-added', payload: { requestId: 'request_1' } },
{ kind: 'ticket.updated', payload: { ticketId: 'ticket_1' } },
{ kind: 'ticket.comment-added', payload: { ticketId: 'ticket_1' } },
{ kind: 'main.updated', payload: { mainOid: MAIN, requestId: 'request_1' } },
];
for (const [index, event] of events.entries()) {
const envelope = {
...event,
occurredAt: NOW,
projectId: 'project_1',
protocolVersion: 4,
sequence: index + 1,
};
expect(decodeCollabCloudProjectEventMessage(envelope)).toEqual(envelope);
}
});
it('decodes snapshot.required as the only non-durable stream instruction', () => {
expect(decodeCollabCloudProjectEventMessage({
kind: 'snapshot.required',
latestSequence: 12,
})).toEqual({ kind: 'snapshot.required', latestSequence: 12 });
});
it.each([
{
kind: 'future.event',
occurredAt: NOW,
payload: {},
projectId: 'project_1',
protocolVersion: COLLAB_PROTOCOL_VERSION,
sequence: 1,
},
{
kind: 'request.comment-added',
occurredAt: NOW,
payload: { body: 'secret', requestId: 'request_1' },
projectId: 'project_1',
protocolVersion: COLLAB_PROTOCOL_VERSION,
sequence: 1,
},
{
kind: 'main.updated',
occurredAt: NOW,
payload: { mainOid: MAIN, requestId: 'request_1' },
projectId: 'project_1',
protocolVersion: 3,
sequence: 1,
},
{
kind: 'snapshot.required',
latestSequence: 12,
reason: 'gap',
},
])('rejects unknown, content-rich, incompatible, or extended messages %#', (input) => {
expect(() => decodeCollabCloudProjectEventMessage(input))
.toThrow('collab.error.protocol-payload-invalid');
});
});
@@ -7,7 +7,7 @@ import {
describe('CollabConstants', () => {
it('freezes the wire protocol version', () => {
expect(COLLAB_PROTOCOL_VERSION).toBe(3);
expect(COLLAB_PROTOCOL_VERSION).toBe(4);
});
it('defines the protected and personal ref semantics', () => {
@@ -20,7 +20,9 @@ describe('CollabConstants', () => {
maxBlobBytes: 50 * 1024 * 1024,
maxChangedPaths: 2_000,
maxCommentBytes: 16 * 1024,
maxMemberDisplayNameUtf16: 200,
maxRequestDescriptionBytes: 16 * 1024,
maxProjectNameUtf16: 200,
maxTicketTitleUtf16: 200,
maxTicketBodyBytes: 32 * 1024,
maxTicketCommentBytes: 16 * 1024,
@@ -74,9 +74,9 @@ function acceptedRelation(id: string) {
};
}
describe('Collab wire protocol v3', () => {
it('declares wire version 3 with finite collection and page limits', () => {
expect(COLLAB_PROTOCOL_VERSION).toBe(3);
describe('Canonical Collab wire protocol v4', () => {
it('declares wire version 4 with finite collection and page limits', () => {
expect(COLLAB_PROTOCOL_VERSION).toBe(4);
expect(COLLAB_LIMITS.maxRequestComments).toBe(500);
expect(COLLAB_LIMITS.maxTicketAcceptedRelations).toBe(2_000);
expect(COLLAB_LIMITS.defaultCommentPageSize).toBe(50);
@@ -0,0 +1,326 @@
import {
COLLAB_MAIN_REF,
COLLAB_PROTOCOL_VERSION,
DEVELOPMENT_BOOTSTRAP_ACTIVATION_PHASES,
DEVELOPMENT_BOOTSTRAP_ATTEMPT_STATES,
DEVELOPMENT_BOOTSTRAP_CANCELLATION_PHASES,
DEVELOPMENT_BOOTSTRAP_MANIFEST_SCHEMA_VERSION,
DEVELOPMENT_BOOTSTRAP_OPERATION_CODECS,
collabMemberRef,
decodeDevelopmentBootstrapManifest,
decodeDevelopmentBootstrapReport,
developmentBootstrapOperationCodec,
encodeDevelopmentBootstrapManifestCanonicalJson,
} from '../src/index';
const NOW = '2026-08-21T00:00:00.000Z';
const LATER = '2026-08-22T00:00:00.000Z';
const MAIN = '1'.repeat(40);
const MEMBER_ONE = '2'.repeat(40);
const MEMBER_TWO = '3'.repeat(40);
const SHA256 = 'a'.repeat(64);
function comparisonMember(
memberId: string,
role: 'manager' | 'member',
) {
return {
activatedAt: NOW,
createdAt: NOW,
displayName: memberId === 'member_1' ? 'Alice' : 'Bob',
memberId,
personalRef: collabMemberRef(memberId),
role,
status: 'active',
};
}
function comparison(overrides: Record<string, unknown> = {}) {
return {
mainOid: MAIN,
mainRef: COLLAB_MAIN_REF,
managerSetGeneration: 4,
members: [
comparisonMember('member_1', 'manager'),
comparisonMember('member_2', 'member'),
],
projectCreatedAt: NOW,
projectId: 'project_1',
projectName: 'Private project',
sourceCaFingerprint: 'b'.repeat(64),
sourceEventSequence: 9,
sourceHostMemberId: 'member_1',
...overrides,
};
}
function sourceEligibility(overrides: Record<string, unknown> = {}) {
return {
liveInvitations: 0,
nonActiveMemberships: 0,
nonterminalAcceptOperations: 0,
nonterminalHostTransfers: 0,
nonterminalManagerOffers: 0,
requestComments: 0,
requests: 0,
terminalProjectTransitions: 0,
ticketComments: 0,
ticketMentions: 0,
ticketRelations: 0,
tickets: 0,
...overrides,
};
}
function manifest(overrides: Record<string, unknown> = {}) {
return {
attemptId: 'attempt_1',
comparison: comparison(),
createdAt: NOW,
git: {
bundle: { byteCount: 4096, sha256: SHA256 },
objectFormat: 'sha1',
refs: [
{ name: COLLAB_MAIN_REF, oid: MAIN },
{ name: collabMemberRef('member_1'), oid: MEMBER_ONE },
{ name: collabMemberRef('member_2'), oid: MEMBER_TWO },
],
},
manifestSchemaVersion: 1,
protocolVersion: 4,
sourceEligibility: sourceEligibility(),
...overrides,
};
}
function readiness(overrides: Record<string, unknown> = {}) {
return {
cleanupSettled: true,
collabGitChildrenDrained: true,
conflictRecoverySettled: true,
hostTransferSettled: true,
joinSettled: true,
leaveSettled: true,
managerResponsibilitySettled: true,
projectOperationQueueDrained: true,
projectSetupSettled: true,
projectWorkSessionClosed: true,
publishSettled: true,
reconciliationSettled: true,
reconnectSettled: true,
repositoryIdentityExact: true,
retirementSettled: true,
...overrides,
};
}
function report(
reporterMemberId: 'member_1' | 'member_2',
overrides: Record<string, unknown> = {},
) {
return {
attemptId: 'attempt_1',
capturedAt: NOW,
clientReadiness: readiness(),
comparison: comparison(),
observedPersonalRefOid: reporterMemberId === 'member_1' ? MEMBER_ONE : MEMBER_TWO,
reporterMemberId,
...(reporterMemberId === 'member_1'
? {
hostStopAttestation: {
attemptId: 'attempt_1',
autoStartDisabled: true,
fenceDurable: true,
fenceId: 'fence_1',
hostStopped: true,
manifestSha256: SHA256,
projectId: 'project_1',
resourcesDrained: true,
routeUnregistered: true,
stoppedAt: NOW,
},
}
: {}),
...overrides,
};
}
function attemptStatus(overrides: Record<string, unknown> = {}) {
return {
attemptId: 'attempt_1',
bundleState: 'missing',
createdAt: NOW,
expiresAt: LATER,
manifestSha256: SHA256,
projectId: 'project_1',
reporterMemberIds: [],
state: 'collecting',
...overrides,
};
}
describe('private-development bootstrap contract', () => {
it('freezes the exact states and durable phases', () => {
expect(DEVELOPMENT_BOOTSTRAP_MANIFEST_SCHEMA_VERSION).toBe(1);
expect(DEVELOPMENT_BOOTSTRAP_ATTEMPT_STATES).toEqual([
'collecting',
'validating',
'ready',
'activating',
'rejected',
'cancelled',
'recovery-required',
'activated',
]);
expect(DEVELOPMENT_BOOTSTRAP_ACTIVATION_PHASES).toEqual([
'publish-intent',
'repository-published',
'activated',
'completed',
]);
expect(DEVELOPMENT_BOOTSTRAP_CANCELLATION_PHASES).toEqual([
'cancel-intent',
'cancelled',
'recovery-required',
]);
});
it('decodes and canonically re-encodes an exact logical manifest', () => {
const decoded = decodeDevelopmentBootstrapManifest(manifest());
expect(decoded.protocolVersion).toBe(COLLAB_PROTOCOL_VERSION);
expect(decoded.git.refs.map(ref => ref.name)).toEqual([
COLLAB_MAIN_REF,
collabMemberRef('member_1'),
collabMemberRef('member_2'),
]);
expect(encodeDevelopmentBootstrapManifestCanonicalJson(decoded))
.toBe(JSON.stringify(manifest()));
});
it.each([
manifest({ futureField: true }),
manifest({ manifestSchemaVersion: 2 }),
manifest({ protocolVersion: 3 }),
manifest({ comparison: comparison({ members: [
comparisonMember('member_2', 'member'),
comparisonMember('member_1', 'manager'),
] }) }),
manifest({ comparison: comparison({ members: [
comparisonMember('member_1', 'member'),
comparisonMember('member_2', 'member'),
] }) }),
manifest({ sourceEligibility: sourceEligibility({ requests: 1 }) }),
manifest({ git: {
bundle: { byteCount: 4096, sha256: SHA256 },
objectFormat: 'sha1',
refs: [{ name: COLLAB_MAIN_REF, oid: MAIN }],
} }),
])('rejects an ineligible or non-canonical manifest %#', (input) => {
expect(() => decodeDevelopmentBootstrapManifest(input))
.toThrow('collab.error.protocol-payload-invalid');
});
it('decodes the two actor-bound reports and exact Host stop proof', () => {
expect(decodeDevelopmentBootstrapReport(report('member_1')))
.toEqual(report('member_1'));
expect(decodeDevelopmentBootstrapReport(report('member_2')))
.toEqual(report('member_2'));
});
it.each([
report('member_1', { hostStopAttestation: undefined }),
report('member_2', { hostStopAttestation: {
attemptId: 'attempt_1',
autoStartDisabled: true,
fenceDurable: true,
fenceId: 'fence_1',
hostStopped: true,
manifestSha256: SHA256,
projectId: 'project_1',
resourcesDrained: true,
routeUnregistered: true,
stoppedAt: NOW,
} }),
report('member_1', { clientReadiness: readiness({ publishSettled: false }) }),
report('member_1', { futureField: true }),
])('rejects a report that weakens readiness or stop evidence %#', (input) => {
expect(() => decodeDevelopmentBootstrapReport(input))
.toThrow('collab.error.protocol-payload-invalid');
});
it('provides one strict codec map for all six bootstrap bindings', () => {
expect(Object.keys(DEVELOPMENT_BOOTSTRAP_OPERATION_CODECS)).toEqual([
'beginDevelopmentBootstrap',
'submitDevelopmentBootstrapReport',
'getDevelopmentBootstrap',
'activateDevelopmentBootstrap',
'cancelDevelopmentBootstrap',
'putDevelopmentBootstrapGitBundle',
]);
const requests = {
activateDevelopmentBootstrap: { attemptId: 'attempt_1', manifestSha256: SHA256 },
beginDevelopmentBootstrap: { manifest: manifest() },
cancelDevelopmentBootstrap: { attemptId: 'attempt_1' },
getDevelopmentBootstrap: { attemptId: 'attempt_1' },
putDevelopmentBootstrapGitBundle: {
attemptId: 'attempt_1',
byteCount: 4096,
contentEncoding: 'identity',
contentType: 'application/x-git-bundle',
sha256: SHA256,
},
submitDevelopmentBootstrapReport: {
attemptId: 'attempt_1',
report: report('member_1'),
},
} as const;
for (const [operation, request] of Object.entries(requests)) {
const codec = developmentBootstrapOperationCodec(
operation as keyof typeof requests,
);
expect(codec.decodeRequest(request)).toEqual({ status: 'ok', value: request });
expect(codec.decodeResponse(attemptStatus())).toEqual(attemptStatus());
expect(codec.decodeRequest({ ...request, futureField: true }).status).toBe('invalid');
}
});
it.each(['activated', 'completed'] as const)(
'decodes a stable result at the %s phase without exposing placement paths or keys',
(activationPhase) => {
const activated = attemptStatus({
activationPhase,
activationResult: {
activatedAt: NOW,
activationOperationId: 'operation_1',
placementGeneration: 1,
projectId: 'project_1',
},
bundleState: 'validated',
reporterMemberIds: ['member_1', 'member_2'],
state: 'activated',
});
const decoded = developmentBootstrapOperationCodec('getDevelopmentBootstrap')
.decodeResponse(activated);
expect(decoded).toEqual(activated);
expect(JSON.stringify(decoded)).not.toMatch(/storageKey|filesystem|path/i);
},
);
it('rejects an activation result for a different Project', () => {
expect(() => developmentBootstrapOperationCodec('getDevelopmentBootstrap')
.decodeResponse(attemptStatus({
activationPhase: 'completed',
activationResult: {
activatedAt: NOW,
activationOperationId: 'operation_1',
placementGeneration: 1,
projectId: 'project_2',
},
bundleState: 'validated',
reporterMemberIds: ['member_1', 'member_2'],
state: 'activated',
}))).toThrow('collab.error.protocol-payload-invalid');
});
});
@@ -5,6 +5,13 @@ import * as ts from 'typescript';
import * as protocol from '../src/index';
const EXPECTED_VALUE_EXPORTS = [
'COLLAB_CLOUD_BINDING_LIMITS',
'COLLAB_CLOUD_BINDING_VERSION',
'COLLAB_CLOUD_CAPABILITIES',
'COLLAB_CLOUD_CAPABILITY_DOCUMENT_SCHEMA_VERSION',
'COLLAB_CLOUD_EVENT_KINDS',
'COLLAB_CLOUD_JSON_OPERATIONS',
'COLLAB_CLOUD_PROJECT_SNAPSHOT_CODEC',
'COLLAB_CONTROL_OPERATION_CODECS',
'COLLAB_ERROR_CODES',
'COLLAB_LIMITS',
@@ -12,14 +19,39 @@ const EXPECTED_VALUE_EXPORTS = [
'COLLAB_MEMBER_REF_PREFIX',
'COLLAB_PROTOCOL_VERSION',
'CollabError',
'DEVELOPMENT_BOOTSTRAP_ACTIVATION_PHASES',
'DEVELOPMENT_BOOTSTRAP_ATTEMPT_STATES',
'DEVELOPMENT_BOOTSTRAP_CANCELLATION_PHASES',
'DEVELOPMENT_BOOTSTRAP_MANIFEST_SCHEMA_VERSION',
'DEVELOPMENT_BOOTSTRAP_OPERATIONS',
'DEVELOPMENT_BOOTSTRAP_OPERATION_CODECS',
'collabCloudCapabilitiesRoute',
'collabCloudCapabilityDocument',
'collabCloudCapabilitySupported',
'collabCloudErrorEnvelope',
'collabCloudGitRoute',
'collabCloudProjectEventsRoute',
'collabCloudProjectOperationRoute',
'collabCloudSuccessEnvelope',
'collabControlOperationCodec',
'collabDevelopmentBootstrapRoute',
'collabErrorGroup',
'collabMemberRef',
'decodeCollabCloudCapabilityDocument',
'decodeCollabCloudErrorEnvelope',
'decodeCollabCloudProjectEventMessage',
'decodeCollabCloudProjectSnapshot',
'decodeCollabCloudSuccessEnvelope',
'decodeCollabProtocolEnvelope',
'decodeDevelopmentBootstrapManifest',
'decodeDevelopmentBootstrapReport',
'developmentBootstrapOperationCodec',
'encodeDevelopmentBootstrapManifestCanonicalJson',
'isCollabGitOid',
'isCollabMemberId',
'isCollabOpaqueId',
'isCollabProjectId',
'matchCollabCloudRoute',
'parseCollabMemberMentions',
'parseCollabTicketReferences',
'sanitizeCollabDiagnosticContext',
@@ -58,6 +90,28 @@ const EXPECTED_TYPE_EXPORTS = [
'ListTicketsRequest', 'TicketMutationResponse',
'UpdateMyRequestMetadataRequest', 'UpdateMyRequestMetadataResponse',
'UpdateTicketContentRequest',
'ActivateDevelopmentBootstrapRequest', 'BeginDevelopmentBootstrapRequest',
'CancelDevelopmentBootstrapRequest', 'CollabCloudCapability',
'CollabCloudCapabilityDocument', 'CollabCloudCapabilityLimits',
'CollabCloudErrorEnvelope', 'CollabCloudEventKind',
'CollabCloudEventPayloadMap', 'CollabCloudGitService',
'CollabCloudJsonOperation', 'CollabCloudProjectEvent',
'CollabCloudProjectEventMessage', 'CollabCloudProjectMember',
'CollabCloudProjectSnapshot', 'CollabCloudProjectSnapshotCodec',
'CollabCloudProjectSummary', 'CollabCloudRoute', 'CollabCloudRouteMatch',
'CollabCloudSnapshotRequired', 'CollabCloudSuccessEnvelope',
'CollabCloudWireError', 'DevelopmentBootstrapActivationPhase',
'DevelopmentBootstrapActivationResult', 'DevelopmentBootstrapAttemptState',
'DevelopmentBootstrapAttemptStatus', 'DevelopmentBootstrapBundleState',
'DevelopmentBootstrapCancellationPhase', 'DevelopmentBootstrapClientReadiness',
'DevelopmentBootstrapComparison', 'DevelopmentBootstrapComparisonMember',
'DevelopmentBootstrapGitRef', 'DevelopmentBootstrapManifest',
'DevelopmentBootstrapObjectFormat', 'DevelopmentBootstrapOperation',
'DevelopmentBootstrapOperationCodec', 'DevelopmentBootstrapOperationMap',
'DevelopmentBootstrapReport', 'DevelopmentBootstrapSourceEligibility',
'DevelopmentHostStopAttestation', 'GetCollabCloudProjectSnapshotRequest',
'GetDevelopmentBootstrapRequest', 'PutDevelopmentBootstrapGitBundleRequest',
'SubmitDevelopmentBootstrapReportRequest',
] as const;
const EXPECTED_OPERATIONS = [
@@ -145,11 +145,15 @@ function publicDeclarations() {
function decoderBehaviorDigests() {
return [
'CollabCloudBinding.ts',
'CollabCloudProjectEvent.ts',
'CollabCloudProjectSnapshot.ts',
'CollabControlOperationCodecs.ts',
'CollabProtocol.ts',
'CollabRequestTicketRequestCodecs.ts',
'CollabRequestTicketResponseCodecs.ts',
'CollabValidation.ts',
'DevelopmentBootstrap.ts',
].map(name => {
const contents = readFileSync(path.join(packageRoot, 'src', name), 'utf8');
return {
+5 -3
View File
@@ -40,7 +40,9 @@
| `HostTransferModule` | Construction boundary exposing the Vault/client `CollabHostTransferService` and a factory for independently closed per-Host `OutgoingHostTransferRuntime`; it must not merge those lifetimes or absorb Git/package/transport/CA algorithms |
| `OutgoingHostTransferCoordinator` / `IncomingHostTransferCoordinator` | Two-device Host handoff across provisional target preparation, quiesced exact package transfer, one-way authority relinquishment, manifest-bound target activation, source verification/demotion, and crash recovery |
| Leave and retirement coordinators | Authority-backed individual departure, pending ordinary-Member Leave settlement, exact local cleanup, terminal Project convergence, acknowledgement, and retained minimum terminal response |
| `CollabProjectWorkSession` / `CollabProjectWorkSessionRegistry` | The single per-Project owner for already-admitted publication ordering, inspection/synchronization fences, reconnect and event-refresh coalescing, cache-write ordering, projection generation, coordination subscriptions, and close/drain; it never replaces `ProjectOperationAdmission` |
| `CollabProjectWorkSession` / `CollabProjectWorkSessionRegistry` | The single per-Project owner for one membership-generation authority session, event connection, publication ordering, inspection/synchronization fences, reconnect and refresh coalescing, cache-write ordering, projection generation, mutation queue, and close/drain; it never replaces `ProjectOperationAdmission` |
| `CollabAuthoritySessionFactory` and LAN/Cloud adapters | Stateless construction of membership-tagged authority-neutral control, event, and Git-network ports; the LAN adapter preserves LAN lifecycle extensions, while the Cloud adapter owns Cloud capability negotiation, routes, development principal presentation, snapshots, events, safe error mapping, and Git endpoint construction |
| `CloudBootstrapCoordinator` / Cloud transition record | Private two-client readiness/report orchestration, former-Host stop fence, Cloud activation request/replay, and one-way local binding recovery without restoring LAN authority after Cloud activation |
| `PublishCoordinator` | Serialized prepare/confirm publication decisions bound to required OID-only local publication state, exact candidate OIDs, durable review receipts, offline/resume phases, and exact pushed-head request synchronization |
| `CollabPublicationService` | Lazy composition of semantic personal status, Native Git Publish, private request-description drafts, contribution-free accepted-main synchronization, durable review/conflict sessions, pinned Project control, and the shared Project-work-session registry |
| `CollabClientProjection` | Session-backed online coordination reads, validated offline coordination/Ticket read cache, acknowledged event cursor persistence, and request/comment dispatch; only finite complete cursor-free Ticket details enter detail cache, bounded detail reads remain online-only, and it must not own a second Project registry |
@@ -63,8 +65,8 @@
| `AcceptCoordinator` | Per-Project exact-main/head/request-revision/resolving-Ticket acceptance, Native Git merge/ref CAS orchestration, durable cross-store recovery, shared accepted-relation capacity before Git mutation and at finalization, Ticket closure, and fail-closed acceptance availability |
| `ProjectEventHub` | Active-Member WebSocket replay, redacted invalidations, sequence-gap fallback, heartbeat, revocation, and Project-scoped teardown |
| `HostedProjectControlService` | Complete Active-Host request, Ticket, membership, administration, and lifecycle composition plus lifecycle-summary projection; construction rejects partial capability bags, while terminal routing uses a separate minimal service type |
| Collab control operation contracts / LAN bindings | `@claudian/collab-protocol` owns the transport-neutral request/Ticket/Accept operation DTOs and codecs. The LAN binding owns its independent v9 envelope/version, Join/invitation/endpoint/snapshot/lifecycle DTOs and codecs, method, route, request source, auth/admission, success status, parameter matching, and dispatch; shared operations are adapted through the package registry rather than reimplemented |
| Collab errors, limits, and events | `@claudian/collab-protocol` owns only shared wire error vocabulary and limits. Claudian-only recovery actions, LAN/connectivity/lifecycle errors, checkout/diff/Host quotas, and the current LAN v9 redacted event catalog remain local extensions; never add them to the shared package merely for import convenience |
| Collab control operation contracts / transport bindings | `@claudian/collab-protocol` owns transport-neutral collaboration DTOs/codecs plus Cloud binding v1 routes, capabilities, bootstrap, snapshot, and event contracts. The LAN binding owns its independent v9 envelope/version, Join/invitation/endpoint/snapshot/lifecycle DTOs and codecs, method, route, request source, auth/admission, success status, parameter matching, and dispatch; shared operation semantics are adapted rather than reimplemented |
| Collab errors, limits, and events | `@claudian/collab-protocol` owns shared wire errors/limits and the accepted Cloud event catalog. Claudian-only recovery actions, LAN/connectivity/lifecycle errors, checkout/diff/Host quotas, and the independent LAN v9 redacted event catalog remain local extensions; never merge LAN and Cloud bindings for import convenience |
| LAN lifecycle gateway | The exhaustive lifecycle operation/security/admission policy. Routes own wire/path/header context validation and raw bearer extraction; the active or terminal gateway owns policy authentication, admission selection, typed dispatch, and deferred-result forwarding |
| `ClaudianProviderHost` | Typed delegation to application capabilities; it owns no duplicate settings, storage, view, or execution state |
+15
View File
@@ -0,0 +1,15 @@
# Private Cloud bootstrap
## Authority transfer
- This scope owns private two-client readiness/report orchestration, the former-Host stop fence, activation request/replay, and the client-local binding transition. It does not own Cloud server activation policy, LAN Host-transfer protocol, or production onboarding.
- Persist the non-restart Host fence and drain Project work sessions and Git children before the source Host begins bootstrap. Step 5 may complete with Cloud activated and both clients still holding pending local transition intent; terminal binding waits for step 6 snapshot and upload-pack.
- Both Members submit independent actor-bound reports. The source Host cannot attest for the other Member, and no path uploads SQLite, credentials, CA material, Project-directory archives, unpublished files, local-only commits, or private drafts.
## Binding lifecycle
- One transition record is the sole phase authority and advances only through `intent`, `readiness-confirmed`, `origin-rotated`, `cloud-verified`, `membership-replaced`, `index-repaired`, `lan-authority-retired`, and `fence-terminal`.
- `membership-replaced` atomically installs the strict tagged Cloud membership and is the adapter-selection boundary. The separate Project index is derived and repaired afterward; neither file duplicates the transition phase.
- `lan-authority-retired` atomically moves only the former Host's exact inactive authority directory to an attempt-scoped private directory on the same filesystem. The other client records a no-op. Retained data is inert diagnostic state and is never opened, auto-started, or treated as rollback authority.
- Recovery revalidates exact old/new URLs, repository identity, activation identity, membership, index, and retired-authority observations at every phase. After Cloud activation, recovery only moves forward or remains visibly pending; it never restores LAN authority or clears the terminal non-restart fence.
- Preserve unpublished files, local commits, private drafts, and cache-rebuild ability throughout binding. The Cloud membership contains server URL, binding/wire versions, derived Git URL, and development actor ID only; it contains no active LAN credential, CA, Host ownership, or recovery phase.
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+18
View File
@@ -0,0 +1,18 @@
# Remote Collab authority
## Ownership
- `CollabAuthoritySessionFactory` is stateless. It constructs one membership-generation session composed of authority-neutral control, event, and Git-network ports, then transfers disposal ownership to `CollabProjectWorkSession`.
- `CollabProjectWorkSessionRegistry` remains the sole retained per-Project lifecycle registry. A work session owns at most one authority session, event connection, refresh queue, and mutation queue and closes them before its membership generation changes.
- `LanAuthorityAdapter` wraps the existing LAN clients and lifecycle extensions without changing LAN v9 binding, credentials, CA pinning, discovery, Host, or transfer semantics.
- `CloudAuthorityAdapter` owns Cloud binding-v1 capability negotiation, package-owned route construction and codecs, development-principal presentation, snapshot/event transport, safe error mapping, and Git endpoint construction. It never implements server Project policy or translates Cloud lifecycle into LAN lifecycle.
## Dependency and safety
- Publication, projection, review, reconciliation, feature, UI, and Agent-facing services depend on the neutral ports and never construct LAN or Cloud transports directly.
- Cloud Projects expose only negotiated capabilities. Host transfer, membership administration, Manager responsibility, Leave, Retire, and LAN diagnostics remain LAN-only and must not fall back to a stale LAN session.
- Canonicalize self-host URLs once and compare exact normalized values. Git environments may carry multiple redacted headers and an optional CA path; credentials, header values, and private paths never enter process arguments, logs, errors, or persisted diagnostics.
## Verification
- Adapter contract tests keep owned application modules real, prove LAN behavior is preserved, prove unknown Cloud capabilities are ignored while unknown binding/wire/schema values fail closed, and prove replacing a membership generation disposes the old session exactly once.
@@ -0,0 +1 @@
@AGENTS.md
+1 -1
View File
@@ -22,7 +22,7 @@ Core must consume provider data through explicit contracts. Do not branch on pro
- `ProviderExecutionRequest.context.linkedContent` is the canonical provider-neutral Linked content reference. New writes use one normalized path-only shape for Notes, folders, or missing content; the Vault root is not a valid target. Compatibility decoders may accept legacy current/linked-Note forms, but providers and features must not emit them. A directory reference is context metadata, not a CWD or recursive-ingestion request.
- Bootstrap persistence stores Claudian metadata and input ledgers. Provider transcript files remain provider-owned and read-only.
- Registries own registration and lookup; they do not absorb the lifecycle or storage responsibilities of the registered service.
- The decision-complete shared Collab wire contract (opaque IDs, transport-neutral DTOs, JSON operation codecs, the operation registry, shared safe errors and limits, Git ref semantics, and decoder-defined compatibility behavior) is owned solely by `@claudian/collab-protocol` under `packages/collab-protocol/`. `src/core/collab/` retains only client-only Collab contracts: the feature and composer ports, project selection, the Vault projects-folder contract, local review/conflict/operation projections, and Claudian-only error/limit extensions layered over package-owned shared vocabulary. Core must not re-export package symbols, maintain a parallel operation inventory, or copy the shared codec, limit, error, or version definitions. HTTP versions, events, methods, route templates, authentication/admission, and dispatch remain application LAN bindings outside both core and the package.
- The decision-complete shared Collab wire contract is owned solely by `@claudian/collab-protocol` under `packages/collab-protocol/`. `src/core/collab/` retains client-only feature/composer ports, Project selection, local review/conflict/operation projections, and the common discriminated LAN/Cloud Project model. Core must not re-export package symbols, maintain a parallel operation inventory, or copy shared codecs, routes, capabilities, events, limits, errors, or versions. LAN HTTP/event bindings remain application-owned; Cloud binding contracts remain package-owned.
- `CollabFeaturePort` exposes complete presentation-facing review and Ticket detail reads. `CollabBoundedQueryPort` is the separate first-page and continuation-page capability for bounded Runtime consumers; do not add cursor-bearing aliases to the generic feature facade or cache incomplete detail as if it were complete.
- `CollabConflictSession` is a read-only immutable conflict descriptor, optionally paired with a prepared publication review. Per-file resolution decisions are not a core state machine: Contributors and Agents edit the real Project, and Publish validates that committed result.
+6
View File
@@ -0,0 +1,6 @@
# Collab core contracts
- `CollabAuthorityKind` is exactly `'lan' | 'cloud'`. `CollabProject` is a discriminated union over one common Project base; only the LAN variant has `hostMemberId` and `managerSetGeneration`. Never fabricate Cloud values or weaken LAN-only fields into meaningless optionals.
- `CollabProjectSnapshot` has one common base, a LAN extension for Host transfer and Manager-responsibility state, and a Cloud variant composed from the package-owned Cloud snapshot plus local authority metadata.
- Core owns authority-neutral client and feature contracts, not transport construction, retained sessions, route registries, compatibility policy, or server domain semantics. Consumers import package-owned wire symbols from `@claudian/collab-protocol`; core does not re-export or duplicate them.
- Feature contracts expose authority kind and negotiated capability support so callers must narrow before invoking LAN-only lifecycle or administration behavior. Absence of a Cloud capability is unsupported behavior, not permission to use a LAN route.
+1
View File
@@ -0,0 +1 @@
@AGENTS.md