@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.
@@ -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,29 @@ 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
+ 'membership-idempotency-tombstone',
36
+ 'project-membership-recovery',
37
+ 'secret-replay-tombstone',
28
38
  ]);
29
39
  exports.COLLAB_PROJECT_BACKUP_RECORD_KINDS = Object.freeze([
30
- ...CollabProjectCheckpoint_1.COLLAB_CHECKPOINT_BACKUP_RECORD_KINDS.slice(0, 13),
40
+ ...CollabProjectCheckpoint_1.COLLAB_CHECKPOINT_PORTABLE_RECORD_KINDS,
41
+ 'cloud-event',
42
+ 'cloud-event-cursor',
43
+ 'idempotency-result',
44
+ 'principal-binding',
45
+ 'repository-placement',
31
46
  ...BACKUP_CONTINUITY_RECORD_KINDS,
32
- ...CollabProjectCheckpoint_1.COLLAB_CHECKPOINT_BACKUP_RECORD_KINDS.slice(14),
47
+ 'terminal-responder',
48
+ 'protected-claim-envelope',
49
+ 'tombstone',
50
+ 'schema-catalog',
51
+ 'server-compatibility',
52
+ 'authority-volume-pair',
33
53
  ]);
34
54
  const CONTINUITY_KIND_SET = new Set(BACKUP_CONTINUITY_RECORD_KINDS);
35
55
  const BACKUP_KIND_SET = new Set(exports.COLLAB_PROJECT_BACKUP_RECORD_KINDS);
@@ -59,7 +79,9 @@ const DELETE_ACTIVE_PHASE_SET = new Set([
59
79
  'tombstoned',
60
80
  ]);
61
81
  const PLAINTEXT_CLAIM_RESPONSE_OPERATION_SET = new Set([
82
+ 'createProjectInvitation',
62
83
  'getTransferredMembershipClaim',
84
+ 'reissueTransferredMembershipClaim',
63
85
  'rotateTransferredMembershipClaims',
64
86
  ]);
