@claudian-collab/protocol 3.2.1 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.COLLAB_PROJECT_BACKUP_RECORD_KINDS = exports.COLLAB_PROJECT_BACKUP_COORDINATION_FORMAT_VERSION = void 0;
3
+ exports.COLLAB_PROJECT_BACKUP_RECORD_KINDS = exports.COLLAB_PROJECT_BACKUP_COORDINATION_FORMAT_VERSION = exports.COLLAB_PROJECT_BACKUP_COMPATIBILITY_STAGE = void 0;
4
4
  exports.collabProjectBackupIdempotencyRecordId = collabProjectBackupIdempotencyRecordId;
5
5
  exports.decodeCollabProjectBackupCheckpointManifest = decodeCollabProjectBackupCheckpointManifest;
6
6
  exports.encodeCollabProjectBackupCheckpointManifestCanonicalJson = encodeCollabProjectBackupCheckpointManifestCanonicalJson;
@@ -13,8 +13,10 @@ const CollabConstants_1 = require("./CollabConstants");
13
13
  const CollabControlOperationCodecs_1 = require("./CollabControlOperationCodecs");
14
14
  const CollabError_1 = require("./CollabError");
15
15
  const CollabProjectCheckpoint_1 = require("./CollabProjectCheckpoint");
16
+ const CollabProjectMembership_1 = require("./CollabProjectMembership");
16
17
  const CollabValidation_1 = require("./CollabValidation");
