@claudian-collab/protocol 3.2.0 → 3.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,119 @@
1
+ import { CollabError } from './CollabError.mjs';
2
+ import { isCollabGitOid, isCollabOpaqueId, isCollabProjectId, } from './CollabValidation.mjs';
3
+ export const COLLAB_PROJECT_RETIREMENT_RESULT_KINDS = Object.freeze([
4
+ 'project-retired',
5
+ ]);
6
+ export const COLLAB_PROJECT_RETIREMENT_OPERATIONS = Object.freeze([
7
+ 'retireProject',
8
+ 'acknowledgeProjectRetirement',
9
+ ]);
10
+ function invalidPayload(field) {
11
+ return new CollabError({ code: 'protocol-payload-invalid', safeContext: { field } });
12
+ }
13
+ function exactRecord(value, field, keys) {
14
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
15
+ throw invalidPayload(field);
16
+ }
17
+ const source = value;
18
+ const expected = new Set(keys);
19
+ if (!keys.every(key => Object.hasOwn(source, key))
20
+ || Object.keys(source).some(key => !expected.has(key)))
21
+ throw invalidPayload(field);
22
+ return source;
23
+ }
24
+ function token(source, field, validate = isCollabOpaqueId) {
25
+ const value = source[field];
26
+ if (typeof value !== 'string' || !validate(value))
27
+ throw invalidPayload(field);
28
+ return value;
29
+ }
30
+ function positiveInteger(source, field) {
31
+ const value = source[field];
32
+ if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 1) {
33
+ throw invalidPayload(field);
34
+ }
35
+ return value;
36
+ }
37
+ function timestamp(source, field) {
38
+ const value = source[field];
39
+ if (typeof value !== 'string'
40
+ || value.length > 64
41
+ || Number.isNaN(Date.parse(value))
42
+ || new Date(value).toISOString() !== value)
43
+ throw invalidPayload(field);
44
+ return value;
45
+ }
46
+ export function decodeCollabProjectRetirementRequest(value) {
47
+ const source = exactRecord(value, 'retirementRequest', [
48
+ 'expectedAuthorityGeneration',
49
+ 'expectedMainOid',
50
+ 'idempotencyKey',
51
+ 'projectId',
52
+ ]);
53
+ return {
54
+ expectedAuthorityGeneration: positiveInteger(source, 'expectedAuthorityGeneration'),
55
+ expectedMainOid: token(source, 'expectedMainOid', isCollabGitOid),
56
+ idempotencyKey: token(source, 'idempotencyKey'),
57
+ projectId: token(source, 'projectId', isCollabProjectId),
58
+ };
59
+ }
60
+ export function decodeCollabProjectRetirementResult(value) {
61
+ const source = exactRecord(value, 'retirementResult', [
62
+ 'acknowledgementRequired',
63
+ 'kind',
64
+ 'projectId',
65
+ 'retiredAt',
66
+ 'retirementId',
67
+ 'terminalExpiresAt',
68
+ ]);
69
+ if (source.acknowledgementRequired !== true || source.kind !== 'project-retired') {
70
+ throw invalidPayload('retirementResult');
71
+ }
72
+ const retiredAt = timestamp(source, 'retiredAt');
73
+ const terminalExpiresAt = timestamp(source, 'terminalExpiresAt');
74
+ if (Date.parse(terminalExpiresAt) <= Date.parse(retiredAt)) {
75
+ throw invalidPayload('terminalExpiresAt');
76
+ }
77
+ return {
78
+ acknowledgementRequired: true,
79
+ kind: 'project-retired',
80
+ projectId: token(source, 'projectId', isCollabProjectId),
81
+ retiredAt,
82
+ retirementId: token(source, 'retirementId'),
83
+ terminalExpiresAt,
84
+ };
85
+ }
86
+ export function decodeCollabProjectRetirementAcknowledgement(value) {
87
+ const source = exactRecord(value, 'retirementAcknowledgement', [
88
+ 'acknowledgedAt',
89
+ 'idempotencyKey',
90
+ 'projectId',
91
+ 'retirementId',
92
+ ]);
93
+ return {
94
+ acknowledgedAt: timestamp(source, 'acknowledgedAt'),
95
+ idempotencyKey: token(source, 'idempotencyKey'),
96
+ projectId: token(source, 'projectId', isCollabProjectId),
97
+ retirementId: token(source, 'retirementId'),
98
+ };
99
+ }
100
+ export function decodeCollabProjectRetirementOperationRequest(operation, value) {
101
+ if (operation === 'retireProject') {
102
+ return decodeCollabProjectRetirementRequest(value);
103
+ }
104
+ const source = exactRecord(value, 'retirementAcknowledgementRequest', [
105
+ 'idempotencyKey',
106
+ 'projectId',
107
+ 'retirementId',
108
+ ]);
109
+ return {
110
+ idempotencyKey: token(source, 'idempotencyKey'),
111
+ projectId: token(source, 'projectId', isCollabProjectId),
112
+ retirementId: token(source, 'retirementId'),
113
+ };
114
+ }
115
+ export function decodeCollabProjectRetirementOperationResponse(operation, value) {
116
+ return (operation === 'retireProject'
117
+ ? decodeCollabProjectRetirementResult(value)
118
+ : decodeCollabProjectRetirementAcknowledgement(value));
119
+ }
@@ -0,0 +1,64 @@
1
+ import { COLLAB_PROTOCOL_VERSION } from './CollabConstants.mjs';
2
+ import { CollabError } from './CollabError.mjs';
3
+ function isRecord(value) {
4
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
5
+ }
6
+ function hasExactKeys(record, required) {
7
+ const allowed = new Set(required);
8
+ return required.every(key => Object.hasOwn(record, key))
9
+ && Object.keys(record).every(key => allowed.has(key));
10
+ }
11
+ function requiredString(record, field) {
12
+ const value = record[field];
13
+ return typeof value === 'string' && value.length > 0 ? value : null;
14
+ }
15
+ function invalidPayload(field) {
16
+ return {
17
+ status: 'invalid',
18
+ error: new CollabError({
19
+ code: 'protocol-payload-invalid',
20
+ safeContext: { field },
21
+ }),
22
+ };
23
+ }
24
+ function unsupportedVersion(receivedVersion) {
25
+ return {
26
+ status: 'unsupported-version',
27
+ receivedVersion,
28
+ error: new CollabError({
29
+ code: 'protocol-version-unsupported',
30
+ safeContext: {
31
+ receivedVersion,
32
+ supportedVersion: COLLAB_PROTOCOL_VERSION,
33
+ },
34
+ }),
35
+ };
36
+ }
37
+ function decodeVersion(record) {
38
+ const version = record.protocolVersion;
39
+ if (typeof version !== 'number' || !Number.isInteger(version)) {
40
+ return invalidPayload('protocolVersion');
41
+ }
42
+ if (version !== COLLAB_PROTOCOL_VERSION)
43
+ return unsupportedVersion(version);
44
+ return { status: 'ok', value: COLLAB_PROTOCOL_VERSION };
45
+ }
46
+ export function decodeCollabProtocolEnvelope(input) {
47
+ if (!isRecord(input) || !hasExactKeys(input, ['protocolVersion', 'requestId', 'data'])) {
48
+ return invalidPayload('envelope');
49
+ }
50
+ const version = decodeVersion(input);
51
+ if (version.status !== 'ok')
52
+ return version;
53
+ const requestId = requiredString(input, 'requestId');
54
+ if (!requestId)
55
+ return invalidPayload('requestId');
56
+ return {
57
+ status: 'ok',
58
+ value: {
59
+ data: input.data,
60
+ protocolVersion: version.value,
61
+ requestId,
62
+ },
63
+ };
64
+ }
@@ -0,0 +1,263 @@
1
+ import { COLLAB_LIMITS } from './CollabConstants.mjs';
2
+ import { CollabError } from './CollabError.mjs';
3
+ import { hasUtf8ByteLengthAtMost, isCollabGitOid, isCollabOpaqueId, isCollabProjectId, } from './CollabValidation.mjs';
4
+ function invalid(reason) {
5
+ return {
6
+ error: new CollabError({
7
+ code: 'protocol-payload-invalid',
8
+ safeContext: { reason },
9
+ }),
10
+ status: 'invalid',
11
+ };
12
+ }
13
+ function isRecord(value) {
14
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
15
+ }
16
+ function hasExactKeys(value, required, optional = []) {
17
+ const allowed = new Set([...required, ...optional]);
18
+ return required.every(key => Object.hasOwn(value, key))
19
+ && Object.keys(value).every(key => allowed.has(key));
20
+ }
21
+ function isRevision(value, minimum = 0) {
22
+ return typeof value === 'number'
23
+ && Number.isSafeInteger(value)
24
+ && value >= minimum;
25
+ }
26
+ function isPageQuery(value, maxLimit = COLLAB_LIMITS.maxCommentPageSize) {
27
+ return (value.cursor === undefined
28
+ || (typeof value.cursor === 'string'
29
+ && value.cursor.length > 0
30
+ && value.cursor.length <= COLLAB_LIMITS.maxPageCursorUtf16))
31
+ && (value.limit === undefined
32
+ || (isRevision(value.limit, 1) && value.limit <= maxLimit));
33
+ }
34
+ function mutationContext(value) {
35
+ return isCollabProjectId(value.projectId) && isCollabOpaqueId(value.idempotencyKey);
36
+ }
37
+ function resolvingTickets(value) {
38
+ if (!Array.isArray(value) || value.length > COLLAB_LIMITS.maxRequestTicketRelations)
39
+ return null;
40
+ const seen = new Set();
41
+ const result = [];
42
+ for (const entry of value) {
43
+ if (!isRecord(entry)
44
+ || !isCollabOpaqueId(entry.ticketId)
45
+ || seen.has(entry.ticketId)
46
+ || !isRevision(entry.revision, 1))
47
+ return null;
48
+ seen.add(entry.ticketId);
49
+ result.push({ ticketId: entry.ticketId, revision: entry.revision });
50
+ }
51
+ return result;
52
+ }
53
+ function decodeRequestTicketRequest(operation, input) {
54
+ if (!isRecord(input))
55
+ return null;
56
+ switch (operation) {
57
+ case 'getRequest':
58
+ return isCollabProjectId(input.projectId) && isCollabOpaqueId(input.requestId)
59
+ ? { projectId: input.projectId, requestId: input.requestId }
60
+ : null;
61
+ case 'ensureMyRequest':
62
+ return mutationContext(input)
63
+ && isCollabGitOid(input.expectedMainOid)
64
+ && isCollabGitOid(input.headOid)
65
+ && typeof input.description === 'string'
66
+ && hasUtf8ByteLengthAtMost(input.description, COLLAB_LIMITS.maxRequestDescriptionBytes)
67
+ ? {
68
+ description: input.description,
69
+ expectedMainOid: input.expectedMainOid,
70
+ headOid: input.headOid,
71
+ idempotencyKey: input.idempotencyKey,
72
+ projectId: input.projectId,
73
+ }
74
+ : null;
75
+ case 'createComment':
76
+ return mutationContext(input)
77
+ && isCollabOpaqueId(input.requestId)
78
+ && typeof input.body === 'string'
79
+ && hasUtf8ByteLengthAtMost(input.body, COLLAB_LIMITS.maxCommentBytes)
80
+ && input.anchor === undefined
81
+ ? {
82
+ body: input.body,
83
+ idempotencyKey: input.idempotencyKey,
84
+ projectId: input.projectId,
85
+ requestId: input.requestId,
86
+ }
87
+ : null;
88
+ case 'updateMyRequestMetadata':
89
+ return mutationContext(input)
90
+ && isCollabOpaqueId(input.requestId)
91
+ && isCollabGitOid(input.expectedHeadOid)
92
+ && isRevision(input.expectedRequestRevision)
93
+ && typeof input.description === 'string'
94
+ && hasUtf8ByteLengthAtMost(input.description, COLLAB_LIMITS.maxRequestDescriptionBytes)
95
+ ? {
96
+ description: input.description,
97
+ expectedHeadOid: input.expectedHeadOid,
98
+ expectedRequestRevision: input.expectedRequestRevision,
99
+ idempotencyKey: input.idempotencyKey,
100
+ projectId: input.projectId,
101
+ requestId: input.requestId,
102
+ }
103
+ : null;
104
+ case 'acceptRequest': {
105
+ const expectedResolvingTickets = resolvingTickets(input.expectedResolvingTickets);
106
+ return mutationContext(input)
107
+ && isCollabOpaqueId(input.requestId)
108
+ && isCollabGitOid(input.expectedMainOid)
109
+ && isCollabGitOid(input.expectedHeadOid)
110
+ && isRevision(input.expectedRequestRevision)
111
+ && expectedResolvingTickets !== null
112
+ ? {
113
+ expectedHeadOid: input.expectedHeadOid,
114
+ expectedMainOid: input.expectedMainOid,
115
+ expectedRequestRevision: input.expectedRequestRevision,
116
+ expectedResolvingTickets,
117
+ idempotencyKey: input.idempotencyKey,
118
+ projectId: input.projectId,
119
+ requestId: input.requestId,
120
+ }
121
+ : null;
122
+ }
123
+ case 'listTickets':
124
+ return hasExactKeys(input, ['projectId', 'status'], ['cursor', 'limit'])
125
+ && isCollabProjectId(input.projectId)
126
+ && (input.status === 'open' || input.status === 'closed' || input.status === 'all')
127
+ && isPageQuery(input, COLLAB_LIMITS.maxTicketPageSize)
128
+ ? {
129
+ ...(input.cursor === undefined ? {} : { cursor: input.cursor }),
130
+ ...(input.limit === undefined ? {} : { limit: input.limit }),
131
+ projectId: input.projectId,
132
+ status: input.status,
133
+ }
134
+ : null;
135
+ case 'listRequestComments':
136
+ return hasExactKeys(input, ['projectId', 'requestId'], ['cursor', 'limit'])
137
+ && isCollabProjectId(input.projectId)
138
+ && isCollabOpaqueId(input.requestId)
139
+ && isPageQuery(input)
140
+ ? {
141
+ ...(input.cursor === undefined ? {} : { cursor: input.cursor }),
142
+ ...(input.limit === undefined ? {} : { limit: input.limit }),
143
+ projectId: input.projectId,
144
+ requestId: input.requestId,
145
+ }
146
+ : null;
147
+ case 'listTicketComments':
148
+ return hasExactKeys(input, ['projectId', 'ticketId'], ['cursor', 'limit'])
149
+ && isCollabProjectId(input.projectId)
150
+ && isCollabOpaqueId(input.ticketId)
151
+ && isPageQuery(input)
152
+ ? {
153
+ ...(input.cursor === undefined ? {} : { cursor: input.cursor }),
154
+ ...(input.limit === undefined ? {} : { limit: input.limit }),
155
+ projectId: input.projectId,
156
+ ticketId: input.ticketId,
157
+ }
158
+ : null;
159
+ case 'listTicketAcceptedRelations':
160
+ return hasExactKeys(input, ['projectId', 'ticketId'], ['cursor', 'limit'])
161
+ && isCollabProjectId(input.projectId)
162
+ && isCollabOpaqueId(input.ticketId)
163
+ && isPageQuery(input, COLLAB_LIMITS.maxRelationsPerPage)
164
+ ? {
165
+ ...(input.cursor === undefined ? {} : { cursor: input.cursor }),
166
+ ...(input.limit === undefined ? {} : { limit: input.limit }),
167
+ projectId: input.projectId,
168
+ ticketId: input.ticketId,
169
+ }
170
+ : null;
171
+ case 'getTicket':
172
+ return hasExactKeys(input, ['projectId', 'ticketId'])
173
+ && isCollabProjectId(input.projectId)
174
+ && isCollabOpaqueId(input.ticketId)
175
+ ? { projectId: input.projectId, ticketId: input.ticketId }
176
+ : null;
177
+ case 'createTicket':
178
+ return hasExactKeys(input, ['body', 'idempotencyKey', 'projectId', 'title'])
179
+ && mutationContext(input)
180
+ && typeof input.body === 'string'
181
+ && typeof input.title === 'string'
182
+ && hasUtf8ByteLengthAtMost(input.body, COLLAB_LIMITS.maxTicketBodyBytes)
183
+ && input.title.length <= COLLAB_LIMITS.maxTicketTitleUtf16
184
+ ? {
185
+ body: input.body,
186
+ idempotencyKey: input.idempotencyKey,
187
+ projectId: input.projectId,
188
+ title: input.title,
189
+ }
190
+ : null;
191
+ case 'updateTicketContent':
192
+ return hasExactKeys(input, [
193
+ 'body', 'expectedRevision', 'idempotencyKey', 'projectId', 'ticketId', 'title',
194
+ ])
195
+ && mutationContext(input)
196
+ && isCollabOpaqueId(input.ticketId)
197
+ && isRevision(input.expectedRevision, 1)
198
+ && typeof input.body === 'string'
199
+ && typeof input.title === 'string'
200
+ && hasUtf8ByteLengthAtMost(input.body, COLLAB_LIMITS.maxTicketBodyBytes)
201
+ && input.title.length <= COLLAB_LIMITS.maxTicketTitleUtf16
202
+ ? {
203
+ body: input.body,
204
+ expectedRevision: input.expectedRevision,
205
+ idempotencyKey: input.idempotencyKey,
206
+ projectId: input.projectId,
207
+ ticketId: input.ticketId,
208
+ title: input.title,
209
+ }
210
+ : null;
211
+ case 'createTicketComment':
212
+ return hasExactKeys(input, ['body', 'idempotencyKey', 'projectId', 'ticketId'])
213
+ && mutationContext(input)
214
+ && isCollabOpaqueId(input.ticketId)
215
+ && typeof input.body === 'string'
216
+ && hasUtf8ByteLengthAtMost(input.body, COLLAB_LIMITS.maxTicketCommentBytes)
217
+ ? {
218
+ body: input.body,
219
+ idempotencyKey: input.idempotencyKey,
220
+ projectId: input.projectId,
221
+ ticketId: input.ticketId,
222
+ }
223
+ : null;
224
+ case 'closeTicket':
225
+ case 'reopenTicket':
226
+ return hasExactKeys(input, [
227
+ 'expectedRevision', 'idempotencyKey', 'projectId', 'ticketId',
228
+ ])
229
+ && mutationContext(input)
230
+ && isCollabOpaqueId(input.ticketId)
231
+ && isRevision(input.expectedRevision, 1)
232
+ ? {
233
+ expectedRevision: input.expectedRevision,
234
+ idempotencyKey: input.idempotencyKey,
235
+ projectId: input.projectId,
236
+ ticketId: input.ticketId,
237
+ }
238
+ : null;
239
+ }
240
+ }
241
+ const INVALID_REASONS = {
242
+ acceptRequest: 'request-accept-payload-invalid',
243
+ closeTicket: 'ticket-mutation-payload-invalid',
244
+ createComment: 'request-comment-payload-invalid',
245
+ createTicket: 'ticket-create-payload-invalid',
246
+ createTicketComment: 'ticket-comment-payload-invalid',
247
+ ensureMyRequest: 'request-ensure-payload-invalid',
248
+ getRequest: 'request-read-payload-invalid',
249
+ getTicket: 'ticket-read-payload-invalid',
250
+ listRequestComments: 'request-comment-page-query-invalid',
251
+ listTicketAcceptedRelations: 'ticket-relation-page-query-invalid',
252
+ listTicketComments: 'ticket-comment-page-query-invalid',
253
+ listTickets: 'ticket-list-query-invalid',
254
+ reopenTicket: 'ticket-mutation-payload-invalid',
255
+ updateMyRequestMetadata: 'request-metadata-payload-invalid',
256
+ updateTicketContent: 'ticket-content-payload-invalid',
257
+ };
258
+ export function decodeCollabRequestTicketOperationRequest(operation, input) {
259
+ const value = decodeRequestTicketRequest(operation, input);
260
+ return value
261
+ ? { status: 'ok', value: value }
262
+ : invalid(INVALID_REASONS[operation]);
263
+ }