@claudian-collab/protocol 3.2.1 → 3.3.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.
@@ -2,9 +2,11 @@ import { COLLAB_AUTHORITY_TRANSFER_CANCELLATION_PHASES, COLLAB_CLOUD_TO_LAN_TRAN
2
2
  import { COLLAB_MAIN_REF, COLLAB_MEMBER_REF_PREFIX, } from './CollabConstants.mjs';
3
3
  import { collabControlOperationCodec, } from './CollabControlOperationCodecs.mjs';
4
4
  import { CollabError } from './CollabError.mjs';
5
- import { COLLAB_CHECKPOINT_ARTIFACT_LIMITS, COLLAB_CHECKPOINT_BACKUP_RECORD_KINDS, COLLAB_PROJECT_COORDINATION_FORMAT_VERSION, decodeCollabProjectCheckpointCoordinationNdjson, decodeCollabProjectCheckpointManifest, validateCollabProjectCheckpointConsistency, } from './CollabProjectCheckpoint.mjs';
5
+ import { COLLAB_CHECKPOINT_ARTIFACT_LIMITS, COLLAB_CHECKPOINT_PORTABLE_RECORD_KINDS, COLLAB_PROJECT_COORDINATION_FORMAT_VERSION, decodeCollabProjectCheckpointCoordinationNdjson, decodeCollabProjectCheckpointManifest, validateCollabProjectCheckpointConsistency, } from './CollabProjectCheckpoint.mjs';
6
+ import { COLLAB_PROJECT_MEMBERSHIP_LIMITS, } from './CollabProjectMembership.mjs';
6
7
  import { hasUtf8ByteLengthAtMost, isCollabGitOid, isCollabMemberId, isCollabOpaqueId, isCollabProjectId, } from './CollabValidation.mjs';
7
- export const COLLAB_PROJECT_BACKUP_COORDINATION_FORMAT_VERSION = 2;
8
+ export const COLLAB_PROJECT_BACKUP_COMPATIBILITY_STAGE = 'pre-production-replaceable';
9
+ export const COLLAB_PROJECT_BACKUP_COORDINATION_FORMAT_VERSION = 3;
8
10
  const BACKUP_CONTINUITY_RECORD_KINDS = Object.freeze([
9
11
  'lifecycle-journal',
10
12
  'authority-transfer-recovery',
@@ -15,11 +17,29 @@ const BACKUP_CONTINUITY_RECORD_KINDS = Object.freeze([
15
17
  'terminal-principal',
16
18
  'terminal-responder-replay',
17
19
  'leave-former-principal-replay',
20
+ 'project-invitation',
21
+ 'protected-invitation-envelope',
22
+ 'transferred-membership-claim-override',
23
+ 'protected-claim-override-envelope',
24
+ 'manager-responsibility-offer',
25
+ 'membership-idempotency-tombstone',
26
+ 'project-membership-recovery',
27
+ 'secret-replay-tombstone',
18
28
  ]);
19
29
  export const COLLAB_PROJECT_BACKUP_RECORD_KINDS = Object.freeze([
20
- ...COLLAB_CHECKPOINT_BACKUP_RECORD_KINDS.slice(0, 13),
30
+ ...COLLAB_CHECKPOINT_PORTABLE_RECORD_KINDS,
31
+ 'cloud-event',
32
+ 'cloud-event-cursor',
33
+ 'idempotency-result',
34
+ 'principal-binding',
35
+ 'repository-placement',
21
36
  ...BACKUP_CONTINUITY_RECORD_KINDS,
22
- ...COLLAB_CHECKPOINT_BACKUP_RECORD_KINDS.slice(14),
37
+ 'terminal-responder',
38
+ 'protected-claim-envelope',
39
+ 'tombstone',
40
+ 'schema-catalog',
41
+ 'server-compatibility',
42
+ 'authority-volume-pair',
23
43
  ]);
24
44
  const CONTINUITY_KIND_SET = new Set(BACKUP_CONTINUITY_RECORD_KINDS);
25
45
  const BACKUP_KIND_SET = new Set(COLLAB_PROJECT_BACKUP_RECORD_KINDS);
@@ -49,7 +69,9 @@ const DELETE_ACTIVE_PHASE_SET = new Set([
49
69
  'tombstoned',
50
70
  ]);
51
71
  const PLAINTEXT_CLAIM_RESPONSE_OPERATION_SET = new Set([
72
+ 'createProjectInvitation',
52
73
  'getTransferredMembershipClaim',
74
+ 'reissueTransferredMembershipClaim',
53
75
  'rotateTransferredMembershipClaims',
54
76
  ]);
55
77
  function invalidPayload(field) {
@@ -107,6 +129,9 @@ function timestampValue(value, field) {
107
129
  function timestamp(source, field) {
108
130
  return timestampValue(source[field], field);
109
131
  }
132
+ function hasExactDuration(start, end, durationMs) {
133
+ return Date.parse(end) === Date.parse(start) + durationMs;
134
+ }
110
135
  function nullableTimestamp(source, field) {
111
136
  return source[field] === null ? null : timestampValue(source[field], field);
112
137
  }
@@ -457,7 +482,15 @@ function lifecycleJournalRecord(source, recordId, revision) {
457
482
  if (recordId !== operationId)
458
483
  throw invalidPayload('recordId');
459
484
  const operationKind = literal(value, 'operationKind', [
460
- 'authority-transfer', 'backup', 'delete', 'export', 'leave', 'retire',
485
+ 'authority-transfer',
486
+ 'backup',
487
+ 'create-project',
488
+ 'delete',
489
+ 'export',
490
+ 'join-project',
491
+ 'leave',
492
+ 'remove-member',
493
+ 'retire',
461
494
  ]);
462
495
  const direction = value.direction === null
463
496
  ? null
@@ -828,6 +861,385 @@ function leaveFormerPrincipalReplayRecord(source, recordId, revision) {
828
861
  },
829
862
  };
830
863
  }
864
+ function projectInvitationRecord(source, recordId, revision) {
865
+ const value = exactRecord(source.value, 'value', [
866
+ 'createdAt',
867
+ 'expiresAt',
868
+ 'idempotencyKey',
869
+ 'invitationId',
870
+ 'issuedByMemberId',
871
+ 'projectId',
872
+ 'requestFingerprint',
873
+ 'revision',
874
+ 'secretReplayExpiresAt',
875
+ 'secretSha256',
876
+ 'state',
877
+ 'terminalAt',
878
+ ]);
879
+ const invitationId = token(value, 'invitationId');
880
+ if (recordId !== invitationId)
881
+ throw invalidPayload('recordId');
882
+ const createdAt = timestamp(value, 'createdAt');
883
+ const expiresAt = timestamp(value, 'expiresAt');
884
+ const secretReplayExpiresAt = timestamp(value, 'secretReplayExpiresAt');
885
+ const state = literal(value, 'state', [
886
+ 'active', 'redeeming', 'redeemed', 'revoked', 'expired',
887
+ ]);
888
+ const terminalAt = nullableTimestamp(value, 'terminalAt');
889
+ const terminal = state === 'redeemed' || state === 'revoked' || state === 'expired';
890
+ if (!hasExactDuration(createdAt, expiresAt, COLLAB_PROJECT_MEMBERSHIP_LIMITS.invitationTtlMs)
891
+ || !hasExactDuration(createdAt, secretReplayExpiresAt, COLLAB_PROJECT_MEMBERSHIP_LIMITS.secretReplayTtlMs)
892
+ || terminal !== (terminalAt !== null)
893
+ || (terminalAt !== null && Date.parse(terminalAt) < Date.parse(createdAt)))
894
+ throw invalidPayload('state');
895
+ return {
896
+ kind: 'project-invitation',
897
+ recordId,
898
+ revision,
899
+ value: {
900
+ createdAt,
901
+ expiresAt,
902
+ idempotencyKey: token(value, 'idempotencyKey'),
903
+ invitationId,
904
+ issuedByMemberId: token(value, 'issuedByMemberId', isCollabMemberId),
905
+ projectId: token(value, 'projectId', isCollabProjectId),
906
+ requestFingerprint: sha256(value, 'requestFingerprint'),
907
+ revision: positiveInteger(value, 'revision'),
908
+ secretReplayExpiresAt,
909
+ secretSha256: sha256(value, 'secretSha256'),
910
+ state,
911
+ terminalAt,
912
+ },
913
+ };
914
+ }
915
+ function protectedSecretEnvelopeFields(value) {
916
+ const ciphertext = boundedString(value, 'ciphertext', 4096);
917
+ const nonce = boundedString(value, 'nonce', 128);
918
+ if (!BASE64URL_PATTERN.test(ciphertext) || !BASE64URL_PATTERN.test(nonce)) {
919
+ throw invalidPayload('ciphertext');
920
+ }
921
+ const createdAt = timestamp(value, 'createdAt');
922
+ const expiresAt = timestamp(value, 'expiresAt');
923
+ if (Date.parse(expiresAt) <= Date.parse(createdAt))
924
+ throw invalidPayload('expiresAt');
925
+ return {
926
+ associatedDataSha256: sha256(value, 'associatedDataSha256'),
927
+ ciphertext,
928
+ createdAt,
929
+ expiresAt,
930
+ keyId: token(value, 'keyId'),
931
+ nonce,
932
+ projectId: token(value, 'projectId', isCollabProjectId),
933
+ };
934
+ }
935
+ function protectedInvitationEnvelopeRecord(source, recordId, revision) {
936
+ const value = exactRecord(source.value, 'value', [
937
+ 'associatedDataSha256',
938
+ 'ciphertext',
939
+ 'createdAt',
940
+ 'expiresAt',
941
+ 'invitationId',
942
+ 'keyId',
943
+ 'nonce',
944
+ 'projectId',
945
+ ]);
946
+ const invitationId = token(value, 'invitationId');
947
+ if (recordId !== invitationId)
948
+ throw invalidPayload('recordId');
949
+ const envelope = protectedSecretEnvelopeFields(value);
950
+ return {
951
+ kind: 'protected-invitation-envelope',
952
+ recordId,
953
+ revision,
954
+ value: {
955
+ associatedDataSha256: envelope.associatedDataSha256,
956
+ ciphertext: envelope.ciphertext,
957
+ createdAt: envelope.createdAt,
958
+ expiresAt: envelope.expiresAt,
959
+ invitationId,
960
+ keyId: envelope.keyId,
961
+ nonce: envelope.nonce,
962
+ projectId: envelope.projectId,
963
+ },
964
+ };
965
+ }
966
+ function transferredMembershipClaimOverrideRecord(source, recordId, revision) {
967
+ const value = exactRecord(source.value, 'value', [
968
+ 'claimGeneration',
969
+ 'claimSha256',
970
+ 'createdAt',
971
+ 'expiresAt',
972
+ 'idempotencyKey',
973
+ 'managerMemberId',
974
+ 'memberId',
975
+ 'projectId',
976
+ 'redemptionReceiptId',
977
+ 'requestFingerprint',
978
+ 'secretReplayExpiresAt',
979
+ 'state',
980
+ 'supersededClaimSha256',
981
+ 'targetPrincipalId',
982
+ 'transferId',
983
+ 'updatedAt',
984
+ ]);
985
+ const transferId = token(value, 'transferId');
986
+ const memberId = token(value, 'memberId', isCollabMemberId);
987
+ const claimGeneration = positiveInteger(value, 'claimGeneration');
988
+ if (recordId !== `${transferId}:${memberId}:${claimGeneration}`) {
989
+ throw invalidPayload('recordId');
990
+ }
991
+ const state = literal(value, 'state', [
992
+ 'active', 'redeemed', 'revoked', 'superseded', 'expired',
993
+ ]);
994
+ const targetPrincipalId = nullableToken(value, 'targetPrincipalId', candidate => (typeof candidate === 'string' && PRINCIPAL_PATTERN.test(candidate)));
995
+ const redemptionReceiptId = nullableToken(value, 'redemptionReceiptId');
996
+ if ((state === 'redeemed') !== (targetPrincipalId !== null && redemptionReceiptId !== null)
997
+ || (state !== 'redeemed' && (targetPrincipalId !== null || redemptionReceiptId !== null)))
998
+ throw invalidPayload('state');
999
+ const createdAt = timestamp(value, 'createdAt');
1000
+ const expiresAt = timestamp(value, 'expiresAt');
1001
+ const secretReplayExpiresAt = timestamp(value, 'secretReplayExpiresAt');
1002
+ const updatedAt = timestamp(value, 'updatedAt');
1003
+ if (!hasExactDuration(createdAt, expiresAt, COLLAB_PROJECT_MEMBERSHIP_LIMITS.transferredClaimTtlMs)
1004
+ || !hasExactDuration(createdAt, secretReplayExpiresAt, COLLAB_PROJECT_MEMBERSHIP_LIMITS.secretReplayTtlMs)
1005
+ || Date.parse(updatedAt) < Date.parse(createdAt))
1006
+ throw invalidPayload('updatedAt');
1007
+ return {
1008
+ kind: 'transferred-membership-claim-override',
1009
+ recordId,
1010
+ revision,
1011
+ value: {
1012
+ claimGeneration,
1013
+ claimSha256: sha256(value, 'claimSha256'),
1014
+ createdAt,
1015
+ expiresAt,
1016
+ idempotencyKey: token(value, 'idempotencyKey'),
1017
+ managerMemberId: token(value, 'managerMemberId', isCollabMemberId),
1018
+ memberId,
1019
+ projectId: token(value, 'projectId', isCollabProjectId),
1020
+ redemptionReceiptId,
1021
+ requestFingerprint: sha256(value, 'requestFingerprint'),
1022
+ secretReplayExpiresAt,
1023
+ state,
1024
+ supersededClaimSha256: sha256(value, 'supersededClaimSha256'),
1025
+ targetPrincipalId,
1026
+ transferId,
1027
+ updatedAt,
1028
+ },
1029
+ };
1030
+ }
1031
+ function protectedClaimOverrideEnvelopeRecord(source, recordId, revision) {
1032
+ const value = exactRecord(source.value, 'value', [
1033
+ 'associatedDataSha256',
1034
+ 'ciphertext',
1035
+ 'claimGeneration',
1036
+ 'createdAt',
1037
+ 'expiresAt',
1038
+ 'keyId',
1039
+ 'memberId',
1040
+ 'nonce',
1041
+ 'projectId',
1042
+ 'transferId',
1043
+ ]);
1044
+ const transferId = token(value, 'transferId');
1045
+ const memberId = token(value, 'memberId', isCollabMemberId);
1046
+ const claimGeneration = positiveInteger(value, 'claimGeneration');
1047
+ if (recordId !== `${transferId}:${memberId}:${claimGeneration}`) {
1048
+ throw invalidPayload('recordId');
1049
+ }
1050
+ const envelope = protectedSecretEnvelopeFields(value);
1051
+ return {
1052
+ kind: 'protected-claim-override-envelope',
1053
+ recordId,
1054
+ revision,
1055
+ value: {
1056
+ associatedDataSha256: envelope.associatedDataSha256,
1057
+ ciphertext: envelope.ciphertext,
1058
+ claimGeneration,
1059
+ createdAt: envelope.createdAt,
1060
+ expiresAt: envelope.expiresAt,
1061
+ keyId: envelope.keyId,
1062
+ memberId,
1063
+ nonce: envelope.nonce,
1064
+ projectId: envelope.projectId,
1065
+ transferId,
1066
+ },
1067
+ };
1068
+ }
1069
+ function managerResponsibilityOfferRecord(source, recordId, revision) {
1070
+ const value = exactRecord(source.value, 'value', [
1071
+ 'acknowledgedAt',
1072
+ 'expiresAt',
1073
+ 'idempotencyKey',
1074
+ 'managerSetGenerationAtOffer',
1075
+ 'offeredAt',
1076
+ 'offerId',
1077
+ 'projectId',
1078
+ 'purpose',
1079
+ 'requestFingerprint',
1080
+ 'revision',
1081
+ 'sourceManagerMemberId',
1082
+ 'state',
1083
+ 'targetMemberId',
1084
+ 'targetMembershipRevisionAtOffer',
1085
+ 'terminalAt',
1086
+ ]);
1087
+ const offerId = token(value, 'offerId');
1088
+ if (recordId !== offerId)
1089
+ throw invalidPayload('recordId');
1090
+ const state = literal(value, 'state', [
1091
+ 'offered', 'acknowledged', 'declined', 'cancelled', 'consumed', 'expired',
1092
+ ]);
1093
+ const offeredAt = timestamp(value, 'offeredAt');
1094
+ const expiresAt = timestamp(value, 'expiresAt');
1095
+ const acknowledgedAt = nullableTimestamp(value, 'acknowledgedAt');
1096
+ const terminalAt = nullableTimestamp(value, 'terminalAt');
1097
+ const terminal = state === 'declined'
1098
+ || state === 'cancelled'
1099
+ || state === 'consumed'
1100
+ || state === 'expired';
1101
+ if (!hasExactDuration(offeredAt, expiresAt, COLLAB_PROJECT_MEMBERSHIP_LIMITS.managerResponsibilityOfferTtlMs)
1102
+ || (state === 'offered' && acknowledgedAt !== null)
1103
+ || (state === 'acknowledged' && acknowledgedAt === null)
1104
+ || terminal !== (terminalAt !== null))
1105
+ throw invalidPayload('state');
1106
+ return {
1107
+ kind: 'manager-responsibility-offer',
1108
+ recordId,
1109
+ revision,
1110
+ value: {
1111
+ acknowledgedAt,
1112
+ expiresAt,
1113
+ idempotencyKey: token(value, 'idempotencyKey'),
1114
+ managerSetGenerationAtOffer: positiveInteger(value, 'managerSetGenerationAtOffer'),
1115
+ offeredAt,
1116
+ offerId,
1117
+ projectId: token(value, 'projectId', isCollabProjectId),
1118
+ purpose: literal(value, 'purpose', ['manager-promotion', 'manager-leave']),
1119
+ requestFingerprint: sha256(value, 'requestFingerprint'),
1120
+ revision: positiveInteger(value, 'revision'),
1121
+ sourceManagerMemberId: token(value, 'sourceManagerMemberId', isCollabMemberId),
1122
+ state,
1123
+ targetMemberId: token(value, 'targetMemberId', isCollabMemberId),
1124
+ targetMembershipRevisionAtOffer: positiveInteger(value, 'targetMembershipRevisionAtOffer'),
1125
+ terminalAt,
1126
+ },
1127
+ };
1128
+ }
1129
+ function membershipRecoveryRecord(source, recordId, revision) {
1130
+ const value = exactRecord(source.value, 'value', [
1131
+ 'expectedMainOid',
1132
+ 'expectedPersonalRefOid',
1133
+ 'invitationId',
1134
+ 'memberId',
1135
+ 'operationId',
1136
+ 'operationKind',
1137
+ 'principalSha256',
1138
+ 'projectId',
1139
+ 'publicationMarkerSha256',
1140
+ 'repositoryPlanSha256',
1141
+ 'requestFingerprint',
1142
+ ]);
1143
+ const operationId = token(value, 'operationId');
1144
+ if (recordId !== operationId)
1145
+ throw invalidPayload('recordId');
1146
+ const operationKind = literal(value, 'operationKind', [
1147
+ 'create-project', 'join-project', 'remove-member',
1148
+ ]);
1149
+ const invitationId = nullableToken(value, 'invitationId');
1150
+ const principalSha256 = nullableSha256(value, 'principalSha256');
1151
+ const publicationMarkerSha256 = nullableSha256(value, 'publicationMarkerSha256');
1152
+ const repositoryPlanSha256 = nullableSha256(value, 'repositoryPlanSha256');
1153
+ if ((operationKind === 'join-project') !== (invitationId !== null)
1154
+ || (operationKind === 'remove-member') === (principalSha256 !== null)
1155
+ || (operationKind === 'create-project') !== (repositoryPlanSha256 !== null)
1156
+ || (operationKind !== 'create-project' && publicationMarkerSha256 !== null))
1157
+ throw invalidPayload('operationKind');
1158
+ return {
1159
+ kind: 'project-membership-recovery',
1160
+ recordId,
1161
+ revision,
1162
+ value: {
1163
+ expectedMainOid: token(value, 'expectedMainOid', isCollabGitOid),
1164
+ expectedPersonalRefOid: token(value, 'expectedPersonalRefOid', isCollabGitOid),
1165
+ invitationId,
1166
+ memberId: token(value, 'memberId', isCollabMemberId),
1167
+ operationId,
1168
+ operationKind,
1169
+ principalSha256,
1170
+ projectId: token(value, 'projectId', isCollabProjectId),
1171
+ publicationMarkerSha256,
1172
+ repositoryPlanSha256,
1173
+ requestFingerprint: sha256(value, 'requestFingerprint'),
1174
+ },
1175
+ };
1176
+ }
1177
+ function membershipIdempotencyTombstoneRecord(source, recordId, revision) {
1178
+ const value = exactRecord(source.value, 'value', [
1179
+ 'actorMemberId',
1180
+ 'compactedAt',
1181
+ 'idempotencyKey',
1182
+ 'operation',
1183
+ 'projectId',
1184
+ 'requestFingerprint',
1185
+ ]);
1186
+ const actorMemberId = token(value, 'actorMemberId', isCollabMemberId);
1187
+ const idempotencyKey = token(value, 'idempotencyKey');
1188
+ const operation = literal(value, 'operation', [
1189
+ 'createManagerResponsibilityOffer',
1190
+ 'acknowledgeManagerResponsibility',
1191
+ 'declineManagerResponsibility',
1192
+ 'cancelManagerResponsibilityOffer',
1193
+ 'promoteManager',
1194
+ ]);
1195
+ if (recordId !== `${operation}:${actorMemberId}:${idempotencyKey}`) {
1196
+ throw invalidPayload('recordId');
1197
+ }
1198
+ return {
1199
+ kind: 'membership-idempotency-tombstone',
1200
+ recordId,
1201
+ revision,
1202
+ value: {
1203
+ actorMemberId,
1204
+ compactedAt: timestamp(value, 'compactedAt'),
1205
+ idempotencyKey,
1206
+ operation,
1207
+ projectId: token(value, 'projectId', isCollabProjectId),
1208
+ requestFingerprint: sha256(value, 'requestFingerprint'),
1209
+ },
1210
+ };
1211
+ }
1212
+ function secretReplayTombstoneRecord(source, recordId, revision) {
1213
+ const value = exactRecord(source.value, 'value', [
1214
+ 'actorMemberId',
1215
+ 'expiredAt',
1216
+ 'idempotencyKey',
1217
+ 'operation',
1218
+ 'projectId',
1219
+ 'requestFingerprint',
1220
+ ]);
1221
+ const actorMemberId = token(value, 'actorMemberId', isCollabMemberId);
1222
+ const idempotencyKey = token(value, 'idempotencyKey');
1223
+ const operation = literal(value, 'operation', [
1224
+ 'createProjectInvitation', 'reissueTransferredMembershipClaim',
1225
+ ]);
1226
+ if (recordId !== `${operation}:${actorMemberId}:${idempotencyKey}`) {
1227
+ throw invalidPayload('recordId');
1228
+ }
1229
+ return {
1230
+ kind: 'secret-replay-tombstone',
1231
+ recordId,
1232
+ revision,
1233
+ value: {
1234
+ actorMemberId,
1235
+ expiredAt: timestamp(value, 'expiredAt'),
1236
+ idempotencyKey,
1237
+ operation,
1238
+ projectId: token(value, 'projectId', isCollabProjectId),
1239
+ requestFingerprint: sha256(value, 'requestFingerprint'),
1240
+ },
1241
+ };
1242
+ }
831
1243
  function decodeContinuityRecord(kind, source, recordId, revision) {
832
1244
  switch (kind) {
833
1245
  case 'lifecycle-journal': return lifecycleJournalRecord(source, recordId, revision);
@@ -845,6 +1257,21 @@ function decodeContinuityRecord(kind, source, recordId, revision) {
845
1257
  return terminalResponderReplayRecord(source, recordId, revision);
846
1258
  case 'leave-former-principal-replay':
847
1259
  return leaveFormerPrincipalReplayRecord(source, recordId, revision);
1260
+ case 'project-invitation': return projectInvitationRecord(source, recordId, revision);
1261
+ case 'protected-invitation-envelope':
1262
+ return protectedInvitationEnvelopeRecord(source, recordId, revision);
1263
+ case 'transferred-membership-claim-override':
1264
+ return transferredMembershipClaimOverrideRecord(source, recordId, revision);
1265
+ case 'protected-claim-override-envelope':
1266
+ return protectedClaimOverrideEnvelopeRecord(source, recordId, revision);
1267
+ case 'manager-responsibility-offer':
1268
+ return managerResponsibilityOfferRecord(source, recordId, revision);
1269
+ case 'membership-idempotency-tombstone':
1270
+ return membershipIdempotencyTombstoneRecord(source, recordId, revision);
1271
+ case 'project-membership-recovery':
1272
+ return membershipRecoveryRecord(source, recordId, revision);
1273
+ case 'secret-replay-tombstone':
1274
+ return secretReplayTombstoneRecord(source, recordId, revision);
848
1275
  }
849
1276
  }
850
1277
  function kindOrder(kind) {
@@ -901,6 +1328,33 @@ function lifecycleJournalHasInvalidSemantics(item) {
901
1328
  }
902
1329
  if (hasCheckpoint)
903
1330
  return true;
1331
+ if (value.operationKind === 'create-project'
1332
+ || value.operationKind === 'join-project'
1333
+ || value.operationKind === 'remove-member') {
1334
+ const phases = value.operationKind === 'create-project'
1335
+ ? new Set([
1336
+ 'prepared',
1337
+ 'repository-publication-intent',
1338
+ 'repository-published',
1339
+ 'activated',
1340
+ ])
1341
+ : value.operationKind === 'join-project'
1342
+ ? new Set(['prepared', 'membership-pending', 'personal-ref-created', 'membership-active'])
1343
+ : new Set(['prepared', 'membership-revoked', 'personal-ref-removed']);
1344
+ const statePhaseValid = (value.state === 'active' && phases.has(value.phase))
1345
+ || (value.state === 'recovery-required'
1346
+ && value.phase === 'recovery-required'
1347
+ && value.recoveryFromPhase !== null
1348
+ && phases.has(value.recoveryFromPhase))
1349
+ || (value.state === 'completed' && value.phase === 'completed');
1350
+ const resultRequired = value.state === 'completed'
1351
+ || (value.operationKind === 'create-project' && effectivePhase === 'activated')
1352
+ || (value.operationKind === 'join-project' && effectivePhase === 'membership-active')
1353
+ || (value.operationKind === 'remove-member'
1354
+ && (effectivePhase === 'membership-revoked'
1355
+ || effectivePhase === 'personal-ref-removed'));
1356
+ return !statePhaseValid || hasResult !== resultRequired;
1357
+ }
904
1358
  if (value.operationKind === 'leave') {
905
1359
  const statePhaseValid = (value.state === 'active' && (value.phase === 'prepared'
906
1360
  || value.phase === 'membership-left'
@@ -969,13 +1423,42 @@ function validateContinuity(records) {
969
1423
  const leaveReplays = new Map(records
970
1424
  .filter((item) => (item.kind === 'leave-former-principal-replay'))
971
1425
  .map(item => [item.value.operationId, item]));
1426
+ const invitations = new Map(records
1427
+ .filter((item) => (item.kind === 'project-invitation'))
1428
+ .map(item => [item.value.invitationId, item]));
1429
+ const invitationEnvelopes = new Map(records
1430
+ .filter((item) => (item.kind === 'protected-invitation-envelope'))
1431
+ .map(item => [item.value.invitationId, item]));
1432
+ const claimOverrides = records.filter((item) => (item.kind === 'transferred-membership-claim-override'));
1433
+ const claimOverrideEnvelopes = new Map(records
1434
+ .filter((item) => (item.kind === 'protected-claim-override-envelope'))
1435
+ .map(item => [
1436
+ `${item.value.transferId}:${item.value.memberId}:${item.value.claimGeneration}`,
1437
+ item,
1438
+ ]));
1439
+ const managerOffers = records.filter((item) => (item.kind === 'manager-responsibility-offer'));
1440
+ const membershipIdempotencyTombstones = records.filter((item) => (item.kind === 'membership-idempotency-tombstone'));
1441
+ const membershipRecoveries = new Map(records
1442
+ .filter((item) => (item.kind === 'project-membership-recovery'))
1443
+ .map(item => [item.value.operationId, item]));
1444
+ const secretReplayTombstones = records.filter((item) => (item.kind === 'secret-replay-tombstone'));
972
1445
  const nonterminalLifecycles = [...lifecycles.values()].filter(item => (item.value.state === 'active' || item.value.state === 'recovery-required'));
973
1446
  if (nonterminalLifecycles.length > 1
974
1447
  || principalBindingRecords.some(item => (memberRecords.get(item.value.memberId)?.value.status !== 'active'))
975
1448
  || new Set(principalBindingRecords.map(item => item.value.principalId)).size
976
1449
  !== principalBindingRecords.length
977
1450
  || new Set(claimRecords.map(item => (`${item.value.transferId}:${item.value.claimSha256}`))).size !== claimRecords.length
978
- || new Set(redemptionReceiptRecords.map(item => (`${item.value.receipt.transferId}:${item.value.receipt.receiptId}`))).size !== redemptionReceiptRecords.length)
1451
+ || new Set(redemptionReceiptRecords.map(item => (`${item.value.receipt.transferId}:${item.value.receipt.receiptId}`))).size !== redemptionReceiptRecords.length
1452
+ || invitations.size !== records.filter(item => item.kind === 'project-invitation').length
1453
+ || invitationEnvelopes.size
1454
+ !== records.filter(item => item.kind === 'protected-invitation-envelope').length
1455
+ || new Set([...invitations.values()].map(item => (`${item.value.issuedByMemberId}:${item.value.idempotencyKey}`))).size !== invitations.size
1456
+ || new Set(claimOverrides.map(item => (`${item.value.transferId}:${item.value.memberId}:${item.value.claimGeneration}`))).size !== claimOverrides.length
1457
+ || new Set(claimOverrides.map(item => (`${item.value.managerMemberId}:${item.value.idempotencyKey}`))).size !== claimOverrides.length
1458
+ || claimOverrideEnvelopes.size
1459
+ !== records.filter(item => item.kind === 'protected-claim-override-envelope').length
1460
+ || membershipRecoveries.size
1461
+ !== records.filter(item => item.kind === 'project-membership-recovery').length)
979
1462
  throw invalidPayload('records');
980
1463
  for (const lifecycle of lifecycles.values()) {
981
1464
  if (lifecycleJournalHasInvalidSemantics(lifecycle)
@@ -986,6 +1469,11 @@ function validateContinuity(records) {
986
1469
  if (lifecycle.value.operationKind === 'authority-transfer'
987
1470
  && !recoveries.has(lifecycle.value.operationId))
988
1471
  throw invalidPayload('records');
1472
+ if ((lifecycle.value.operationKind === 'create-project'
1473
+ || lifecycle.value.operationKind === 'join-project'
1474
+ || lifecycle.value.operationKind === 'remove-member')
1475
+ && !membershipRecoveries.has(lifecycle.value.operationId))
1476
+ throw invalidPayload('records');
989
1477
  if (lifecycle.value.operationKind === 'leave') {
990
1478
  const actorMemberId = lifecycle.value.actorMemberId;
991
1479
  const actor = actorMemberId === null ? undefined : memberRecords.get(actorMemberId);
@@ -1162,6 +1650,13 @@ function validateContinuity(records) {
1162
1650
  const lifecycle = lifecycles.get(claim.value.transferId);
1163
1651
  const recovery = recoveries.get(claim.value.transferId);
1164
1652
  const redemptionReceipt = redemptionReceipts.get(`${claim.value.transferId}:${claim.value.memberId}`);
1653
+ const redeemedOverride = redemptionReceipt === undefined
1654
+ ? undefined
1655
+ : claimOverrides.find(item => (item.value.transferId === claim.value.transferId
1656
+ && item.value.memberId === claim.value.memberId
1657
+ && item.value.state === 'redeemed'
1658
+ && item.value.claimSha256 === redemptionReceipt.value.receipt.claimSha256
1659
+ && item.value.redemptionReceiptId === redemptionReceipt.value.receipt.receiptId));
1165
1660
  if (memberRecords.get(claim.value.memberId)?.value.status !== 'active'
1166
1661
  || recovery === undefined
1167
1662
  || recovery.value.sourceAuthority.kind !== 'lan'
@@ -1171,7 +1666,11 @@ function validateContinuity(records) {
1171
1666
  || claim.value.expiresAt !== recovery.value.expiresAt
1172
1667
  || (claim.value.targetPrincipalId !== null
1173
1668
  && principalBindings.get(claim.value.memberId) !== claim.value.targetPrincipalId)
1174
- || (claim.value.state === 'redeemed') !== (redemptionReceipt !== undefined)) {
1669
+ || (claim.value.state === 'redeemed' && redemptionReceipt === undefined)
1670
+ || (redemptionReceipt !== undefined
1671
+ && claim.value.state !== 'redeemed'
1672
+ && redeemedOverride === undefined)
1673
+ || (claim.value.state === 'redeemed' && redeemedOverride !== undefined)) {
1175
1674
  throw invalidPayload('records');
1176
1675
  }
1177
1676
  }
@@ -1187,6 +1686,153 @@ function validateContinuity(records) {
1187
1686
  !== responderReplays.has(terminal.value.operationId))
1188
1687
  throw invalidPayload('records');
1189
1688
  }
1689
+ for (const recovery of membershipRecoveries.values()) {
1690
+ const lifecycle = lifecycles.get(recovery.value.operationId);
1691
+ const invitation = recovery.value.invitationId === null
1692
+ ? undefined
1693
+ : invitations.get(recovery.value.invitationId);
1694
+ const member = memberRecords.get(recovery.value.memberId);
1695
+ if (lifecycle === undefined
1696
+ || lifecycle.value.operationKind !== recovery.value.operationKind
1697
+ || lifecycle.value.requestFingerprint !== recovery.value.requestFingerprint
1698
+ || lifecycle.value.state !== 'completed'
1699
+ || member === undefined
1700
+ || (recovery.value.operationKind === 'join-project' && invitation === undefined)
1701
+ || (recovery.value.operationKind !== 'join-project' && invitation !== undefined)
1702
+ || (recovery.value.operationKind === 'create-project'
1703
+ && lifecycle.value.actorMemberId !== null)
1704
+ || (recovery.value.operationKind === 'join-project'
1705
+ && lifecycle.value.actorMemberId !== null)
1706
+ || (recovery.value.operationKind === 'remove-member'
1707
+ && lifecycle.value.actorMemberId === null)
1708
+ || (recovery.value.operationKind === 'join-project'
1709
+ && invitation?.value.state !== 'redeemed')
1710
+ || (recovery.value.operationKind === 'remove-member' && (member.value.status !== 'revoked'
1711
+ || principalBindings.has(recovery.value.memberId))))
1712
+ throw invalidPayload('records');
1713
+ }
1714
+ for (const invitation of invitations.values()) {
1715
+ const envelope = invitationEnvelopes.get(invitation.value.invitationId);
1716
+ const tombstone = secretReplayTombstones.find(item => (item.value.operation === 'createProjectInvitation'
1717
+ && item.value.actorMemberId === invitation.value.issuedByMemberId
1718
+ && item.value.idempotencyKey === invitation.value.idempotencyKey
1719
+ && item.value.requestFingerprint === invitation.value.requestFingerprint));
1720
+ if (!members.has(invitation.value.issuedByMemberId)
1721
+ || (envelope === undefined) === (tombstone === undefined)
1722
+ || (tombstone !== undefined
1723
+ && tombstone.value.expiredAt !== invitation.value.secretReplayExpiresAt)
1724
+ || (envelope !== undefined && (envelope.value.projectId !== invitation.value.projectId
1725
+ || envelope.value.createdAt !== invitation.value.createdAt
1726
+ || envelope.value.expiresAt !== invitation.value.secretReplayExpiresAt)))
1727
+ throw invalidPayload('records');
1728
+ }
1729
+ for (const envelope of invitationEnvelopes.values()) {
1730
+ if (!invitations.has(envelope.value.invitationId))
1731
+ throw invalidPayload('records');
1732
+ }
1733
+ const overridesByMember = new Map();
1734
+ for (const override of claimOverrides) {
1735
+ const identity = `${override.value.transferId}:${override.value.memberId}`;
1736
+ const values = overridesByMember.get(identity) ?? [];
1737
+ values.push(override);
1738
+ overridesByMember.set(identity, values);
1739
+ const envelope = claimOverrideEnvelopes.get(`${identity}:${override.value.claimGeneration}`);
1740
+ const tombstone = secretReplayTombstones.find(item => (item.value.operation === 'reissueTransferredMembershipClaim'
1741
+ && item.value.actorMemberId === override.value.managerMemberId
1742
+ && item.value.idempotencyKey === override.value.idempotencyKey
1743
+ && item.value.requestFingerprint === override.value.requestFingerprint));
1744
+ if (!members.has(override.value.memberId)
1745
+ || !members.has(override.value.managerMemberId)
1746
+ || (envelope === undefined) === (tombstone === undefined)
1747
+ || (tombstone !== undefined
1748
+ && tombstone.value.expiredAt !== override.value.secretReplayExpiresAt)
1749
+ || (envelope !== undefined && (envelope.value.projectId !== override.value.projectId
1750
+ || envelope.value.createdAt !== override.value.createdAt
1751
+ || envelope.value.expiresAt !== override.value.secretReplayExpiresAt)))
1752
+ throw invalidPayload('records');
1753
+ }
1754
+ for (const overrides of overridesByMember.values()) {
1755
+ overrides.sort((left, right) => left.value.claimGeneration - right.value.claimGeneration);
1756
+ const highest = overrides.at(-1);
1757
+ const first = overrides[0];
1758
+ const identity = first === undefined
1759
+ ? ''
1760
+ : `${first.value.transferId}:${first.value.memberId}`;
1761
+ const sourceClaim = claims.get(identity);
1762
+ const recovery = first === undefined ? undefined : recoveries.get(first.value.transferId);
1763
+ const member = first === undefined ? undefined : memberRecords.get(first.value.memberId);
1764
+ const principalBinding = first === undefined
1765
+ ? undefined
1766
+ : principalBindings.get(first.value.memberId);
1767
+ const redemptionReceipt = redemptionReceipts.get(identity);
1768
+ const redeemedOverrides = overrides.filter(item => item.value.state === 'redeemed');
1769
+ if (first === undefined
1770
+ || sourceClaim === undefined
1771
+ || recovery?.value.sourceAuthority.kind !== 'lan'
1772
+ || member?.value.status !== 'active'
1773
+ || sourceClaim.value.state === 'redeemed'
1774
+ || overrides.some((item, index) => item.value.claimGeneration !== index + 1)
1775
+ || overrides.some((item, index) => item.value.supersededClaimSha256 !== (index === 0
1776
+ ? sourceClaim.value.claimSha256
1777
+ : overrides[index - 1].value.claimSha256))
1778
+ || new Set(overrides.map(item => item.value.claimSha256)).size !== overrides.length
1779
+ || overrides.filter(item => item.value.state === 'active').length > 1
1780
+ || overrides.some(item => item.value.state === 'active' && item !== highest)
1781
+ || redeemedOverrides.length > 1
1782
+ || redeemedOverrides.some(item => item !== highest)
1783
+ || highest?.value.state === 'superseded'
1784
+ || (highest?.value.state === 'active' && (principalBinding !== undefined
1785
+ || redemptionReceipt !== undefined))
1786
+ || (highest?.value.state === 'redeemed' && (principalBinding !== highest.value.targetPrincipalId
1787
+ || redemptionReceipt === undefined
1788
+ || redemptionReceipt.value.receipt.transferId !== highest.value.transferId
1789
+ || redemptionReceipt.value.receipt.memberId !== highest.value.memberId
1790
+ || redemptionReceipt.value.receipt.claimSha256 !== highest.value.claimSha256
1791
+ || redemptionReceipt.value.receipt.receiptId !== highest.value.redemptionReceiptId
1792
+ || redemptionReceipt.value.receipt.checkpointSha256
1793
+ !== sourceClaim.value.checkpointSha256))
1794
+ || (highest?.value.state !== 'redeemed' && redemptionReceipt !== undefined))
1795
+ throw invalidPayload('records');
1796
+ }
1797
+ for (const envelope of claimOverrideEnvelopes.values()) {
1798
+ if (!claimOverrides.some(item => (item.value.transferId === envelope.value.transferId
1799
+ && item.value.memberId === envelope.value.memberId
1800
+ && item.value.claimGeneration === envelope.value.claimGeneration)))
1801
+ throw invalidPayload('records');
1802
+ }
1803
+ const currentOffers = managerOffers.filter(item => (item.value.state === 'offered' || item.value.state === 'acknowledged'));
1804
+ if (managerOffers.some(item => (item.value.sourceManagerMemberId === item.value.targetMemberId
1805
+ || !members.has(item.value.sourceManagerMemberId)
1806
+ || !members.has(item.value.targetMemberId)))
1807
+ || new Set(currentOffers.map(item => item.value.sourceManagerMemberId)).size
1808
+ !== currentOffers.length
1809
+ || new Set(currentOffers.map(item => item.value.targetMemberId)).size
1810
+ !== currentOffers.length)
1811
+ throw invalidPayload('records');
1812
+ for (const tombstone of secretReplayTombstones) {
1813
+ const liveSecretRecord = tombstone.value.operation === 'createProjectInvitation'
1814
+ ? [...invitations.values()].some(item => (item.value.issuedByMemberId === tombstone.value.actorMemberId
1815
+ && item.value.idempotencyKey === tombstone.value.idempotencyKey
1816
+ && invitationEnvelopes.has(item.value.invitationId)))
1817
+ : claimOverrides.some(item => (item.value.managerMemberId === tombstone.value.actorMemberId
1818
+ && item.value.idempotencyKey === tombstone.value.idempotencyKey
1819
+ && claimOverrideEnvelopes.has(`${item.value.transferId}:${item.value.memberId}:${item.value.claimGeneration}`)));
1820
+ if (!members.has(tombstone.value.actorMemberId) || liveSecretRecord) {
1821
+ throw invalidPayload('records');
1822
+ }
1823
+ }
1824
+ for (const tombstone of membershipIdempotencyTombstones) {
1825
+ const exactResult = records.some(item => (item.kind === 'idempotency-result'
1826
+ && item.value.memberId === tombstone.value.actorMemberId
1827
+ && item.value.operation === tombstone.value.operation
1828
+ && item.value.idempotencyKey === tombstone.value.idempotencyKey));
1829
+ const exactOffer = tombstone.value.operation === 'createManagerResponsibilityOffer'
1830
+ && managerOffers.some(item => (item.value.sourceManagerMemberId === tombstone.value.actorMemberId
1831
+ && item.value.idempotencyKey === tombstone.value.idempotencyKey));
1832
+ if (!members.has(tombstone.value.actorMemberId) || exactResult || exactOffer) {
1833
+ throw invalidPayload('records');
1834
+ }
1835
+ }
1190
1836
  for (const item of records) {
1191
1837
  if (item.kind === 'idempotency-result' && !members.has(item.value.memberId)) {
1192
1838
  throw invalidPayload('records');
@@ -1238,6 +1884,19 @@ function validateContinuity(records) {
1238
1884
  const lifecycle = lifecycles.get(receipt.transferId);
1239
1885
  const terminalPrincipal = terminalPrincipals.get(identity);
1240
1886
  const lanToCloud = recovery?.value.sourceAuthority.kind === 'lan';
1887
+ const redeemedOverride = claimOverrides.find(item => (item.value.transferId === receipt.transferId
1888
+ && item.value.memberId === receipt.memberId
1889
+ && item.value.state === 'redeemed'
1890
+ && item.value.claimSha256 === receipt.claimSha256
1891
+ && item.value.redemptionReceiptId === receipt.receiptId));
1892
+ const matchesSourceClaim = claim?.value.state === 'redeemed'
1893
+ && claim.value.claimSha256 === receipt.claimSha256
1894
+ && claim.value.checkpointSha256 === receipt.checkpointSha256
1895
+ && claim.value.operationIntentId === receipt.operationIntentId
1896
+ && claim.value.redemptionReceiptId === receipt.receiptId;
1897
+ const matchesOverride = claim !== undefined
1898
+ && redeemedOverride !== undefined
1899
+ && claim.value.checkpointSha256 === receipt.checkpointSha256;
1241
1900
  if (recovery === undefined
1242
1901
  || lifecycle === undefined
1243
1902
  || memberRecords.get(receipt.memberId)?.value.status !== 'active'
@@ -1245,11 +1904,7 @@ function validateContinuity(records) {
1245
1904
  || !keys.has(`${receipt.transferId}:${receipt.receiptKeyId}`)
1246
1905
  || Date.parse(receipt.redeemedAt) > Date.parse(recovery.value.expiresAt)
1247
1906
  || (lanToCloud && (item.value.acknowledgedAt !== null
1248
- || claim === undefined
1249
- || claim.value.claimSha256 !== receipt.claimSha256
1250
- || claim.value.checkpointSha256 !== receipt.checkpointSha256
1251
- || claim.value.operationIntentId !== receipt.operationIntentId
1252
- || claim.value.redemptionReceiptId !== receipt.receiptId))
1907
+ || (!matchesSourceClaim && !matchesOverride)))
1253
1908
  || (!lanToCloud && (item.value.acknowledgedAt === null
1254
1909
  || receipt.memberId === recovery.value.targetHostMemberId
1255
1910
  || receipt.checkpointSha256 !== lifecycle.value.checkpointSha256
@@ -1444,6 +2099,33 @@ export function encodeCollabProjectBackupCheckpointCoordinationNdjson(records) {
1444
2099
  export function validateCollabProjectBackupCheckpointConsistency(manifest, records) {
1445
2100
  const decodedManifest = decodeCollabProjectBackupCheckpointManifest(manifest);
1446
2101
  const decodedRecords = decodeCollabProjectBackupCheckpointCoordinationNdjson(records.map(item => JSON.stringify(item)).join('\n') + '\n');
2102
+ const invitationEnvelopeIds = new Set(decodedRecords
2103
+ .filter(item => item.kind === 'protected-invitation-envelope')
2104
+ .map(item => item.recordId));
2105
+ const claimOverrideEnvelopeIds = new Set(decodedRecords
2106
+ .filter(item => item.kind === 'protected-claim-override-envelope')
2107
+ .map(item => item.recordId));
2108
+ const captureTime = Date.parse(decodedManifest.createdAt);
2109
+ if (decodedRecords.some(item => {
2110
+ if (item.kind === 'secret-replay-tombstone') {
2111
+ return Date.parse(item.value.expiredAt) > captureTime;
2112
+ }
2113
+ if (item.kind === 'membership-idempotency-tombstone') {
2114
+ return Date.parse(item.value.compactedAt) > captureTime;
2115
+ }
2116
+ if (item.kind === 'project-invitation') {
2117
+ return captureTime < Date.parse(item.value.createdAt)
2118
+ || (captureTime < Date.parse(item.value.secretReplayExpiresAt))
2119
+ !== invitationEnvelopeIds.has(item.recordId);
2120
+ }
2121
+ if (item.kind === 'transferred-membership-claim-override') {
2122
+ return captureTime < Date.parse(item.value.createdAt)
2123
+ || (captureTime < Date.parse(item.value.secretReplayExpiresAt))
2124
+ !== claimOverrideEnvelopeIds.has(item.recordId);
2125
+ }
2126
+ return false;
2127
+ }))
2128
+ throw invalidPayload('records');
1447
2129
  if (decodedRecords.some(item => {
1448
2130
  switch (item.kind) {
1449
2131
  case 'lifecycle-journal': return item.value.operationId === decodedManifest.operationId;