1// courtesy of the best 🐇 mary
2// https://github.com/mary-ext/boat/blob/trunk/src/views/identity/plc-oplogs.tsx
3import { IndexedEntry, Service } from "@atcute/did-plc";
4
5export type DiffEntry =
6 | {
7 type: "identity_created";
8 orig: IndexedEntry;
9 nullified: boolean;
10 at: string;
11 rotationKeys: string[];
12 verificationMethods: Record<string, string>;
13 alsoKnownAs: string[];
14 services: Record<string, { type: string; endpoint: string }>;
15 }
16 | {
17 type: "identity_tombstoned";
18 orig: IndexedEntry;
19 nullified: boolean;
20 at: string;
21 }
22 | {
23 type: "rotation_key_added";
24 orig: IndexedEntry;
25 nullified: boolean;
26 at: string;
27 rotation_key: string;
28 }
29 | {
30 type: "rotation_key_removed";
31 orig: IndexedEntry;
32 nullified: boolean;
33 at: string;
34 rotation_key: string;
35 }
36 | {
37 type: "verification_method_added";
38 orig: IndexedEntry;
39 nullified: boolean;
40 at: string;
41 method_id: string;
42 method_key: string;
43 }
44 | {
45 type: "verification_method_removed";
46 orig: IndexedEntry;
47 nullified: boolean;
48 at: string;
49 method_id: string;
50 method_key: string;
51 }
52 | {
53 type: "verification_method_changed";
54 orig: IndexedEntry;
55 nullified: boolean;
56 at: string;
57 method_id: string;
58 prev_method_key: string;
59 next_method_key: string;
60 }
61 | {
62 type: "handle_added";
63 orig: IndexedEntry;
64 nullified: boolean;
65 at: string;
66 handle: string;
67 }
68 | {
69 type: "handle_removed";
70 orig: IndexedEntry;
71 nullified: boolean;
72 at: string;
73 handle: string;
74 }
75 | {
76 type: "handle_changed";
77 orig: IndexedEntry;
78 nullified: boolean;
79 at: string;
80 prev_handle: string;
81 next_handle: string;
82 }
83 | {
84 type: "service_added";
85 orig: IndexedEntry;
86 nullified: boolean;
87 at: string;
88 service_id: string;
89 service_type: string;
90 service_endpoint: string;
91 }
92 | {
93 type: "service_removed";
94 orig: IndexedEntry;
95 nullified: boolean;
96 at: string;
97 service_id: string;
98 service_type: string;
99 service_endpoint: string;
100 }
101 | {
102 type: "service_changed";
103 orig: IndexedEntry;
104 nullified: boolean;
105 at: string;
106 service_id: string;
107 prev_service_type: string;
108 next_service_type: string;
109 prev_service_endpoint: string;
110 next_service_endpoint: string;
111 };
112
113export const createOperationHistory = (entries: IndexedEntry[]): DiffEntry[] => {
114 const history: DiffEntry[] = [];
115
116 for (let idx = 0, len = entries.length; idx < len; idx++) {
117 const entry = entries[idx];
118 const op = entry.operation;
119
120 if (op.type === "create") {
121 history.push({
122 type: "identity_created",
123 orig: entry,
124 nullified: entry.nullified,
125 at: entry.createdAt,
126 rotationKeys: [op.recoveryKey, op.signingKey],
127 verificationMethods: { atproto: op.signingKey },
128 alsoKnownAs: [`at://${op.handle}`],
129 services: {
130 atproto_pds: {
131 type: "AtprotoPersonalDataServer",
132 endpoint: op.service,
133 },
134 },
135 });
136 } else if (op.type === "plc_operation") {
137 const prevOp = findLastMatching(entries, (entry) => !entry.nullified, idx - 1)?.operation;
138
139 let oldRotationKeys: string[];
140 let oldVerificationMethods: Record<string, string>;
141 let oldAlsoKnownAs: string[];
142 let oldServices: Record<string, Service>;
143
144 if (!prevOp) {
145 history.push({
146 type: "identity_created",
147 orig: entry,
148 nullified: entry.nullified,
149 at: entry.createdAt,
150 rotationKeys: op.rotationKeys,
151 verificationMethods: op.verificationMethods,
152 alsoKnownAs: op.alsoKnownAs,
153 services: op.services,
154 });
155
156 continue;
157 } else if (prevOp.type === "create") {
158 oldRotationKeys = [prevOp.recoveryKey, prevOp.signingKey];
159 oldVerificationMethods = { atproto: prevOp.signingKey };
160 oldAlsoKnownAs = [`at://${prevOp.handle}`];
161 oldServices = {
162 atproto_pds: {
163 type: "AtprotoPersonalDataServer",
164 endpoint: prevOp.service,
165 },
166 };
167 } else if (prevOp.type === "plc_operation") {
168 oldRotationKeys = prevOp.rotationKeys;
169 oldVerificationMethods = prevOp.verificationMethods;
170 oldAlsoKnownAs = prevOp.alsoKnownAs;
171 oldServices = prevOp.services;
172 } else {
173 continue;
174 }
175
176 // Check for rotation key changes
177 {
178 const additions = difference(op.rotationKeys, oldRotationKeys);
179 const removals = difference(oldRotationKeys, op.rotationKeys);
180
181 for (const key of additions) {
182 history.push({
183 type: "rotation_key_added",
184 orig: entry,
185 nullified: entry.nullified,
186 at: entry.createdAt,
187 rotation_key: key,
188 });
189 }
190
191 for (const key of removals) {
192 history.push({
193 type: "rotation_key_removed",
194 orig: entry,
195 nullified: entry.nullified,
196 at: entry.createdAt,
197 rotation_key: key,
198 });
199 }
200 }
201
202 // Check for verification method changes
203 {
204 for (const id in op.verificationMethods) {
205 if (!(id in oldVerificationMethods)) {
206 history.push({
207 type: "verification_method_added",
208 orig: entry,
209 nullified: entry.nullified,
210 at: entry.createdAt,
211 method_id: id,
212 method_key: op.verificationMethods[id],
213 });
214 } else if (op.verificationMethods[id] !== oldVerificationMethods[id]) {
215 history.push({
216 type: "verification_method_changed",
217 orig: entry,
218 nullified: entry.nullified,
219 at: entry.createdAt,
220 method_id: id,
221 prev_method_key: oldVerificationMethods[id],
222 next_method_key: op.verificationMethods[id],
223 });
224 }
225 }
226
227 for (const id in oldVerificationMethods) {
228 if (!(id in op.verificationMethods)) {
229 history.push({
230 type: "verification_method_removed",
231 orig: entry,
232 nullified: entry.nullified,
233 at: entry.createdAt,
234 method_id: id,
235 method_key: oldVerificationMethods[id],
236 });
237 }
238 }
239 }
240
241 // Check for handle changes
242 if (op.alsoKnownAs.length === 1 && oldAlsoKnownAs.length === 1) {
243 if (op.alsoKnownAs[0] !== oldAlsoKnownAs[0]) {
244 history.push({
245 type: "handle_changed",
246 orig: entry,
247 nullified: entry.nullified,
248 at: entry.createdAt,
249 prev_handle: oldAlsoKnownAs[0],
250 next_handle: op.alsoKnownAs[0],
251 });
252 }
253 } else {
254 const additions = difference(op.alsoKnownAs, oldAlsoKnownAs);
255 const removals = difference(oldAlsoKnownAs, op.alsoKnownAs);
256
257 for (const handle of additions) {
258 history.push({
259 type: "handle_added",
260 orig: entry,
261 nullified: entry.nullified,
262 at: entry.createdAt,
263 handle: handle,
264 });
265 }
266
267 for (const handle of removals) {
268 history.push({
269 type: "handle_removed",
270 orig: entry,
271 nullified: entry.nullified,
272 at: entry.createdAt,
273 handle: handle,
274 });
275 }
276 }
277
278 // Check for service changes
279 {
280 for (const id in op.services) {
281 if (!(id in oldServices)) {
282 history.push({
283 type: "service_added",
284 orig: entry,
285 nullified: entry.nullified,
286 at: entry.createdAt,
287 service_id: id,
288 service_type: op.services[id].type,
289 service_endpoint: op.services[id].endpoint,
290 });
291 } else if (!dequal(op.services[id], oldServices[id])) {
292 history.push({
293 type: "service_changed",
294 orig: entry,
295 nullified: entry.nullified,
296 at: entry.createdAt,
297 service_id: id,
298 prev_service_type: oldServices[id].type,
299 next_service_type: op.services[id].type,
300 prev_service_endpoint: oldServices[id].endpoint,
301 next_service_endpoint: op.services[id].endpoint,
302 });
303 }
304 }
305
306 for (const id in oldServices) {
307 if (!(id in op.services)) {
308 history.push({
309 type: "service_removed",
310 orig: entry,
311 nullified: entry.nullified,
312 at: entry.createdAt,
313 service_id: id,
314 service_type: oldServices[id].type,
315 service_endpoint: oldServices[id].endpoint,
316 });
317 }
318 }
319 }
320 } else if (op.type === "plc_tombstone") {
321 history.push({
322 type: "identity_tombstoned",
323 orig: entry,
324 nullified: entry.nullified,
325 at: entry.createdAt,
326 });
327 }
328 }
329
330 return history;
331};
332
333function findLastMatching<T, S extends T>(
334 arr: T[],
335 predicate: (item: T) => item is S,
336 start?: number,
337): S | undefined;
338function findLastMatching<T>(
339 arr: T[],
340 predicate: (item: T) => boolean,
341 start?: number,
342): T | undefined;
343function findLastMatching<T>(
344 arr: T[],
345 predicate: (item: T) => boolean,
346 start: number = arr.length - 1,
347): T | undefined {
348 for (let i = start, v: any; i >= 0; i--) {
349 if (predicate((v = arr[i]))) {
350 return v;
351 }
352 }
353
354 return undefined;
355}
356
357function difference<T>(a: readonly T[], b: readonly T[]): T[] {
358 const set = new Set(b);
359 return a.filter((value) => !set.has(value));
360}
361
362const dequal = (a: any, b: any): boolean => {
363 let ctor: any;
364 let len: number;
365
366 if (a === b) {
367 return true;
368 }
369
370 if (a && b && (ctor = a.constructor) === b.constructor) {
371 if (ctor === Array) {
372 if ((len = a.length) === b.length) {
373 while (len--) {
374 if (!dequal(a[len], b[len])) {
375 return false;
376 }
377 }
378 }
379
380 return len === -1;
381 } else if (!ctor || ctor === Object) {
382 len = 0;
383
384 for (ctor in a) {
385 len++;
386
387 if (!(ctor in b) || !dequal(a[ctor], b[ctor])) {
388 return false;
389 }
390 }
391
392 return Object.keys(b).length === len;
393 }
394 }
395
396 return a !== a && b !== b;
397};
398
399export const groupBy = <K, T>(items: T[], keyFn: (item: T, index: number) => K): Map<K, T[]> => {
400 const map = new Map<K, T[]>();
401
402 for (let idx = 0, len = items.length; idx < len; idx++) {
403 const val = items[idx];
404 const key = keyFn(val, idx);
405
406 const list = map.get(key);
407
408 if (list !== undefined) {
409 list.push(val);
410 } else {
411 map.set(key, [val]);
412 }
413 }
414
415 return map;
416};