17
- exports.COLLAB_PROJECT_BACKUP_COORDINATION_FORMAT_VERSION = 2;
18
+ exports.COLLAB_PROJECT_BACKUP_COMPATIBILITY_STAGE = 'pre-production-replaceable';
19
+ exports.COLLAB_PROJECT_BACKUP_COORDINATION_FORMAT_VERSION = 3;
18
20
  const BACKUP_CONTINUITY_RECORD_KINDS = Object.freeze([
19
21
  'lifecycle-journal',
20
22
  'authority-transfer-recovery',
@@ -25,11 +27,28 @@ const BACKUP_CONTINUITY_RECORD_KINDS = Object.freeze([
25
27
  'terminal-principal',
26
28
  'terminal-responder-replay',
27
29
  'leave-former-principal-replay',
30
+ 'project-invitation',
31
+ 'protected-invitation-envelope',
32
+ 'transferred-membership-claim-override',
33
+ 'protected-claim-override-envelope',
34
+ 'manager-responsibility-offer',
35
+ 'project-membership-recovery',
36
+ 'secret-replay-tombstone',
28
37
  ]);
29
38
  exports.COLLAB_PROJECT_BACKUP_RECORD_KINDS = Object.freeze([
30
- ...CollabProjectCheckpoint_1.COLLAB_CHECKPOINT_BACKUP_RECORD_KINDS.slice(0, 13),
39
+ ...CollabProjectCheckpoint_1.COLLAB_CHECKPOINT_PORTABLE_RECORD_KINDS,
40
+ 'cloud-event',
41
+ 'cloud-event-cursor',
42
+ 'idempotency-result',
43
+ 'principal-binding',
44
+ 'repository-placement',
31
45
  ...BACKUP_CONTINUITY_RECORD_KINDS,
32
- ...CollabProjectCheckpoint_1.COLLAB_CHECKPOINT_BACKUP_RECORD_KINDS.slice(14),
46
+ 'terminal-responder',
47
+ 'protected-claim-envelope',
48
+ 'tombstone',
49
+ 'schema-catalog',
50
+ 'server-compatibility',
51
+ 'authority-volume-pair',
33
52
  ]);
34
53
  const CONTINUITY_KIND_SET = new Set(BACKUP_CONTINUITY_RECORD_KINDS);
35
54
  const BACKUP_KIND_SET = new Set(exports.COLLAB_PROJECT_BACKUP_RECORD_KINDS);
@@ -59,7 +78,9 @@ const DELETE_ACTIVE_PHASE_SET = new Set([
59
78
  'tombstoned',
60
79
  ]);
61
80
  const PLAINTEXT_CLAIM_RESPONSE_OPERATION_SET = new Set([
81
+ 'createProjectInvitation',
62
82
  'getTransferredMembershipClaim',
83
+ 'reissueTransferredMembershipClaim',
63
84
  'rotateTransferredMembershipClaims',
64
85
  ]);
65
86
  function invalidPayload(field) {
@@ -117,6 +138,9 @@ function timestampValue(value, field) {
117
138
  function timestamp(source, field) {
118
139
  return timestampValue(source[field], field);
119
140
  }
141
+ function hasExactDuration(start, end, durationMs) {
142
+ return Date.parse(end) === Date.parse(start) + durationMs;
143
+ }
120
144
  function nullableTimestamp(source, field) {
121
145
  return source[field] === null ? null : timestampValue(source[field], field);
122
146
  }
@@ -467,7 +491,15 @@ function lifecycleJournalRecord(source, recordId, revision) {
467
491
  if (recordId !== operationId)
468
492
  throw invalidPayload('recordId');
469
493
  const operationKind = literal(value, 'operationKind', [
470
- 'authority-transfer', 'backup', 'delete', 'export', 'leave', 'retire',
494
+ 'authority-transfer',
495
+ 'backup',
496
+ 'create-project',
497
+ 'delete',
498
+ 'export',
499
+ 'join-project',
500
+ 'leave',
501
+ 'remove-member',
502
+ 'retire',
471
503
  ]);
472
504
  const direction = value.direction === null
473
505
  ? null
@@ -838,6 +870,350 @@ function leaveFormerPrincipalReplayRecord(source, recordId, revision) {
838
870
  },
839
871
  };
840
872
  }
873
+ function projectInvitationRecord(source, recordId, revision) {
874
+ const value = exactRecord(source.value, 'value', [
875
+ 'createdAt',
876
+ 'expiresAt',
877
+ 'idempotencyKey',
878
+ 'invitationId',
879
+ 'issuedByMemberId',
880
+ 'projectId',
881
+ 'requestFingerprint',
882
+ 'revision',
883
+ 'secretReplayExpiresAt',
884
+ 'secretSha256',
885
+ 'state',
886
+ 'terminalAt',
887
+ ]);
888
+ const invitationId = token(value, 'invitationId');
889
+ if (recordId !== invitationId)
890
+ throw invalidPayload('recordId');
891
+ const createdAt = timestamp(value, 'createdAt');
892
+ const expiresAt = timestamp(value, 'expiresAt');
893
+ const secretReplayExpiresAt = timestamp(value, 'secretReplayExpiresAt');
894
+ const state = literal(value, 'state', [
895
+ 'active', 'redeeming', 'redeemed', 'revoked', 'expired',
896
+ ]);
897
+ const terminalAt = nullableTimestamp(value, 'terminalAt');
898
+ const terminal = state === 'redeemed' || state === 'revoked' || state === 'expired';
899
+ if (!hasExactDuration(createdAt, expiresAt, CollabProjectMembership_1.COLLAB_PROJECT_MEMBERSHIP_LIMITS.invitationTtlMs)
900
+ || !hasExactDuration(createdAt, secretReplayExpiresAt, CollabProjectMembership_1.COLLAB_PROJECT_MEMBERSHIP_LIMITS.secretReplayTtlMs)
901
+ || terminal !== (terminalAt !== null)
902
+ || (terminalAt !== null && Date.parse(terminalAt) < Date.parse(createdAt)))
903
+ throw invalidPayload('state');
904
+ return {
905
+ kind: 'project-invitation',
906
+ recordId,
907
+ revision,
908
+ value: {
909
+ createdAt,
910
+ expiresAt,
911
+ idempotencyKey: token(value, 'idempotencyKey'),
912
+ invitationId,
913
+ issuedByMemberId: token(value, 'issuedByMemberId', CollabValidation_1.isCollabMemberId),
914
+ projectId: token(value, 'projectId', CollabValidation_1.isCollabProjectId),
915
+ requestFingerprint: sha256(value, 'requestFingerprint'),
916
+ revision: positiveInteger(value, 'revision'),
917
+ secretReplayExpiresAt,
918
+ secretSha256: sha256(value, 'secretSha256'),
919
+ state,
920
+ terminalAt,
921
+ },
922
+ };
923
+ }
924
+ function protectedSecretEnvelopeFields(value) {
925
+ const ciphertext = boundedString(value, 'ciphertext', 4096);
926
+ const nonce = boundedString(value, 'nonce', 128);
927
+ if (!BASE64URL_PATTERN.test(ciphertext) || !BASE64URL_PATTERN.test(nonce)) {
928
+ throw invalidPayload('ciphertext');
929
+ }
930
+ const createdAt = timestamp(value, 'createdAt');
931
+ const expiresAt = timestamp(value, 'expiresAt');
932
+ if (Date.parse(expiresAt) <= Date.parse(createdAt))
933
+ throw invalidPayload('expiresAt');
934
+ return {
935
+ associatedDataSha256: sha256(value, 'associatedDataSha256'),
936
+ ciphertext,
937
+ createdAt,
938
+ expiresAt,
939
+ keyId: token(value, 'keyId'),
940
+ nonce,
941
+ projectId: token(value, 'projectId', CollabValidation_1.isCollabProjectId),
942
+ };
943
+ }
944
+ function protectedInvitationEnvelopeRecord(source, recordId, revision) {
945
+ const value = exactRecord(source.value, 'value', [
946
+ 'associatedDataSha256',
947
+ 'ciphertext',
948
+ 'createdAt',
949
+ 'expiresAt',
950
+ 'invitationId',
951
+ 'keyId',
952
+ 'nonce',
953
+ 'projectId',
954
+ ]);
955
+ const invitationId = token(value, 'invitationId');
956
+ if (recordId !== invitationId)
957
+ throw invalidPayload('recordId');
958
+ const envelope = protectedSecretEnvelopeFields(value);
959
+ return {
960
+ kind: 'protected-invitation-envelope',
961
+ recordId,
962
+ revision,
963
+ value: {
964
+ associatedDataSha256: envelope.associatedDataSha256,
965
+ ciphertext: envelope.ciphertext,
966
+ createdAt: envelope.createdAt,
967
+ expiresAt: envelope.expiresAt,
968
+ invitationId,
969
+ keyId: envelope.keyId,
970
+ nonce: envelope.nonce,
971
+ projectId: envelope.projectId,
972
+ },
973
+ };
974
+ }
975
+ function transferredMembershipClaimOverrideRecord(source, recordId, revision) {
976
+ const value = exactRecord(source.value, 'value', [
977
+ 'claimGeneration',
978
+ 'claimSha256',
979
+ 'createdAt',
980
+ 'expiresAt',
981
+ 'idempotencyKey',
982
+ 'managerMemberId',
983
+ 'memberId',
984
+ 'projectId',
985
+ 'redemptionReceiptId',
986
+ 'requestFingerprint',
987
+ 'secretReplayExpiresAt',
988
+ 'state',
989
+ 'supersededClaimSha256',
990
+ 'targetPrincipalId',
991
+ 'transferId',
992
+ 'updatedAt',
993
+ ]);
994
+ const transferId = token(value, 'transferId');
995
+ const memberId = token(value, 'memberId', CollabValidation_1.isCollabMemberId);
996
+ const claimGeneration = positiveInteger(value, 'claimGeneration');
997
+ if (recordId !== `${transferId}:${memberId}:${claimGeneration}`) {
998
+ throw invalidPayload('recordId');
999
+ }
1000
+ const state = literal(value, 'state', [
1001
+ 'active', 'redeemed', 'revoked', 'superseded', 'expired',
1002
+ ]);
1003
+ const targetPrincipalId = nullableToken(value, 'targetPrincipalId', candidate => (typeof candidate === 'string' && PRINCIPAL_PATTERN.test(candidate)));
1004
+ const redemptionReceiptId = nullableToken(value, 'redemptionReceiptId');
1005
+ if ((state === 'redeemed') !== (targetPrincipalId !== null && redemptionReceiptId !== null)
1006
+ || (state !== 'redeemed' && (targetPrincipalId !== null || redemptionReceiptId !== null)))
1007
+ throw invalidPayload('state');
1008
+ const createdAt = timestamp(value, 'createdAt');
1009
+ const expiresAt = timestamp(value, 'expiresAt');
1010
+ const secretReplayExpiresAt = timestamp(value, 'secretReplayExpiresAt');
1011
+ const updatedAt = timestamp(value, 'updatedAt');
1012
+ if (!hasExactDuration(createdAt, expiresAt, CollabProjectMembership_1.COLLAB_PROJECT_MEMBERSHIP_LIMITS.transferredClaimTtlMs)
1013
+ || !hasExactDuration(createdAt, secretReplayExpiresAt, CollabProjectMembership_1.COLLAB_PROJECT_MEMBERSHIP_LIMITS.secretReplayTtlMs)
1014
+ || Date.parse(updatedAt) < Date.parse(createdAt))
1015
+ throw invalidPayload('updatedAt');
1016
+ return {
1017
+ kind: 'transferred-membership-claim-override',
1018
+ recordId,
1019
+ revision,
1020
+ value: {
1021
+ claimGeneration,
1022
+ claimSha256: sha256(value, 'claimSha256'),
1023
+ createdAt,
1024
+ expiresAt,
1025
+ idempotencyKey: token(value, 'idempotencyKey'),
1026
+ managerMemberId: token(value, 'managerMemberId', CollabValidation_1.isCollabMemberId),
1027
+ memberId,
1028
+ projectId: token(value, 'projectId', CollabValidation_1.isCollabProjectId),
1029
+ redemptionReceiptId,
1030
+ requestFingerprint: sha256(value, 'requestFingerprint'),
1031
+ secretReplayExpiresAt,
1032
+ state,
1033
+ supersededClaimSha256: sha256(value, 'supersededClaimSha256'),
1034
+ targetPrincipalId,
1035
+ transferId,
1036
+ updatedAt,
1037
+ },
1038
+ };
1039
+ }
1040
+ function protectedClaimOverrideEnvelopeRecord(source, recordId, revision) {
1041
+ const value = exactRecord(source.value, 'value', [
1042
+ 'associatedDataSha256',
1043
+ 'ciphertext',
1044
+ 'claimGeneration',
1045
+ 'createdAt',
1046
+ 'expiresAt',
1047
+ 'keyId',
1048
+ 'memberId',
1049
+ 'nonce',
1050
+ 'projectId',
1051
+ 'transferId',
1052
+ ]);
1053
+ const transferId = token(value, 'transferId');
1054
+ const memberId = token(value, 'memberId', CollabValidation_1.isCollabMemberId);
1055
+ const claimGeneration = positiveInteger(value, 'claimGeneration');
1056
+ if (recordId !== `${transferId}:${memberId}:${claimGeneration}`) {
1057
+ throw invalidPayload('recordId');
1058
+ }
1059
+ const envelope = protectedSecretEnvelopeFields(value);
1060
+ return {
1061
+ kind: 'protected-claim-override-envelope',
1062
+ recordId,
1063
+ revision,
1064
+ value: {
1065
+ associatedDataSha256: envelope.associatedDataSha256,
1066
+ ciphertext: envelope.ciphertext,
1067
+ claimGeneration,
1068
+ createdAt: envelope.createdAt,
1069
+ expiresAt: envelope.expiresAt,
1070
+ keyId: envelope.keyId,
1071
+ memberId,
1072
+ nonce: envelope.nonce,
1073
+ projectId: envelope.projectId,
1074
+ transferId,
1075
+ },
1076
+ };
1077
+ }
1078
+ function managerResponsibilityOfferRecord(source, recordId, revision) {
1079
+ const value = exactRecord(source.value, 'value', [
1080
+ 'acknowledgedAt',
1081
+ 'expiresAt',
1082
+ 'idempotencyKey',
1083
+ 'managerSetGenerationAtOffer',
1084
+ 'offeredAt',
1085
+ 'offerId',
1086
+ 'projectId',
1087
+ 'purpose',
1088
+ 'requestFingerprint',
1089
+ 'revision',
1090
+ 'sourceManagerMemberId',
1091
+ 'state',
1092
+ 'targetMemberId',
1093
+ 'targetMembershipRevisionAtOffer',
1094
+ 'terminalAt',
1095
+ ]);
1096
+ const offerId = token(value, 'offerId');
1097
+ if (recordId !== offerId)
1098
+ throw invalidPayload('recordId');
1099
+ const state = literal(value, 'state', [
1100
+ 'offered', 'acknowledged', 'declined', 'cancelled', 'consumed', 'expired',
1101
+ ]);
1102
+ const offeredAt = timestamp(value, 'offeredAt');
1103
+ const expiresAt = timestamp(value, 'expiresAt');
1104
+ const acknowledgedAt = nullableTimestamp(value, 'acknowledgedAt');
1105
+ const terminalAt = nullableTimestamp(value, 'terminalAt');
1106
+ const terminal = state === 'declined'
1107
+ || state === 'cancelled'
1108
+ || state === 'consumed'
1109
+ || state === 'expired';
1110
+ if (!hasExactDuration(offeredAt, expiresAt, CollabProjectMembership_1.COLLAB_PROJECT_MEMBERSHIP_LIMITS.managerResponsibilityOfferTtlMs)
1111
+ || (state === 'offered' && acknowledgedAt !== null)
1112
+ || (state === 'acknowledged' && acknowledgedAt === null)
1113
+ || terminal !== (terminalAt !== null))
1114
+ throw invalidPayload('state');
1115
+ return {
1116
+ kind: 'manager-responsibility-offer',
1117
+ recordId,
1118
+ revision,
1119
+ value: {
1120
+ acknowledgedAt,
1121
+ expiresAt,
1122
+ idempotencyKey: token(value, 'idempotencyKey'),
1123
+ managerSetGenerationAtOffer: positiveInteger(value, 'managerSetGenerationAtOffer'),
1124
+ offeredAt,
1125
+ offerId,
1126
+ projectId: token(value, 'projectId', CollabValidation_1.isCollabProjectId),
1127
+ purpose: literal(value, 'purpose', ['manager-promotion', 'manager-leave']),
1128
+ requestFingerprint: sha256(value, 'requestFingerprint'),
1129
+ revision: positiveInteger(value, 'revision'),
1130
+ sourceManagerMemberId: token(value, 'sourceManagerMemberId', CollabValidation_1.isCollabMemberId),
1131
+ state,
1132
+ targetMemberId: token(value, 'targetMemberId', CollabValidation_1.isCollabMemberId),
1133
+ targetMembershipRevisionAtOffer: positiveInteger(value, 'targetMembershipRevisionAtOffer'),
1134
+ terminalAt,
1135
+ },
1136
+ };
1137
+ }
1138
+ function membershipRecoveryRecord(source, recordId, revision) {
1139
+ const value = exactRecord(source.value, 'value', [
1140
+ 'expectedMainOid',
1141
+ 'expectedPersonalRefOid',
1142
+ 'invitationId',
1143
+ 'memberId',
1144
+ 'operationId',
1145
+ 'operationKind',
1146
+ 'principalSha256',
1147
+ 'projectId',
1148
+ 'publicationMarkerSha256',
1149
+ 'repositoryPlanSha256',
1150
+ 'requestFingerprint',
1151
+ ]);
1152
+ const operationId = token(value, 'operationId');
1153
+ if (recordId !== operationId)
1154
+ throw invalidPayload('recordId');
1155
+ const operationKind = literal(value, 'operationKind', [
1156
+ 'create-project', 'join-project', 'remove-member',
1157
+ ]);
1158
+ const invitationId = nullableToken(value, 'invitationId');
1159
+ const principalSha256 = nullableSha256(value, 'principalSha256');
1160
+ const publicationMarkerSha256 = nullableSha256(value, 'publicationMarkerSha256');
1161
+ const repositoryPlanSha256 = nullableSha256(value, 'repositoryPlanSha256');
1162
+ if ((operationKind === 'join-project') !== (invitationId !== null)
1163
+ || (operationKind === 'remove-member') === (principalSha256 !== null)
1164
+ || (operationKind === 'create-project') !== (repositoryPlanSha256 !== null)
1165
+ || (operationKind !== 'create-project' && publicationMarkerSha256 !== null))
1166
+ throw invalidPayload('operationKind');
1167
+ return {
1168
+ kind: 'project-membership-recovery',
1169
+ recordId,
1170
+ revision,
1171
+ value: {
1172
+ expectedMainOid: token(value, 'expectedMainOid', CollabValidation_1.isCollabGitOid),
1173
+ expectedPersonalRefOid: token(value, 'expectedPersonalRefOid', CollabValidation_1.isCollabGitOid),
1174
+ invitationId,
1175
+ memberId: token(value, 'memberId', CollabValidation_1.isCollabMemberId),
1176
+ operationId,
1177
+ operationKind,
1178
+ principalSha256,
1179
+ projectId: token(value, 'projectId', CollabValidation_1.isCollabProjectId),
1180
+ publicationMarkerSha256,
1181
+ repositoryPlanSha256,
1182
+ requestFingerprint: sha256(value, 'requestFingerprint'),
1183
+ },
1184
+ };
1185
+ }
1186
+ function secretReplayTombstoneRecord(source, recordId, revision) {
1187
+ const value = exactRecord(source.value, 'value', [
1188
+ 'actorMemberId',
1189
+ 'expiredAt',
1190
+ 'idempotencyKey',
1191
+ 'operation',
1192
+ 'projectId',
1193
+ 'requestFingerprint',
1194
+ ]);
1195
+ const actorMemberId = token(value, 'actorMemberId', CollabValidation_1.isCollabMemberId);
1196
+ const idempotencyKey = token(value, 'idempotencyKey');
1197
+ const operation = literal(value, 'operation', [
1198
+ 'createProjectInvitation', 'reissueTransferredMembershipClaim',
1199
+ ]);
1200
+ if (recordId !== `${operation}:${actorMemberId}:${idempotencyKey}`) {
1201
+ throw invalidPayload('recordId');
1202
+ }
1203
+ return {
1204
+ kind: 'secret-replay-tombstone',
1205
+ recordId,
1206
+ revision,
1207
+ value: {
1208
+ actorMemberId,
1209
+ expiredAt: timestamp(value, 'expiredAt'),
1210
+ idempotencyKey,
1211
+ operation,
1212
+ projectId: token(value, 'projectId', CollabValidation_1.isCollabProjectId),
1213
+ requestFingerprint: sha256(value, 'requestFingerprint'),
1214
+ },
1215
+ };
1216
+ }
841
1217
  function decodeContinuityRecord(kind, source, recordId, revision) {
842
1218
  switch (kind) {
843
1219
  case 'lifecycle-journal': return lifecycleJournalRecord(source, recordId, revision);
@@ -855,6 +1231,19 @@ function decodeContinuityRecord(kind, source, recordId, revision) {
855
1231
  return terminalResponderReplayRecord(source, recordId, revision);
856
1232
  case 'leave-former-principal-replay':
857
1233
  return leaveFormerPrincipalReplayRecord(source, recordId, revision);
1234
+ case 'project-invitation': return projectInvitationRecord(source, recordId, revision);
1235
+ case 'protected-invitation-envelope':
1236
+ return protectedInvitationEnvelopeRecord(source, recordId, revision);
1237
+ case 'transferred-membership-claim-override':
1238
+ return transferredMembershipClaimOverrideRecord(source, recordId, revision);
1239
+ case 'protected-claim-override-envelope':
1240
+ return protectedClaimOverrideEnvelopeRecord(source, recordId, revision);
1241
+ case 'manager-responsibility-offer':
1242
+ return managerResponsibilityOfferRecord(source, recordId, revision);
1243
+ case 'project-membership-recovery':
1244
+ return membershipRecoveryRecord(source, recordId, revision);
1245
+ case 'secret-replay-tombstone':
1246
+ return secretReplayTombstoneRecord(source, recordId, revision);
858
1247
  }
859
1248
  }
860
1249
  function kindOrder(kind) {
@@ -911,6 +1300,33 @@ function lifecycleJournalHasInvalidSemantics(item) {
911
1300
  }
912
1301
  if (hasCheckpoint)
913
1302
  return true;
1303
+ if (value.operationKind === 'create-project'
1304
+ || value.operationKind === 'join-project'
1305
+ || value.operationKind === 'remove-member') {
1306
+ const phases = value.operationKind === 'create-project'
1307
+ ? new Set([
1308
+ 'prepared',
1309
+ 'repository-publication-intent',
1310
+ 'repository-published',
1311
+ 'activated',
1312
+ ])
1313
+ : value.operationKind === 'join-project'
1314
+ ? new Set(['prepared', 'membership-pending', 'personal-ref-created', 'membership-active'])
1315
+ : new Set(['prepared', 'membership-revoked', 'personal-ref-removed']);
1316
+ const statePhaseValid = (value.state === 'active' && phases.has(value.phase))
1317
+ || (value.state === 'recovery-required'
1318
+ && value.phase === 'recovery-required'
1319
+ && value.recoveryFromPhase !== null
1320
+ && phases.has(value.recoveryFromPhase))
1321
+ || (value.state === 'completed' && value.phase === 'completed');
1322
+ const resultRequired = value.state === 'completed'
1323
+ || (value.operationKind === 'create-project' && effectivePhase === 'activated')
1324
+ || (value.operationKind === 'join-project' && effectivePhase === 'membership-active')
1325
+ || (value.operationKind === 'remove-member'
1326
+ && (effectivePhase === 'membership-revoked'
1327
+ || effectivePhase === 'personal-ref-removed'));
1328
+ return !statePhaseValid || hasResult !== resultRequired;
1329
+ }
914
1330
  if (value.operationKind === 'leave') {
915
1331
  const statePhaseValid = (value.state === 'active' && (value.phase === 'prepared'
916
1332
  || value.phase === 'membership-left'
@@ -979,13 +1395,41 @@ function validateContinuity(records) {
979
1395
  const leaveReplays = new Map(records
980
1396
  .filter((item) => (item.kind === 'leave-former-principal-replay'))
981
1397
  .map(item => [item.value.operationId, item]));
1398
+ const invitations = new Map(records
1399
+ .filter((item) => (item.kind === 'project-invitation'))
1400
+ .map(item => [item.value.invitationId, item]));
1401
+ const invitationEnvelopes = new Map(records
1402
+ .filter((item) => (item.kind === 'protected-invitation-envelope'))
1403
+ .map(item => [item.value.invitationId, item]));
1404
+ const claimOverrides = records.filter((item) => (item.kind === 'transferred-membership-claim-override'));
1405
+ const claimOverrideEnvelopes = new Map(records
1406
+ .filter((item) => (item.kind === 'protected-claim-override-envelope'))
1407
+ .map(item => [
1408
+ `${item.value.transferId}:${item.value.memberId}:${item.value.claimGeneration}`,
1409
+ item,
1410
+ ]));
1411
+ const managerOffers = records.filter((item) => (item.kind === 'manager-responsibility-offer'));
1412
+ const membershipRecoveries = new Map(records
1413
+ .filter((item) => (item.kind === 'project-membership-recovery'))
1414
+ .map(item => [item.value.operationId, item]));
1415
+ const secretReplayTombstones = records.filter((item) => (item.kind === 'secret-replay-tombstone'));
982
1416
  const nonterminalLifecycles = [...lifecycles.values()].filter(item => (item.value.state === 'active' || item.value.state === 'recovery-required'));
983
1417
  if (nonterminalLifecycles.length > 1
984
1418
  || principalBindingRecords.some(item => (memberRecords.get(item.value.memberId)?.value.status !== 'active'))
985
1419
  || new Set(principalBindingRecords.map(item => item.value.principalId)).size
986
1420
  !== principalBindingRecords.length
987
1421
  || new Set(claimRecords.map(item => (`${item.value.transferId}:${item.value.claimSha256}`))).size !== claimRecords.length
988
- || new Set(redemptionReceiptRecords.map(item => (`${item.value.receipt.transferId}:${item.value.receipt.receiptId}`))).size !== redemptionReceiptRecords.length)
1422
+ || new Set(redemptionReceiptRecords.map(item => (`${item.value.receipt.transferId}:${item.value.receipt.receiptId}`))).size !== redemptionReceiptRecords.length
1423
+ || invitations.size !== records.filter(item => item.kind === 'project-invitation').length
1424
+ || invitationEnvelopes.size
1425
+ !== records.filter(item => item.kind === 'protected-invitation-envelope').length
1426
+ || new Set([...invitations.values()].map(item => (`${item.value.issuedByMemberId}:${item.value.idempotencyKey}`))).size !== invitations.size
1427
+ || new Set(claimOverrides.map(item => (`${item.value.transferId}:${item.value.memberId}:${item.value.claimGeneration}`))).size !== claimOverrides.length
1428
+ || new Set(claimOverrides.map(item => (`${item.value.managerMemberId}:${item.value.idempotencyKey}`))).size !== claimOverrides.length
1429
+ || claimOverrideEnvelopes.size
1430
+ !== records.filter(item => item.kind === 'protected-claim-override-envelope').length
1431
+ || membershipRecoveries.size
1432
+ !== records.filter(item => item.kind === 'project-membership-recovery').length)
989
1433
  throw invalidPayload('records');
990
1434
  for (const lifecycle of lifecycles.values()) {
991
1435
  if (lifecycleJournalHasInvalidSemantics(lifecycle)
@@ -996,6 +1440,11 @@ function validateContinuity(records) {
996
1440
  if (lifecycle.value.operationKind === 'authority-transfer'
997
1441
  && !recoveries.has(lifecycle.value.operationId))
998
1442
  throw invalidPayload('records');
1443
+ if ((lifecycle.value.operationKind === 'create-project'
1444
+ || lifecycle.value.operationKind === 'join-project'
1445
+ || lifecycle.value.operationKind === 'remove-member')
1446
+ && !membershipRecoveries.has(lifecycle.value.operationId))
1447
+ throw invalidPayload('records');
999
1448
  if (lifecycle.value.operationKind === 'leave') {
1000
1449
  const actorMemberId = lifecycle.value.actorMemberId;
1001
1450
  const actor = actorMemberId === null ? undefined : memberRecords.get(actorMemberId);
@@ -1172,6 +1621,13 @@ function validateContinuity(records) {
1172
1621
  const lifecycle = lifecycles.get(claim.value.transferId);
1173
1622
  const recovery = recoveries.get(claim.value.transferId);
1174
1623
  const redemptionReceipt = redemptionReceipts.get(`${claim.value.transferId}:${claim.value.memberId}`);
1624
+ const redeemedOverride = redemptionReceipt === undefined
1625
+ ? undefined
1626
+ : claimOverrides.find(item => (item.value.transferId === claim.value.transferId
1627
+ && item.value.memberId === claim.value.memberId
1628
+ && item.value.state === 'redeemed'
1629
+ && item.value.claimSha256 === redemptionReceipt.value.receipt.claimSha256
1630
+ && item.value.redemptionReceiptId === redemptionReceipt.value.receipt.receiptId));
1175
1631
  if (memberRecords.get(claim.value.memberId)?.value.status !== 'active'
1176
1632
  || recovery === undefined
1177
1633
  || recovery.value.sourceAuthority.kind !== 'lan'
@@ -1181,7 +1637,11 @@ function validateContinuity(records) {
1181
1637
  || claim.value.expiresAt !== recovery.value.expiresAt
1182
1638
  || (claim.value.targetPrincipalId !== null
1183
1639
  && principalBindings.get(claim.value.memberId) !== claim.value.targetPrincipalId)
1184
- || (claim.value.state === 'redeemed') !== (redemptionReceipt !== undefined)) {
1640
+ || (claim.value.state === 'redeemed' && redemptionReceipt === undefined)
1641
+ || (redemptionReceipt !== undefined
1642
+ && claim.value.state !== 'redeemed'
1643
+ && redeemedOverride === undefined)
1644
+ || (claim.value.state === 'redeemed' && redeemedOverride !== undefined)) {
1185
1645
  throw invalidPayload('records');
1186
1646
  }
1187
1647
  }
@@ -1197,6 +1657,141 @@ function validateContinuity(records) {
1197
1657
  !== responderReplays.has(terminal.value.operationId))
1198
1658
  throw invalidPayload('records');
1199
1659
  }
1660
+ for (const recovery of membershipRecoveries.values()) {
1661
+ const lifecycle = lifecycles.get(recovery.value.operationId);
1662
+ const invitation = recovery.value.invitationId === null
1663
+ ? undefined
1664
+ : invitations.get(recovery.value.invitationId);
1665
+ const member = memberRecords.get(recovery.value.memberId);
1666
+ if (lifecycle === undefined
1667
+ || lifecycle.value.operationKind !== recovery.value.operationKind
1668
+ || lifecycle.value.requestFingerprint !== recovery.value.requestFingerprint
1669
+ || lifecycle.value.state !== 'completed'
1670
+ || member === undefined
1671
+ || (recovery.value.operationKind === 'join-project' && invitation === undefined)
1672
+ || (recovery.value.operationKind !== 'join-project' && invitation !== undefined)
1673
+ || (recovery.value.operationKind === 'create-project'
1674
+ && lifecycle.value.actorMemberId !== null)
1675
+ || (recovery.value.operationKind === 'join-project'
1676
+ && lifecycle.value.actorMemberId !== null)
1677
+ || (recovery.value.operationKind === 'remove-member'
1678
+ && lifecycle.value.actorMemberId === null)
1679
+ || (recovery.value.operationKind === 'join-project'
1680
+ && invitation?.value.state !== 'redeemed')
1681
+ || (recovery.value.operationKind === 'remove-member' && (member.value.status !== 'revoked'
1682
+ || principalBindings.has(recovery.value.memberId))))
1683
+ throw invalidPayload('records');
1684
+ }
1685
+ for (const invitation of invitations.values()) {
1686
+ const envelope = invitationEnvelopes.get(invitation.value.invitationId);
1687
+ const tombstone = secretReplayTombstones.find(item => (item.value.operation === 'createProjectInvitation'
1688
+ && item.value.actorMemberId === invitation.value.issuedByMemberId
1689
+ && item.value.idempotencyKey === invitation.value.idempotencyKey
1690
+ && item.value.requestFingerprint === invitation.value.requestFingerprint));
1691
+ if (!members.has(invitation.value.issuedByMemberId)
1692
+ || (envelope === undefined) === (tombstone === undefined)
1693
+ || (tombstone !== undefined
1694
+ && tombstone.value.expiredAt !== invitation.value.secretReplayExpiresAt)
1695
+ || (envelope !== undefined && (envelope.value.projectId !== invitation.value.projectId
1696
+ || envelope.value.createdAt !== invitation.value.createdAt
1697
+ || envelope.value.expiresAt !== invitation.value.secretReplayExpiresAt)))
1698
+ throw invalidPayload('records');
1699
+ }
1700
+ for (const envelope of invitationEnvelopes.values()) {
1701
+ if (!invitations.has(envelope.value.invitationId))
1702
+ throw invalidPayload('records');
1703
+ }
1704
+ const overridesByMember = new Map();
1705
+ for (const override of claimOverrides) {
1706
+ const identity = `${override.value.transferId}:${override.value.memberId}`;
1707
+ const values = overridesByMember.get(identity) ?? [];
1708
+ values.push(override);
1709
+ overridesByMember.set(identity, values);
1710
+ const envelope = claimOverrideEnvelopes.get(`${identity}:${override.value.claimGeneration}`);
1711
+ const tombstone = secretReplayTombstones.find(item => (item.value.operation === 'reissueTransferredMembershipClaim'
1712
+ && item.value.actorMemberId === override.value.managerMemberId
1713
+ && item.value.idempotencyKey === override.value.idempotencyKey
1714
+ && item.value.requestFingerprint === override.value.requestFingerprint));
1715
+ if (!members.has(override.value.memberId)
1716
+ || !members.has(override.value.managerMemberId)
1717
+ || (envelope === undefined) === (tombstone === undefined)
1718
+ || (tombstone !== undefined
1719
+ && tombstone.value.expiredAt !== override.value.secretReplayExpiresAt)
1720
+ || (envelope !== undefined && (envelope.value.projectId !== override.value.projectId
1721
+ || envelope.value.createdAt !== override.value.createdAt
1722
+ || envelope.value.expiresAt !== override.value.secretReplayExpiresAt)))
1723
+ throw invalidPayload('records');
1724
+ }
1725
+ for (const overrides of overridesByMember.values()) {
1726
+ overrides.sort((left, right) => left.value.claimGeneration - right.value.claimGeneration);
1727
+ const highest = overrides.at(-1);
1728
+ const first = overrides[0];
1729
+ const identity = first === undefined
1730
+ ? ''
1731
+ : `${first.value.transferId}:${first.value.memberId}`;
1732
+ const sourceClaim = claims.get(identity);
1733
+ const recovery = first === undefined ? undefined : recoveries.get(first.value.transferId);
1734
+ const member = first === undefined ? undefined : memberRecords.get(first.value.memberId);
1735
+ const principalBinding = first === undefined
1736
+ ? undefined
1737
+ : principalBindings.get(first.value.memberId);
1738
+ const redemptionReceipt = redemptionReceipts.get(identity);
1739
+ const redeemedOverrides = overrides.filter(item => item.value.state === 'redeemed');
1740
+ if (first === undefined
1741
+ || sourceClaim === undefined
1742
+ || recovery?.value.sourceAuthority.kind !== 'lan'
1743
+ || member?.value.status !== 'active'
1744
+ || sourceClaim.value.state === 'redeemed'
1745
+ || overrides.some((item, index) => item.value.claimGeneration !== index + 1)
1746
+ || overrides.some((item, index) => item.value.supersededClaimSha256 !== (index === 0
1747
+ ? sourceClaim.value.claimSha256
1748
+ : overrides[index - 1].value.claimSha256))
1749
+ || new Set(overrides.map(item => item.value.claimSha256)).size !== overrides.length
1750
+ || overrides.filter(item => item.value.state === 'active').length > 1
1751
+ || overrides.some(item => item.value.state === 'active' && item !== highest)
1752
+ || redeemedOverrides.length > 1
1753
+ || redeemedOverrides.some(item => item !== highest)
1754
+ || highest?.value.state === 'superseded'
1755
+ || (highest?.value.state === 'active' && (principalBinding !== undefined
1756
+ || redemptionReceipt !== undefined))
1757
+ || (highest?.value.state === 'redeemed' && (principalBinding !== highest.value.targetPrincipalId
1758
+ || redemptionReceipt === undefined
1759
+ || redemptionReceipt.value.receipt.transferId !== highest.value.transferId
1760
+ || redemptionReceipt.value.receipt.memberId !== highest.value.memberId
1761
+ || redemptionReceipt.value.receipt.claimSha256 !== highest.value.claimSha256
1762
+ || redemptionReceipt.value.receipt.receiptId !== highest.value.redemptionReceiptId
1763
+ || redemptionReceipt.value.receipt.checkpointSha256
1764
+ !== sourceClaim.value.checkpointSha256))
1765
+ || (highest?.value.state !== 'redeemed' && redemptionReceipt !== undefined))
1766
+ throw invalidPayload('records');
1767
+ }
1768
+ for (const envelope of claimOverrideEnvelopes.values()) {
1769
+ if (!claimOverrides.some(item => (item.value.transferId === envelope.value.transferId
1770
+ && item.value.memberId === envelope.value.memberId
1771
+ && item.value.claimGeneration === envelope.value.claimGeneration)))
1772
+ throw invalidPayload('records');
1773
+ }
1774
+ const currentOffers = managerOffers.filter(item => (item.value.state === 'offered' || item.value.state === 'acknowledged'));
1775
+ if (managerOffers.some(item => (item.value.sourceManagerMemberId === item.value.targetMemberId
1776
+ || !members.has(item.value.sourceManagerMemberId)
1777
+ || !members.has(item.value.targetMemberId)))
1778
+ || new Set(currentOffers.map(item => item.value.sourceManagerMemberId)).size
1779
+ !== currentOffers.length
1780
+ || new Set(currentOffers.map(item => item.value.targetMemberId)).size
1781
+ !== currentOffers.length)
1782
+ throw invalidPayload('records');
1783
+ for (const tombstone of secretReplayTombstones) {
1784
+ const liveSecretRecord = tombstone.value.operation === 'createProjectInvitation'
1785
+ ? [...invitations.values()].some(item => (item.value.issuedByMemberId === tombstone.value.actorMemberId
1786
+ && item.value.idempotencyKey === tombstone.value.idempotencyKey
1787
+ && invitationEnvelopes.has(item.value.invitationId)))
1788
+ : claimOverrides.some(item => (item.value.managerMemberId === tombstone.value.actorMemberId
1789
+ && item.value.idempotencyKey === tombstone.value.idempotencyKey
1790
+ && claimOverrideEnvelopes.has(`${item.value.transferId}:${item.value.memberId}:${item.value.claimGeneration}`)));
1791
+ if (!members.has(tombstone.value.actorMemberId) || liveSecretRecord) {
1792
+ throw invalidPayload('records');
1793
+ }
1794
+ }
1200
1795
  for (const item of records) {
1201
1796
  if (item.kind === 'idempotency-result' && !members.has(item.value.memberId)) {
1202
1797
  throw invalidPayload('records');
@@ -1248,6 +1843,19 @@ function validateContinuity(records) {
1248
1843
  const lifecycle = lifecycles.get(receipt.transferId);
1249
1844
  const terminalPrincipal = terminalPrincipals.get(identity);
1250
1845
  const lanToCloud = recovery?.value.sourceAuthority.kind === 'lan';
1846
+ const redeemedOverride = claimOverrides.find(item => (item.value.transferId === receipt.transferId
1847
+ && item.value.memberId === receipt.memberId
1848
+ && item.value.state === 'redeemed'
1849
+ && item.value.claimSha256 === receipt.claimSha256
1850
+ && item.value.redemptionReceiptId === receipt.receiptId));
1851
+ const matchesSourceClaim = claim?.value.state === 'redeemed'
1852
+ && claim.value.claimSha256 === receipt.claimSha256
1853
+ && claim.value.checkpointSha256 === receipt.checkpointSha256
1854
+ && claim.value.operationIntentId === receipt.operationIntentId
1855
+ && claim.value.redemptionReceiptId === receipt.receiptId;
1856
+ const matchesOverride = claim !== undefined
1857
+ && redeemedOverride !== undefined
1858
+ && claim.value.checkpointSha256 === receipt.checkpointSha256;
1251
1859
  if (recovery === undefined
1252
1860
  || lifecycle === undefined
1253
1861
  || memberRecords.get(receipt.memberId)?.value.status !== 'active'
@@ -1255,11 +1863,7 @@ function validateContinuity(records) {
1255
1863
  || !keys.has(`${receipt.transferId}:${receipt.receiptKeyId}`)
1256
1864
  || Date.parse(receipt.redeemedAt) > Date.parse(recovery.value.expiresAt)
1257
1865
  || (lanToCloud && (item.value.acknowledgedAt !== null
1258
- || claim === undefined
1259
- || claim.value.claimSha256 !== receipt.claimSha256
1260
- || claim.value.checkpointSha256 !== receipt.checkpointSha256
1261
- || claim.value.operationIntentId !== receipt.operationIntentId
1262
- || claim.value.redemptionReceiptId !== receipt.receiptId))
1866
+ || (!matchesSourceClaim && !matchesOverride)))
1263
1867
  || (!lanToCloud && (item.value.acknowledgedAt === null
1264
1868
  || receipt.memberId === recovery.value.targetHostMemberId
1265
1869
  || receipt.checkpointSha256 !== lifecycle.value.checkpointSha256
@@ -1454,6 +2058,30 @@ function encodeCollabProjectBackupCheckpointCoordinationNdjson(records) {
1454
2058
  function validateCollabProjectBackupCheckpointConsistency(manifest, records) {
1455
2059
  const decodedManifest = decodeCollabProjectBackupCheckpointManifest(manifest);
1456
2060
  const decodedRecords = decodeCollabProjectBackupCheckpointCoordinationNdjson(records.map(item => JSON.stringify(item)).join('\n') + '\n');
2061
+ const invitationEnvelopeIds = new Set(decodedRecords
2062
+ .filter(item => item.kind === 'protected-invitation-envelope')
2063
+ .map(item => item.recordId));
2064
+ const claimOverrideEnvelopeIds = new Set(decodedRecords
2065
+ .filter(item => item.kind === 'protected-claim-override-envelope')
2066
+ .map(item => item.recordId));
2067
+ const captureTime = Date.parse(decodedManifest.createdAt);
2068
+ if (decodedRecords.some(item => {
2069
+ if (item.kind === 'secret-replay-tombstone') {
2070
+ return Date.parse(item.value.expiredAt) > captureTime;
2071
+ }
2072
+ if (item.kind === 'project-invitation') {
2073
+ return captureTime < Date.parse(item.value.createdAt)
2074
+ || (captureTime < Date.parse(item.value.secretReplayExpiresAt))
2075
+ !== invitationEnvelopeIds.has(item.recordId);
2076
+ }
2077
+ if (item.kind === 'transferred-membership-claim-override') {
2078
+ return captureTime < Date.parse(item.value.createdAt)
2079
+ || (captureTime < Date.parse(item.value.secretReplayExpiresAt))
2080
+ !== claimOverrideEnvelopeIds.has(item.recordId);
2081
+ }
2082
+ return false;
2083
+ }))
2084
+ throw invalidPayload('records');
1457
2085
  if (decodedRecords.some(item => {
1458
2086
  switch (item.kind) {
1459
2087
  case 'lifecycle-journal': return item.value.operationId === decodedManifest.operationId;