65
87
  function invalidPayload(field) {
@@ -117,6 +139,9 @@ function timestampValue(value, field) {
117
139
  function timestamp(source, field) {
118
140
  return timestampValue(source[field], field);
119
141
  }
142
+ function hasExactDuration(start, end, durationMs) {
143
+ return Date.parse(end) === Date.parse(start) + durationMs;
144
+ }
120
145
  function nullableTimestamp(source, field) {
121
146
  return source[field] === null ? null : timestampValue(source[field], field);
122
147
  }
@@ -467,7 +492,15 @@ function lifecycleJournalRecord(source, recordId, revision) {
467
492
  if (recordId !== operationId)
468
493
  throw invalidPayload('recordId');
469
494
  const operationKind = literal(value, 'operationKind', [
470
- 'authority-transfer', 'backup', 'delete', 'export', 'leave', 'retire',
495
+ 'authority-transfer',
496
+ 'backup',
497
+ 'create-project',
498
+ 'delete',
499
+ 'export',
500
+ 'join-project',
501
+ 'leave',
502
+ 'remove-member',
503
+ 'retire',
471
504
  ]);
472
505
  const direction = value.direction === null
473
506
  ? null
@@ -838,6 +871,385 @@ function leaveFormerPrincipalReplayRecord(source, recordId, revision) {
838
871
  },
839
872
  };
840
873
  }
874
+ function projectInvitationRecord(source, recordId, revision) {
875
+ const value = exactRecord(source.value, 'value', [
876
+ 'createdAt',
877
+ 'expiresAt',
878
+ 'idempotencyKey',
879
+ 'invitationId',
880
+ 'issuedByMemberId',
881
+ 'projectId',
882
+ 'requestFingerprint',
883
+ 'revision',
884
+ 'secretReplayExpiresAt',
885
+ 'secretSha256',
886
+ 'state',
887
+ 'terminalAt',
888
+ ]);
889
+ const invitationId = token(value, 'invitationId');
890
+ if (recordId !== invitationId)
891
+ throw invalidPayload('recordId');
892
+ const createdAt = timestamp(value, 'createdAt');
893
+ const expiresAt = timestamp(value, 'expiresAt');
894
+ const secretReplayExpiresAt = timestamp(value, 'secretReplayExpiresAt');
895
+ const state = literal(value, 'state', [
896
+ 'active', 'redeeming', 'redeemed', 'revoked', 'expired',
897
+ ]);
898
+ const terminalAt = nullableTimestamp(value, 'terminalAt');
899
+ const terminal = state === 'redeemed' || state === 'revoked' || state === 'expired';
900
+ if (!hasExactDuration(createdAt, expiresAt, CollabProjectMembership_1.COLLAB_PROJECT_MEMBERSHIP_LIMITS.invitationTtlMs)
901
+ || !hasExactDuration(createdAt, secretReplayExpiresAt, CollabProjectMembership_1.COLLAB_PROJECT_MEMBERSHIP_LIMITS.secretReplayTtlMs)
902
+ || terminal !== (terminalAt !== null)
903
+ || (terminalAt !== null && Date.parse(terminalAt) < Date.parse(createdAt)))
904
+ throw invalidPayload('state');
905
+ return {
906
+ kind: 'project-invitation',
907
+ recordId,
908
+ revision,
909
+ value: {
910
+ createdAt,
911
+ expiresAt,
912
+ idempotencyKey: token(value, 'idempotencyKey'),
913
+ invitationId,
914
+ issuedByMemberId: token(value, 'issuedByMemberId', CollabValidation_1.isCollabMemberId),
915
+ projectId: token(value, 'projectId', CollabValidation_1.isCollabProjectId),
916
+ requestFingerprint: sha256(value, 'requestFingerprint'),
917
+ revision: positiveInteger(value, 'revision'),
918
+ secretReplayExpiresAt,
919
+ secretSha256: sha256(value, 'secretSha256'),
920
+ state,
921
+ terminalAt,
922
+ },
923
+ };
924
+ }
925
+ function protectedSecretEnvelopeFields(value) {
926
+ const ciphertext = boundedString(value, 'ciphertext', 4096);
927
+ const nonce = boundedString(value, 'nonce', 128);
928
+ if (!BASE64URL_PATTERN.test(ciphertext) || !BASE64URL_PATTERN.test(nonce)) {
929
+ throw invalidPayload('ciphertext');
930
+ }
931
+ const createdAt = timestamp(value, 'createdAt');
932
+ const expiresAt = timestamp(value, 'expiresAt');
933
+ if (Date.parse(expiresAt) <= Date.parse(createdAt))
934
+ throw invalidPayload('expiresAt');
935
+ return {
936
+ associatedDataSha256: sha256(value, 'associatedDataSha256'),
937
+ ciphertext,
938
+ createdAt,
939
+ expiresAt,
940
+ keyId: token(value, 'keyId'),
941
+ nonce,
942
+ projectId: token(value, 'projectId', CollabValidation_1.isCollabProjectId),
943
+ };
944
+ }
945
+ function protectedInvitationEnvelopeRecord(source, recordId, revision) {
946
+ const value = exactRecord(source.value, 'value', [
947
+ 'associatedDataSha256',
948
+ 'ciphertext',
949
+ 'createdAt',
950
+ 'expiresAt',
951
+ 'invitationId',
952
+ 'keyId',
953
+ 'nonce',
954
+ 'projectId',
955
+ ]);
956
+ const invitationId = token(value, 'invitationId');
957
+ if (recordId !== invitationId)
958
+ throw invalidPayload('recordId');
959
+ const envelope = protectedSecretEnvelopeFields(value);
960
+ return {
961
+ kind: 'protected-invitation-envelope',
962
+ recordId,
963
+ revision,
964
+ value: {
965
+ associatedDataSha256: envelope.associatedDataSha256,
966
+ ciphertext: envelope.ciphertext,
967
+ createdAt: envelope.createdAt,
968
+ expiresAt: envelope.expiresAt,
969
+ invitationId,
970
+ keyId: envelope.keyId,
971
+ nonce: envelope.nonce,
972
+ projectId: envelope.projectId,
973
+ },
974
+ };
975
+ }
976
+ function transferredMembershipClaimOverrideRecord(source, recordId, revision) {
977
+ const value = exactRecord(source.value, 'value', [
978
+ 'claimGeneration',
979
+ 'claimSha256',
980
+ 'createdAt',
981
+ 'expiresAt',
982
+ 'idempotencyKey',
983
+ 'managerMemberId',
984
+ 'memberId',
985
+ 'projectId',
986
+ 'redemptionReceiptId',
987
+ 'requestFingerprint',
988
+ 'secretReplayExpiresAt',
989
+ 'state',
990
+ 'supersededClaimSha256',
991
+ 'targetPrincipalId',
992
+ 'transferId',
993
+ 'updatedAt',
994
+ ]);
995
+ const transferId = token(value, 'transferId');
996
+ const memberId = token(value, 'memberId', CollabValidation_1.isCollabMemberId);
997
+ const claimGeneration = positiveInteger(value, 'claimGeneration');
998
+ if (recordId !== `${transferId}:${memberId}:${claimGeneration}`) {
999
+ throw invalidPayload('recordId');
1000
+ }
1001
+ const state = literal(value, 'state', [
1002
+ 'active', 'redeemed', 'revoked', 'superseded', 'expired',
1003
+ ]);
1004
+ const targetPrincipalId = nullableToken(value, 'targetPrincipalId', candidate => (typeof candidate === 'string' && PRINCIPAL_PATTERN.test(candidate)));
1005
+ const redemptionReceiptId = nullableToken(value, 'redemptionReceiptId');
1006
+ if ((state === 'redeemed') !== (targetPrincipalId !== null && redemptionReceiptId !== null)
1007
+ || (state !== 'redeemed' && (targetPrincipalId !== null || redemptionReceiptId !== null)))
1008
+ throw invalidPayload('state');
1009
+ const createdAt = timestamp(value, 'createdAt');
1010
+ const expiresAt = timestamp(value, 'expiresAt');
1011
+ const secretReplayExpiresAt = timestamp(value, 'secretReplayExpiresAt');
1012
+ const updatedAt = timestamp(value, 'updatedAt');
1013
+ if (!hasExactDuration(createdAt, expiresAt, CollabProjectMembership_1.COLLAB_PROJECT_MEMBERSHIP_LIMITS.transferredClaimTtlMs)
1014
+ || !hasExactDuration(createdAt, secretReplayExpiresAt, CollabProjectMembership_1.COLLAB_PROJECT_MEMBERSHIP_LIMITS.secretReplayTtlMs)
1015
+ || Date.parse(updatedAt) < Date.parse(createdAt))
1016
+ throw invalidPayload('updatedAt');
1017
+ return {
1018
+ kind: 'transferred-membership-claim-override',
1019
+ recordId,
1020
+ revision,
1021
+ value: {
1022
+ claimGeneration,
1023
+ claimSha256: sha256(value, 'claimSha256'),
1024
+ createdAt,
1025
+ expiresAt,
1026
+ idempotencyKey: token(value, 'idempotencyKey'),
1027
+ managerMemberId: token(value, 'managerMemberId', CollabValidation_1.isCollabMemberId),
1028
+ memberId,
1029
+ projectId: token(value, 'projectId', CollabValidation_1.isCollabProjectId),
1030
+ redemptionReceiptId,
1031
+ requestFingerprint: sha256(value, 'requestFingerprint'),
1032
+ secretReplayExpiresAt,
1033
+ state,
1034
+ supersededClaimSha256: sha256(value, 'supersededClaimSha256'),
1035
+ targetPrincipalId,
1036
+ transferId,
1037
+ updatedAt,
1038
+ },
1039
+ };
1040
+ }
1041
+ function protectedClaimOverrideEnvelopeRecord(source, recordId, revision) {
1042
+ const value = exactRecord(source.value, 'value', [
1043
+ 'associatedDataSha256',
1044
+ 'ciphertext',
1045
+ 'claimGeneration',
1046
+ 'createdAt',
1047
+ 'expiresAt',
1048
+ 'keyId',
1049
+ 'memberId',
1050
+ 'nonce',
1051
+ 'projectId',
1052
+ 'transferId',
1053
+ ]);
1054
+ const transferId = token(value, 'transferId');
1055
+ const memberId = token(value, 'memberId', CollabValidation_1.isCollabMemberId);
1056
+ const claimGeneration = positiveInteger(value, 'claimGeneration');
1057
+ if (recordId !== `${transferId}:${memberId}:${claimGeneration}`) {
1058
+ throw invalidPayload('recordId');
1059
+ }
1060
+ const envelope = protectedSecretEnvelopeFields(value);
1061
+ return {
1062
+ kind: 'protected-claim-override-envelope',
1063
+ recordId,
1064
+ revision,
1065
+ value: {
1066
+ associatedDataSha256: envelope.associatedDataSha256,
1067
+ ciphertext: envelope.ciphertext,
1068
+ claimGeneration,
1069
+ createdAt: envelope.createdAt,
1070
+ expiresAt: envelope.expiresAt,
1071
+ keyId: envelope.keyId,
1072
+ memberId,
1073
+ nonce: envelope.nonce,
1074
+ projectId: envelope.projectId,
1075
+ transferId,
1076
+ },
1077
+ };
1078
+ }
1079
+ function managerResponsibilityOfferRecord(source, recordId, revision) {
1080
+ const value = exactRecord(source.value, 'value', [
1081
+ 'acknowledgedAt',
1082
+ 'expiresAt',
1083
+ 'idempotencyKey',
1084
+ 'managerSetGenerationAtOffer',
1085
+ 'offeredAt',
1086
+ 'offerId',
1087
+ 'projectId',
1088
+ 'purpose',
1089
+ 'requestFingerprint',
1090
+ 'revision',
1091
+ 'sourceManagerMemberId',
1092
+ 'state',
1093
+ 'targetMemberId',
1094
+ 'targetMembershipRevisionAtOffer',
1095
+ 'terminalAt',
1096
+ ]);
1097
+ const offerId = token(value, 'offerId');
1098
+ if (recordId !== offerId)
1099
+ throw invalidPayload('recordId');
1100
+ const state = literal(value, 'state', [
1101
+ 'offered', 'acknowledged', 'declined', 'cancelled', 'consumed', 'expired',
1102
+ ]);
1103
+ const offeredAt = timestamp(value, 'offeredAt');
1104
+ const expiresAt = timestamp(value, 'expiresAt');
1105
+ const acknowledgedAt = nullableTimestamp(value, 'acknowledgedAt');
1106
+ const terminalAt = nullableTimestamp(value, 'terminalAt');
1107
+ const terminal = state === 'declined'
1108
+ || state === 'cancelled'
1109
+ || state === 'consumed'
1110
+ || state === 'expired';
1111
+ if (!hasExactDuration(offeredAt, expiresAt, CollabProjectMembership_1.COLLAB_PROJECT_MEMBERSHIP_LIMITS.managerResponsibilityOfferTtlMs)
1112
+ || (state === 'offered' && acknowledgedAt !== null)
1113
+ || (state === 'acknowledged' && acknowledgedAt === null)
1114
+ || terminal !== (terminalAt !== null))
1115
+ throw invalidPayload('state');
1116
+ return {
1117
+ kind: 'manager-responsibility-offer',
1118
+ recordId,
1119
+ revision,
1120
+ value: {
1121
+ acknowledgedAt,
1122
+ expiresAt,
1123
+ idempotencyKey: token(value, 'idempotencyKey'),
1124
+ managerSetGenerationAtOffer: positiveInteger(value, 'managerSetGenerationAtOffer'),
1125
+ offeredAt,
1126
+ offerId,
1127
+ projectId: token(value, 'projectId', CollabValidation_1.isCollabProjectId),
1128
+ purpose: literal(value, 'purpose', ['manager-promotion', 'manager-leave']),
1129
+ requestFingerprint: sha256(value, 'requestFingerprint'),
1130
+ revision: positiveInteger(value, 'revision'),
1131
+ sourceManagerMemberId: token(value, 'sourceManagerMemberId', CollabValidation_1.isCollabMemberId),
1132
+ state,
1133
+ targetMemberId: token(value, 'targetMemberId', CollabValidation_1.isCollabMemberId),
1134
+ targetMembershipRevisionAtOffer: positiveInteger(value, 'targetMembershipRevisionAtOffer'),
1135
+ terminalAt,
1136
+ },
1137
+ };
1138
+ }
1139
+ function membershipRecoveryRecord(source, recordId, revision) {
1140
+ const value = exactRecord(source.value, 'value', [
1141
+ 'expectedMainOid',
1142
+ 'expectedPersonalRefOid',
1143
+ 'invitationId',
1144
+ 'memberId',
1145
+ 'operationId',
1146
+ 'operationKind',
1147
+ 'principalSha256',
1148
+ 'projectId',
1149
+ 'publicationMarkerSha256',
1150
+ 'repositoryPlanSha256',
1151
+ 'requestFingerprint',
1152
+ ]);
1153
+ const operationId = token(value, 'operationId');
1154
+ if (recordId !== operationId)
1155
+ throw invalidPayload('recordId');
1156
+ const operationKind = literal(value, 'operationKind', [
1157
+ 'create-project', 'join-project', 'remove-member',
1158
+ ]);
1159
+ const invitationId = nullableToken(value, 'invitationId');
1160
+ const principalSha256 = nullableSha256(value, 'principalSha256');
1161
+ const publicationMarkerSha256 = nullableSha256(value, 'publicationMarkerSha256');
1162
+ const repositoryPlanSha256 = nullableSha256(value, 'repositoryPlanSha256');
1163
+ if ((operationKind === 'join-project') !== (invitationId !== null)
1164
+ || (operationKind === 'remove-member') === (principalSha256 !== null)
1165
+ || (operationKind === 'create-project') !== (repositoryPlanSha256 !== null)
1166
+ || (operationKind !== 'create-project' && publicationMarkerSha256 !== null))
1167
+ throw invalidPayload('operationKind');
1168
+ return {
1169
+ kind: 'project-membership-recovery',
1170
+ recordId,
1171
+ revision,
1172
+ value: {
1173
+ expectedMainOid: token(value, 'expectedMainOid', CollabValidation_1.isCollabGitOid),
1174
+ expectedPersonalRefOid: token(value, 'expectedPersonalRefOid', CollabValidation_1.isCollabGitOid),
1175
+ invitationId,
1176
+ memberId: token(value, 'memberId', CollabValidation_1.isCollabMemberId),
1177
+ operationId,
1178
+ operationKind,
1179
+ principalSha256,
1180
+ projectId: token(value, 'projectId', CollabValidation_1.isCollabProjectId),
1181
+ publicationMarkerSha256,
1182
+ repositoryPlanSha256,
1183
+ requestFingerprint: sha256(value, 'requestFingerprint'),
1184
+ },
1185
+ };
1186
+ }
1187
+ function membershipIdempotencyTombstoneRecord(source, recordId, revision) {
1188
+ const value = exactRecord(source.value, 'value', [
1189
+ 'actorMemberId',
1190
+ 'compactedAt',
1191
+ 'idempotencyKey',
1192
+ 'operation',
1193
+ 'projectId',
1194
+ 'requestFingerprint',
1195
+ ]);
1196
+ const actorMemberId = token(value, 'actorMemberId', CollabValidation_1.isCollabMemberId);
1197
+ const idempotencyKey = token(value, 'idempotencyKey');
1198
+ const operation = literal(value, 'operation', [
1199
+ 'createManagerResponsibilityOffer',
1200
+ 'acknowledgeManagerResponsibility',
1201
+ 'declineManagerResponsibility',
1202
+ 'cancelManagerResponsibilityOffer',
1203
+ 'promoteManager',
1204
+ ]);
1205
+ if (recordId !== `${operation}:${actorMemberId}:${idempotencyKey}`) {
1206
+ throw invalidPayload('recordId');
1207
+ }
1208
+ return {
1209
+ kind: 'membership-idempotency-tombstone',
1210
+ recordId,
1211
+ revision,
1212
+ value: {
1213
+ actorMemberId,
1214
+ compactedAt: timestamp(value, 'compactedAt'),
1215
+ idempotencyKey,
1216
+ operation,
1217
+ projectId: token(value, 'projectId', CollabValidation_1.isCollabProjectId),
1218
+ requestFingerprint: sha256(value, 'requestFingerprint'),
1219
+ },
1220
+ };
1221
+ }
1222
+ function secretReplayTombstoneRecord(source, recordId, revision) {
1223
+ const value = exactRecord(source.value, 'value', [
1224
+ 'actorMemberId',
1225
+ 'expiredAt',
1226
+ 'idempotencyKey',
1227
+ 'operation',
1228
+ 'projectId',
1229
+ 'requestFingerprint',
1230
+ ]);
1231
+ const actorMemberId = token(value, 'actorMemberId', CollabValidation_1.isCollabMemberId);
1232
+ const idempotencyKey = token(value, 'idempotencyKey');
1233
+ const operation = literal(value, 'operation', [
1234
+ 'createProjectInvitation', 'reissueTransferredMembershipClaim',
1235
+ ]);
1236
+ if (recordId !== `${operation}:${actorMemberId}:${idempotencyKey}`) {
1237
+ throw invalidPayload('recordId');
1238
+ }
1239
+ return {
1240
+ kind: 'secret-replay-tombstone',
1241
+ recordId,
1242
+ revision,
1243
+ value: {
1244
+ actorMemberId,
1245
+ expiredAt: timestamp(value, 'expiredAt'),
1246
+ idempotencyKey,
1247
+ operation,
1248
+ projectId: token(value, 'projectId', CollabValidation_1.isCollabProjectId),
1249
+ requestFingerprint: sha256(value, 'requestFingerprint'),
1250
+ },
1251
+ };
1252
+ }
841
1253
  function decodeContinuityRecord(kind, source, recordId, revision) {
842
1254
  switch (kind) {
843
1255
  case 'lifecycle-journal': return lifecycleJournalRecord(source, recordId, revision);
@@ -855,6 +1267,21 @@ function decodeContinuityRecord(kind, source, recordId, revision) {
855
1267
  return terminalResponderReplayRecord(source, recordId, revision);
856
1268
  case 'leave-former-principal-replay':
857
1269
  return leaveFormerPrincipalReplayRecord(source, recordId, revision);
1270
+ case 'project-invitation': return projectInvitationRecord(source, recordId, revision);
1271
+ case 'protected-invitation-envelope':
1272
+ return protectedInvitationEnvelopeRecord(source, recordId, revision);
1273
+ case 'transferred-membership-claim-override':
1274
+ return transferredMembershipClaimOverrideRecord(source, recordId, revision);
1275
+ case 'protected-claim-override-envelope':
1276
+ return protectedClaimOverrideEnvelopeRecord(source, recordId, revision);
1277
+ case 'manager-responsibility-offer':
1278
+ return managerResponsibilityOfferRecord(source, recordId, revision);
1279
+ case 'membership-idempotency-tombstone':
1280
+ return membershipIdempotencyTombstoneRecord(source, recordId, revision);
1281
+ case 'project-membership-recovery':
1282
+ return membershipRecoveryRecord(source, recordId, revision);
1283
+ case 'secret-replay-tombstone':
1284
+ return secretReplayTombstoneRecord(source, recordId, revision);
858
1285
  }
859
1286
  }
860
1287
  function kindOrder(kind) {
@@ -911,6 +1338,33 @@ function lifecycleJournalHasInvalidSemantics(item) {
911
1338
  }
912
1339
  if (hasCheckpoint)
913
1340
  return true;
1341
+ if (value.operationKind === 'create-project'
1342
+ || value.operationKind === 'join-project'
1343
+ || value.operationKind === 'remove-member') {
1344
+ const phases = value.operationKind === 'create-project'
1345
+ ? new Set([
1346
+ 'prepared',
1347
+ 'repository-publication-intent',
1348
+ 'repository-published',
1349
+ 'activated',
1350
+ ])
1351
+ : value.operationKind === 'join-project'
1352
+ ? new Set(['prepared', 'membership-pending', 'personal-ref-created', 'membership-active'])
1353
+ : new Set(['prepared', 'membership-revoked', 'personal-ref-removed']);
1354
+ const statePhaseValid = (value.state === 'active' && phases.has(value.phase))
1355
+ || (value.state === 'recovery-required'
1356
+ && value.phase === 'recovery-required'
1357
+ && value.recoveryFromPhase !== null
1358
+ && phases.has(value.recoveryFromPhase))
1359
+ || (value.state === 'completed' && value.phase === 'completed');
1360
+ const resultRequired = value.state === 'completed'
1361
+ || (value.operationKind === 'create-project' && effectivePhase === 'activated')
1362
+ || (value.operationKind === 'join-project' && effectivePhase === 'membership-active')
1363
+ || (value.operationKind === 'remove-member'
1364
+ && (effectivePhase === 'membership-revoked'
1365
+ || effectivePhase === 'personal-ref-removed'));
1366
+ return !statePhaseValid || hasResult !== resultRequired;
1367
+ }
914
1368
  if (value.operationKind === 'leave') {
915
1369
  const statePhaseValid = (value.state === 'active' && (value.phase === 'prepared'
916
1370
  || value.phase === 'membership-left'
@@ -979,13 +1433,42 @@ function validateContinuity(records) {
979
1433
  const leaveReplays = new Map(records
980
1434
  .filter((item) => (item.kind === 'leave-former-principal-replay'))
981
1435
  .map(item => [item.value.operationId, item]));
1436
+ const invitations = new Map(records
1437
+ .filter((item) => (item.kind === 'project-invitation'))
1438
+ .map(item => [item.value.invitationId, item]));
1439
+ const invitationEnvelopes = new Map(records
1440
+ .filter((item) => (item.kind === 'protected-invitation-envelope'))
1441
+ .map(item => [item.value.invitationId, item]));
1442
+ const claimOverrides = records.filter((item) => (item.kind === 'transferred-membership-claim-override'));
1443
+ const claimOverrideEnvelopes = new Map(records
1444
+ .filter((item) => (item.kind === 'protected-claim-override-envelope'))
1445
+ .map(item => [
1446
+ `${item.value.transferId}:${item.value.memberId}:${item.value.claimGeneration}`,
1447
+ item,
1448
+ ]));
1449
+ const managerOffers = records.filter((item) => (item.kind === 'manager-responsibility-offer'));
1450
+ const membershipIdempotencyTombstones = records.filter((item) => (item.kind === 'membership-idempotency-tombstone'));
1451
+ const membershipRecoveries = new Map(records
1452
+ .filter((item) => (item.kind === 'project-membership-recovery'))
1453
+ .map(item => [item.value.operationId, item]));
1454
+ const secretReplayTombstones = records.filter((item) => (item.kind === 'secret-replay-tombstone'));
982
1455
  const nonterminalLifecycles = [...lifecycles.values()].filter(item => (item.value.state === 'active' || item.value.state === 'recovery-required'));
983
1456
  if (nonterminalLifecycles.length > 1
984
1457
  || principalBindingRecords.some(item => (memberRecords.get(item.value.memberId)?.value.status !== 'active'))
985
1458
  || new Set(principalBindingRecords.map(item => item.value.principalId)).size
986
1459
  !== principalBindingRecords.length
987
1460
  || 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)
1461
+ || new Set(redemptionReceiptRecords.map(item => (`${item.value.receipt.transferId}:${item.value.receipt.receiptId}`))).size !== redemptionReceiptRecords.length
1462
+ || invitations.size !== records.filter(item => item.kind === 'project-invitation').length
1463
+ || invitationEnvelopes.size
1464
+ !== records.filter(item => item.kind === 'protected-invitation-envelope').length
1465
+ || new Set([...invitations.values()].map(item => (`${item.value.issuedByMemberId}:${item.value.idempotencyKey}`))).size !== invitations.size
1466
+ || new Set(claimOverrides.map(item => (`${item.value.transferId}:${item.value.memberId}:${item.value.claimGeneration}`))).size !== claimOverrides.length
1467
+ || new Set(claimOverrides.map(item => (`${item.value.managerMemberId}:${item.value.idempotencyKey}`))).size !== claimOverrides.length
1468
+ || claimOverrideEnvelopes.size
1469
+ !== records.filter(item => item.kind === 'protected-claim-override-envelope').length
1470
+ || membershipRecoveries.size
1471
+ !== records.filter(item => item.kind === 'project-membership-recovery').length)
989
1472
  throw invalidPayload('records');
990
1473
  for (const lifecycle of lifecycles.values()) {
991
1474
  if (lifecycleJournalHasInvalidSemantics(lifecycle)
@@ -996,6 +1479,11 @@ function validateContinuity(records) {
996
1479
  if (lifecycle.value.operationKind === 'authority-transfer'
997
1480
  && !recoveries.has(lifecycle.value.operationId))
998
1481
  throw invalidPayload('records');
1482
+ if ((lifecycle.value.operationKind === 'create-project'
1483
+ || lifecycle.value.operationKind === 'join-project'
1484
+ || lifecycle.value.operationKind === 'remove-member')
1485
+ && !membershipRecoveries.has(lifecycle.value.operationId))
1486
+ throw invalidPayload('records');
999
1487
  if (lifecycle.value.operationKind === 'leave') {
1000
1488
  const actorMemberId = lifecycle.value.actorMemberId;
1001
1489
  const actor = actorMemberId === null ? undefined : memberRecords.get(actorMemberId);
@@ -1172,6 +1660,13 @@ function validateContinuity(records) {
1172
1660
  const lifecycle = lifecycles.get(claim.value.transferId);
1173
1661
  const recovery = recoveries.get(claim.value.transferId);
1174
1662
  const redemptionReceipt = redemptionReceipts.get(`${claim.value.transferId}:${claim.value.memberId}`);
1663
+ const redeemedOverride = redemptionReceipt === undefined
1664
+ ? undefined
1665
+ : claimOverrides.find(item => (item.value.transferId === claim.value.transferId
1666
+ && item.value.memberId === claim.value.memberId
1667
+ && item.value.state === 'redeemed'
1668
+ && item.value.claimSha256 === redemptionReceipt.value.receipt.claimSha256
1669
+ && item.value.redemptionReceiptId === redemptionReceipt.value.receipt.receiptId));
1175
1670
  if (memberRecords.get(claim.value.memberId)?.value.status !== 'active'
1176
1671
  || recovery === undefined
1177
1672
  || recovery.value.sourceAuthority.kind !== 'lan'
@@ -1181,7 +1676,11 @@ function validateContinuity(records) {
1181
1676
  || claim.value.expiresAt !== recovery.value.expiresAt
1182
1677
  || (claim.value.targetPrincipalId !== null
1183
1678
  && principalBindings.get(claim.value.memberId) !== claim.value.targetPrincipalId)
1184
- || (claim.value.state === 'redeemed') !== (redemptionReceipt !== undefined)) {
1679
+ || (claim.value.state === 'redeemed' && redemptionReceipt === undefined)
1680
+ || (redemptionReceipt !== undefined
1681
+ && claim.value.state !== 'redeemed'
1682
+ && redeemedOverride === undefined)
1683
+ || (claim.value.state === 'redeemed' && redeemedOverride !== undefined)) {
1185
1684
  throw invalidPayload('records');
1186
1685
  }
1187
1686
  }
@@ -1197,6 +1696,153 @@ function validateContinuity(records) {
1197
1696
  !== responderReplays.has(terminal.value.operationId))
1198
1697
  throw invalidPayload('records');
1199
1698
  }
1699
+ for (const recovery of membershipRecoveries.values()) {
1700
+ const lifecycle = lifecycles.get(recovery.value.operationId);
1701
+ const invitation = recovery.value.invitationId === null
1702
+ ? undefined
1703
+ : invitations.get(recovery.value.invitationId);
1704
+ const member = memberRecords.get(recovery.value.memberId);
1705
+ if (lifecycle === undefined
1706
+ || lifecycle.value.operationKind !== recovery.value.operationKind
1707
+ || lifecycle.value.requestFingerprint !== recovery.value.requestFingerprint
1708
+ || lifecycle.value.state !== 'completed'
1709
+ || member === undefined
1710
+ || (recovery.value.operationKind === 'join-project' && invitation === undefined)
1711
+ || (recovery.value.operationKind !== 'join-project' && invitation !== undefined)
1712
+ || (recovery.value.operationKind === 'create-project'
1713
+ && lifecycle.value.actorMemberId !== null)
1714
+ || (recovery.value.operationKind === 'join-project'
1715
+ && lifecycle.value.actorMemberId !== null)
1716
+ || (recovery.value.operationKind === 'remove-member'
1717
+ && lifecycle.value.actorMemberId === null)
1718
+ || (recovery.value.operationKind === 'join-project'
1719
+ && invitation?.value.state !== 'redeemed')
1720
+ || (recovery.value.operationKind === 'remove-member' && (member.value.status !== 'revoked'
1721
+ || principalBindings.has(recovery.value.memberId))))
1722
+ throw invalidPayload('records');
1723
+ }
1724
+ for (const invitation of invitations.values()) {
1725
+ const envelope = invitationEnvelopes.get(invitation.value.invitationId);
1726
+ const tombstone = secretReplayTombstones.find(item => (item.value.operation === 'createProjectInvitation'
1727
+ && item.value.actorMemberId === invitation.value.issuedByMemberId
1728
+ && item.value.idempotencyKey === invitation.value.idempotencyKey
1729
+ && item.value.requestFingerprint === invitation.value.requestFingerprint));
1730
+ if (!members.has(invitation.value.issuedByMemberId)
1731
+ || (envelope === undefined) === (tombstone === undefined)
1732
+ || (tombstone !== undefined
1733
+ && tombstone.value.expiredAt !== invitation.value.secretReplayExpiresAt)
1734
+ || (envelope !== undefined && (envelope.value.projectId !== invitation.value.projectId
1735
+ || envelope.value.createdAt !== invitation.value.createdAt
1736
+ || envelope.value.expiresAt !== invitation.value.secretReplayExpiresAt)))
1737
+ throw invalidPayload('records');
1738
+ }
1739
+ for (const envelope of invitationEnvelopes.values()) {
1740
+ if (!invitations.has(envelope.value.invitationId))
1741
+ throw invalidPayload('records');
1742
+ }
1743
+ const overridesByMember = new Map();
1744
+ for (const override of claimOverrides) {
1745
+ const identity = `${override.value.transferId}:${override.value.memberId}`;
1746
+ const values = overridesByMember.get(identity) ?? [];
1747
+ values.push(override);
1748
+ overridesByMember.set(identity, values);
1749
+ const envelope = claimOverrideEnvelopes.get(`${identity}:${override.value.claimGeneration}`);
1750
+ const tombstone = secretReplayTombstones.find(item => (item.value.operation === 'reissueTransferredMembershipClaim'
1751
+ && item.value.actorMemberId === override.value.managerMemberId
1752
+ && item.value.idempotencyKey === override.value.idempotencyKey
1753
+ && item.value.requestFingerprint === override.value.requestFingerprint));
1754
+ if (!members.has(override.value.memberId)
1755
+ || !members.has(override.value.managerMemberId)
1756
+ || (envelope === undefined) === (tombstone === undefined)
1757
+ || (tombstone !== undefined
1758
+ && tombstone.value.expiredAt !== override.value.secretReplayExpiresAt)
1759
+ || (envelope !== undefined && (envelope.value.projectId !== override.value.projectId
1760
+ || envelope.value.createdAt !== override.value.createdAt
1761
+ || envelope.value.expiresAt !== override.value.secretReplayExpiresAt)))
1762
+ throw invalidPayload('records');
1763
+ }
1764
+ for (const overrides of overridesByMember.values()) {
1765
+ overrides.sort((left, right) => left.value.claimGeneration - right.value.claimGeneration);
1766
+ const highest = overrides.at(-1);
1767
+ const first = overrides[0];
1768
+ const identity = first === undefined
1769
+ ? ''
1770
+ : `${first.value.transferId}:${first.value.memberId}`;
1771
+ const sourceClaim = claims.get(identity);
1772
+ const recovery = first === undefined ? undefined : recoveries.get(first.value.transferId);
1773
+ const member = first === undefined ? undefined : memberRecords.get(first.value.memberId);
1774
+ const principalBinding = first === undefined
1775
+ ? undefined
1776
+ : principalBindings.get(first.value.memberId);
1777
+ const redemptionReceipt = redemptionReceipts.get(identity);
1778
+ const redeemedOverrides = overrides.filter(item => item.value.state === 'redeemed');
1779
+ if (first === undefined
1780
+ || sourceClaim === undefined
1781
+ || recovery?.value.sourceAuthority.kind !== 'lan'
1782
+ || member?.value.status !== 'active'
1783
+ || sourceClaim.value.state === 'redeemed'
1784
+ || overrides.some((item, index) => item.value.claimGeneration !== index + 1)
1785
+ || overrides.some((item, index) => item.value.supersededClaimSha256 !== (index === 0
1786
+ ? sourceClaim.value.claimSha256
1787
+ : overrides[index - 1].value.claimSha256))
1788
+ || new Set(overrides.map(item => item.value.claimSha256)).size !== overrides.length
1789
+ || overrides.filter(item => item.value.state === 'active').length > 1
1790
+ || overrides.some(item => item.value.state === 'active' && item !== highest)
1791
+ || redeemedOverrides.length > 1
1792
+ || redeemedOverrides.some(item => item !== highest)
1793
+ || highest?.value.state === 'superseded'
1794
+ || (highest?.value.state === 'active' && (principalBinding !== undefined
1795
+ || redemptionReceipt !== undefined))
1796
+ || (highest?.value.state === 'redeemed' && (principalBinding !== highest.value.targetPrincipalId
1797
+ || redemptionReceipt === undefined
1798
+ || redemptionReceipt.value.receipt.transferId !== highest.value.transferId
1799
+ || redemptionReceipt.value.receipt.memberId !== highest.value.memberId
1800
+ || redemptionReceipt.value.receipt.claimSha256 !== highest.value.claimSha256
1801
+ || redemptionReceipt.value.receipt.receiptId !== highest.value.redemptionReceiptId
1802
+ || redemptionReceipt.value.receipt.checkpointSha256
1803
+ !== sourceClaim.value.checkpointSha256))
1804
+ || (highest?.value.state !== 'redeemed' && redemptionReceipt !== undefined))
1805
+ throw invalidPayload('records');
1806
+ }
1807
+ for (const envelope of claimOverrideEnvelopes.values()) {
1808
+ if (!claimOverrides.some(item => (item.value.transferId === envelope.value.transferId
1809
+ && item.value.memberId === envelope.value.memberId
1810
+ && item.value.claimGeneration === envelope.value.claimGeneration)))
1811
+ throw invalidPayload('records');
1812
+ }
1813
+ const currentOffers = managerOffers.filter(item => (item.value.state === 'offered' || item.value.state === 'acknowledged'));
1814
+ if (managerOffers.some(item => (item.value.sourceManagerMemberId === item.value.targetMemberId
1815
+ || !members.has(item.value.sourceManagerMemberId)
1816
+ || !members.has(item.value.targetMemberId)))
1817
+ || new Set(currentOffers.map(item => item.value.sourceManagerMemberId)).size
1818
+ !== currentOffers.length
1819
+ || new Set(currentOffers.map(item => item.value.targetMemberId)).size
1820
+ !== currentOffers.length)
1821
+ throw invalidPayload('records');
1822
+ for (const tombstone of secretReplayTombstones) {
1823
+ const liveSecretRecord = tombstone.value.operation === 'createProjectInvitation'
1824
+ ? [...invitations.values()].some(item => (item.value.issuedByMemberId === tombstone.value.actorMemberId
1825
+ && item.value.idempotencyKey === tombstone.value.idempotencyKey
1826
+ && invitationEnvelopes.has(item.value.invitationId)))
1827
+ : claimOverrides.some(item => (item.value.managerMemberId === tombstone.value.actorMemberId
1828
+ && item.value.idempotencyKey === tombstone.value.idempotencyKey
1829
+ && claimOverrideEnvelopes.has(`${item.value.transferId}:${item.value.memberId}:${item.value.claimGeneration}`)));
1830
+ if (!members.has(tombstone.value.actorMemberId) || liveSecretRecord) {
1831
+ throw invalidPayload('records');
1832
+ }
1833
+ }
1834
+ for (const tombstone of membershipIdempotencyTombstones) {
1835
+ const exactResult = records.some(item => (item.kind === 'idempotency-result'
1836
+ && item.value.memberId === tombstone.value.actorMemberId
1837
+ && item.value.operation === tombstone.value.operation
1838
+ && item.value.idempotencyKey === tombstone.value.idempotencyKey));
1839
+ const exactOffer = tombstone.value.operation === 'createManagerResponsibilityOffer'
1840
+ && managerOffers.some(item => (item.value.sourceManagerMemberId === tombstone.value.actorMemberId
1841
+ && item.value.idempotencyKey === tombstone.value.idempotencyKey));
1842
+ if (!members.has(tombstone.value.actorMemberId) || exactResult || exactOffer) {
1843
+ throw invalidPayload('records');
1844
+ }
1845
+ }
1200
1846
  for (const item of records) {
1201
1847
  if (item.kind === 'idempotency-result' && !members.has(item.value.memberId)) {
1202
1848
  throw invalidPayload('records');
@@ -1248,6 +1894,19 @@ function validateContinuity(records) {
1248
1894
  const lifecycle = lifecycles.get(receipt.transferId);
1249
1895
  const terminalPrincipal = terminalPrincipals.get(identity);
1250
1896
  const lanToCloud = recovery?.value.sourceAuthority.kind === 'lan';
1897
+ const redeemedOverride = claimOverrides.find(item => (item.value.transferId === receipt.transferId
1898
+ && item.value.memberId === receipt.memberId
1899
+ && item.value.state === 'redeemed'
1900
+ && item.value.claimSha256 === receipt.claimSha256
1901
+ && item.value.redemptionReceiptId === receipt.receiptId));
1902
+ const matchesSourceClaim = claim?.value.state === 'redeemed'
1903
+ && claim.value.claimSha256 === receipt.claimSha256
1904
+ && claim.value.checkpointSha256 === receipt.checkpointSha256
1905
+ && claim.value.operationIntentId === receipt.operationIntentId
1906
+ && claim.value.redemptionReceiptId === receipt.receiptId;
1907
+ const matchesOverride = claim !== undefined
1908
+ && redeemedOverride !== undefined
1909
+ && claim.value.checkpointSha256 === receipt.checkpointSha256;
1251
1910
  if (recovery === undefined
1252
1911
  || lifecycle === undefined
1253
1912
  || memberRecords.get(receipt.memberId)?.value.status !== 'active'
@@ -1255,11 +1914,7 @@ function validateContinuity(records) {
1255
1914
  || !keys.has(`${receipt.transferId}:${receipt.receiptKeyId}`)
1256
1915
  || Date.parse(receipt.redeemedAt) > Date.parse(recovery.value.expiresAt)
1257
1916
  || (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))
1917
+ || (!matchesSourceClaim && !matchesOverride)))
1263
1918
  || (!lanToCloud && (item.value.acknowledgedAt === null
1264
1919
  || receipt.memberId === recovery.value.targetHostMemberId
1265
1920
  || receipt.checkpointSha256 !== lifecycle.value.checkpointSha256
@@ -1454,6 +2109,33 @@ function encodeCollabProjectBackupCheckpointCoordinationNdjson(records) {
1454
2109
  function validateCollabProjectBackupCheckpointConsistency(manifest, records) {
1455
2110
  const decodedManifest = decodeCollabProjectBackupCheckpointManifest(manifest);
1456
2111
  const decodedRecords = decodeCollabProjectBackupCheckpointCoordinationNdjson(records.map(item => JSON.stringify(item)).join('\n') + '\n');
2112
+ const invitationEnvelopeIds = new Set(decodedRecords
2113
+ .filter(item => item.kind === 'protected-invitation-envelope')
2114
+ .map(item => item.recordId));
2115
+ const claimOverrideEnvelopeIds = new Set(decodedRecords
2116
+ .filter(item => item.kind === 'protected-claim-override-envelope')
2117
+ .map(item => item.recordId));
2118
+ const captureTime = Date.parse(decodedManifest.createdAt);
2119
+ if (decodedRecords.some(item => {
2120
+ if (item.kind === 'secret-replay-tombstone') {
2121
+ return Date.parse(item.value.expiredAt) > captureTime;
2122
+ }
2123
+ if (item.kind === 'membership-idempotency-tombstone') {
2124
+ return Date.parse(item.value.compactedAt) > captureTime;
2125
+ }
2126
+ if (item.kind === 'project-invitation') {
2127
+ return captureTime < Date.parse(item.value.createdAt)
2128
+ || (captureTime < Date.parse(item.value.secretReplayExpiresAt))
2129
+ !== invitationEnvelopeIds.has(item.recordId);
2130
+ }
2131
+ if (item.kind === 'transferred-membership-claim-override') {
2132
+ return captureTime < Date.parse(item.value.createdAt)
2133
+ || (captureTime < Date.parse(item.value.secretReplayExpiresAt))
2134
+ !== claimOverrideEnvelopeIds.has(item.recordId);
2135
+ }
2136
+ return false;
2137
+ }))
2138
+ throw invalidPayload('records');
1457
2139
  if (decodedRecords.some(item => {
1458
2140
  switch (item.kind) {
1459
2141
  case 'lifecycle-journal': return item.value.operationId === decodedManifest.operationId;