@peers-app/peers-sdk 0.25.13 → 0.25.15

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.
Files changed (79) hide show
  1. package/dist/context/user-context-identity.test.d.ts +1 -0
  2. package/dist/context/user-context-identity.test.js +220 -0
  3. package/dist/context/user-context.d.ts +5 -1
  4. package/dist/context/user-context.js +63 -8
  5. package/dist/contracts/index.d.ts +1 -1
  6. package/dist/contracts/system/identity-contract.d.ts +156 -0
  7. package/dist/contracts/system/identity-contract.js +172 -0
  8. package/dist/contracts/system/index.d.ts +1 -1
  9. package/dist/data/assistants.d.ts +4 -4
  10. package/dist/data/devices.d.ts +70 -4
  11. package/dist/data/devices.js +3 -0
  12. package/dist/data/group-members.d.ts +2 -2
  13. package/dist/data/groups.d.ts +2 -2
  14. package/dist/data/invites.d.ts +93 -25
  15. package/dist/data/persistent-vars.d.ts +8 -0
  16. package/dist/data/persistent-vars.js +16 -0
  17. package/dist/data/tool-access-approvals.d.ts +2 -2
  18. package/dist/data/user-permissions.d.ts +10 -2
  19. package/dist/data/user-permissions.js +35 -4
  20. package/dist/data/user-permissions.test.js +26 -0
  21. package/dist/data/users.d.ts +48 -4
  22. package/dist/data/users.js +61 -5
  23. package/dist/data/workflows.d.ts +1 -1
  24. package/dist/device/connection.d.ts +7 -1
  25. package/dist/device/connection.js +38 -6
  26. package/dist/device/connection.test.js +33 -4
  27. package/dist/device/device.d.ts +2 -2
  28. package/dist/device/device.js +4 -2
  29. package/dist/device/get-trust-level-fn.d.ts +1 -0
  30. package/dist/device/get-trust-level-fn.js +28 -4
  31. package/dist/device/get-trust-level-fn.test.js +95 -0
  32. package/dist/device-pairing/types.d.ts +26 -26
  33. package/dist/host-credentials.d.ts +78 -0
  34. package/dist/host-credentials.js +128 -0
  35. package/dist/host-credentials.test.d.ts +1 -0
  36. package/dist/host-credentials.test.js +88 -0
  37. package/dist/identity/account-email.d.ts +59 -0
  38. package/dist/identity/account-email.js +87 -0
  39. package/dist/identity/account-email.test.d.ts +1 -0
  40. package/dist/identity/account-email.test.js +59 -0
  41. package/dist/identity/anchor-document.d.ts +205 -0
  42. package/dist/identity/anchor-document.js +119 -0
  43. package/dist/identity/anchor-document.test.d.ts +1 -0
  44. package/dist/identity/anchor-document.test.js +16 -0
  45. package/dist/identity/anchor-resolution.d.ts +61 -0
  46. package/dist/identity/anchor-resolution.js +117 -0
  47. package/dist/identity/anchor-resolution.test.d.ts +1 -0
  48. package/dist/identity/anchor-resolution.test.js +130 -0
  49. package/dist/identity/key-change.d.ts +142 -0
  50. package/dist/identity/key-change.js +472 -0
  51. package/dist/identity/key-change.test.d.ts +1 -0
  52. package/dist/identity/key-change.test.js +811 -0
  53. package/dist/identity/key-contest.d.ts +91 -0
  54. package/dist/identity/key-contest.js +55 -0
  55. package/dist/identity/key-resolutions.d.ts +55 -0
  56. package/dist/identity/key-resolutions.js +42 -0
  57. package/dist/identity/passkey.d.ts +22 -0
  58. package/dist/identity/passkey.js +33 -0
  59. package/dist/identity/user-row-change.d.ts +45 -0
  60. package/dist/identity/user-row-change.js +81 -0
  61. package/dist/index.d.ts +9 -0
  62. package/dist/index.js +9 -0
  63. package/dist/invites/invite-messages.d.ts +512 -116
  64. package/dist/invites/invite-token.d.ts +114 -24
  65. package/dist/invites/invite-token.js +6 -0
  66. package/dist/keys.d.ts +7 -0
  67. package/dist/keys.js +20 -0
  68. package/dist/package-installer/package-author-signing.test.js +11 -0
  69. package/dist/package-installer/package-publisher.d.ts +5 -1
  70. package/dist/package-installer/package-publisher.js +1 -1
  71. package/dist/package-installer/package-publisher.test.js +11 -0
  72. package/dist/package-installer/package-remote-checker.js +3 -0
  73. package/dist/system-ids.d.ts +16 -0
  74. package/dist/system-ids.js +16 -0
  75. package/dist/types/workflow.d.ts +2 -2
  76. package/dist/utils.d.ts +10 -0
  77. package/dist/utils.js +43 -0
  78. package/dist/utils.test.js +23 -0
  79. package/package.json +1 -1
@@ -0,0 +1,472 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.identityRecoveryNoticesVar = exports.identityRotatedAwayVar = exports.identityRotatedAwayEvent = exports.DEFAULT_RECOVERY_DELAY_MS = void 0;
4
+ exports.recoveryDelayMs = recoveryDelayMs;
5
+ exports.setContestFetcher = setContestFetcher;
6
+ exports.peekLocalKeyContest = peekLocalKeyContest;
7
+ exports.setLocalKeyContest = setLocalKeyContest;
8
+ exports.holdKeyContest = holdKeyContest;
9
+ exports.evaluatePresentedKey = evaluatePresentedKey;
10
+ exports.detectIdentityRotatedAway = detectIdentityRotatedAway;
11
+ exports.clearRotatedAwayNotice = clearRotatedAwayNotice;
12
+ exports.acceptRecovery = acceptRecovery;
13
+ exports.applyKeyContest = applyKeyContest;
14
+ exports.evaluatePresentedAnchors = evaluatePresentedAnchors;
15
+ const user_context_singleton_1 = require("../context/user-context-singleton");
16
+ const persistent_vars_1 = require("../data/persistent-vars");
17
+ const users_1 = require("../data/users");
18
+ const events_1 = require("../events");
19
+ const keys_1 = require("../keys");
20
+ const utils_1 = require("../utils");
21
+ const anchor_resolution_1 = require("./anchor-resolution");
22
+ const key_contest_1 = require("./key-contest");
23
+ const key_resolutions_1 = require("./key-resolutions");
24
+ /** How long a recovery waits before peers adopt it. Not a user setting. */
25
+ exports.DEFAULT_RECOVERY_DELAY_MS = 72 * 60 * 60 * 1000;
26
+ /**
27
+ * Delay before a confirmed recovery replaces the key on record.
28
+ * `PEERS_RECOVERY_DELAY_MS` shortens it for tests. Unset means {@link DEFAULT_RECOVERY_DELAY_MS}.
29
+ */
30
+ function recoveryDelayMs() {
31
+ const override = Number(process.env.PEERS_RECOVERY_DELAY_MS);
32
+ if (Number.isFinite(override) && override >= 0)
33
+ return override;
34
+ return exports.DEFAULT_RECOVERY_DELAY_MS;
35
+ }
36
+ /**
37
+ * Fired on a device whose own key is no longer the key the anchors confirm.
38
+ * The device must be paired again. Hosts subscribe to show the notice.
39
+ */
40
+ exports.identityRotatedAwayEvent = new events_1.Emitter("identityRotatedAway");
41
+ /**
42
+ * Device-scoped record of the rotated-away state so the UI can show it after a restart.
43
+ */
44
+ exports.identityRotatedAwayVar = (0, persistent_vars_1.deviceVar)("identityRotatedAway", { defaultValue: null });
45
+ /**
46
+ * Recoveries waiting on the delay or on a contest.
47
+ * The own-device banner and a contact's decision both read this.
48
+ */
49
+ exports.identityRecoveryNoticesVar = (0, persistent_vars_1.deviceVar)("identityRecoveryNotices", { defaultValue: [] });
50
+ let contestFetcher;
51
+ /**
52
+ * The contest this device signed. Device-scoped and persisted: with services off the
53
+ * handshake is the only carrier, and a restart must not silence it.
54
+ */
55
+ const localKeyContestVar = (0, persistent_vars_1.deviceVar)("identityLocalKeyContest", { defaultValue: null });
56
+ const heldContests = [];
57
+ const recoveryTimers = new Map();
58
+ /** Installs the contest lookup {@link evaluatePresentedKey} uses. */
59
+ function setContestFetcher(fetcher) {
60
+ contestFetcher = fetcher;
61
+ }
62
+ /** The contest this device is carrying in its handshake, if it has signed one. */
63
+ function peekLocalKeyContest() {
64
+ return localKeyContestVar() ?? undefined;
65
+ }
66
+ /** Remembers a contest this device signed, so later handshakes carry it, across restarts. */
67
+ async function setLocalKeyContest(signed) {
68
+ await localKeyContestVar.loadingPromise;
69
+ localKeyContestVar(signed ?? null);
70
+ // The write is chained onto loadingPromise; wait for it so the contest survives a restart
71
+ // that follows straight after.
72
+ await localKeyContestVar.loadingPromise;
73
+ }
74
+ /** Remembers a contest received from a peer or an anchor. */
75
+ function holdKeyContest(signed) {
76
+ heldContests.push(signed);
77
+ }
78
+ function boxMatches(publicKey, publicBoxKey) {
79
+ return (0, keys_1.derivePublicBoxKey)(publicKey) === publicBoxKey;
80
+ }
81
+ function cacheFresh(row, now) {
82
+ const checked = Date.parse(row.lastCheckedAt);
83
+ if (Number.isNaN(checked))
84
+ return false;
85
+ return now - checked < row.cacheSeconds * 1000;
86
+ }
87
+ function decisionFromState(state, recoveryPending = false) {
88
+ if (state === "confirmed-rotation")
89
+ return { accepted: true, state: "confirmed-rotation" };
90
+ if (state === "vetoed")
91
+ return { accepted: false, state: "vetoed" };
92
+ if (recoveryPending || state === "confirmed-recovery") {
93
+ return { accepted: false, state: "recovery-pending" };
94
+ }
95
+ return { accepted: false, state: "pending" };
96
+ }
97
+ function recoveryDue(row, now) {
98
+ if (row.contested)
99
+ return false;
100
+ const seen = Date.parse(row.firstSeenAt);
101
+ return Number.isFinite(seen) && now - seen >= recoveryDelayMs();
102
+ }
103
+ function contestsMatch(userId, recordPublicKey, pendingKey, contests) {
104
+ return contests.some((signed) => (0, key_contest_1.verifyKeyContest)({ signed, userId, recordPublicKey, pendingKey }));
105
+ }
106
+ async function loadContests(userId) {
107
+ const remote = contestFetcher ? await contestFetcher(userId).catch(() => []) : [];
108
+ return [...heldContests, ...remote];
109
+ }
110
+ function clearRecoveryTimer(userId, presentedKey) {
111
+ const key = `${userId}:${presentedKey}`;
112
+ const timer = recoveryTimers.get(key);
113
+ if (timer)
114
+ clearTimeout(timer);
115
+ recoveryTimers.delete(key);
116
+ }
117
+ function scheduleRecovery(params, firstSeenAt) {
118
+ const key = `${params.userId}:${params.presentedKey}`;
119
+ clearRecoveryTimer(params.userId, params.presentedKey);
120
+ const wait = Math.max(0, Date.parse(firstSeenAt) + recoveryDelayMs() - Date.now());
121
+ const timer = setTimeout(() => {
122
+ recoveryTimers.delete(key);
123
+ void evaluatePresentedKey(params).catch((err) => {
124
+ console.warn(`Recovery check failed for ${params.userId}`, err);
125
+ });
126
+ }, wait);
127
+ timer.unref?.();
128
+ recoveryTimers.set(key, timer);
129
+ }
130
+ async function rememberResolution(dataContext, existing, next) {
131
+ const nowIso = new Date().toISOString();
132
+ await (0, key_resolutions_1.KeyResolutions)(dataContext).save({
133
+ resolutionId: existing?.resolutionId ?? (0, utils_1.newid)(),
134
+ userId: next.userId,
135
+ presentedKey: next.presentedKey,
136
+ firstSeenAt: existing?.firstSeenAt ?? next.firstSeenAt ?? nowIso,
137
+ lastCheckedAt: next.lastCheckedAt,
138
+ state: next.state,
139
+ successionByRecordKey: next.successionByRecordKey,
140
+ contested: Boolean(existing?.contested || next.contested),
141
+ cacheSeconds: next.cacheSeconds,
142
+ });
143
+ }
144
+ async function switchPersonalKey(dataContext, user, presentedKey, presentedBoxKey) {
145
+ if (user.publicKey === presentedKey && user.publicBoxKey === presentedBoxKey)
146
+ return;
147
+ // The row was signed by the key being replaced; the new key's signed row arrives by sync.
148
+ const { signature: _signature, ...profile } = user;
149
+ await (0, users_1.writeResolvedUserKey)(dataContext, {
150
+ ...profile,
151
+ publicKey: presentedKey,
152
+ publicBoxKey: presentedBoxKey,
153
+ });
154
+ }
155
+ /**
156
+ * Decides whether `presentedKey` may replace the key on record for `userId`.
157
+ *
158
+ * The anchors already stored for the user are fetched (or a fresh cached answer is reused).
159
+ * A confirmed rotation updates the personal `Users` row immediately. A confirmed recovery
160
+ * waits out {@link recoveryDelayMs} and is held if a holder of the key on record contests
161
+ * it. A veto or no answer leaves the key on record in place.
162
+ */
163
+ async function evaluatePresentedKey(params) {
164
+ const personal = await (0, users_1.Users)(params.dataContext).get(params.userId);
165
+ const record = params.record ?? personal;
166
+ if (!record?.publicKey)
167
+ return { accepted: false, state: "no-record" };
168
+ if (params.presentedKey === record.publicKey) {
169
+ if (params.presentedBoxKey !== record.publicBoxKey) {
170
+ return { accepted: false, state: "box-mismatch" };
171
+ }
172
+ return { accepted: true, state: "current" };
173
+ }
174
+ if (!boxMatches(params.presentedKey, params.presentedBoxKey)) {
175
+ return { accepted: false, state: "box-mismatch" };
176
+ }
177
+ const existing = (await (0, key_resolutions_1.KeyResolutions)(params.dataContext).findOne({
178
+ userId: params.userId,
179
+ presentedKey: params.presentedKey,
180
+ })) ?? null;
181
+ const now = Date.now();
182
+ if (existing && cacheFresh(existing, now) && existing.state !== "confirmed-recovery") {
183
+ if (existing.state === "confirmed-rotation" && personal && params.adopt !== false) {
184
+ await switchPersonalKey(params.dataContext, personal, params.presentedKey, params.presentedBoxKey);
185
+ }
186
+ return decisionFromState(existing.state);
187
+ }
188
+ if (existing?.state === "confirmed-recovery" && cacheFresh(existing, now)) {
189
+ // The anchor was asked for contests when this row was last checked. The recovering device
190
+ // handshakes often and each handshake lands here, so within the cache window only contests
191
+ // that reached this device directly are considered, except at the moment the delay is up:
192
+ // the anchor is asked once more before the key is adopted.
193
+ let row = existing;
194
+ if (!row.contested) {
195
+ const contests = recoveryDue(row, now) ? await loadContests(params.userId) : heldContests;
196
+ const matched = contestsMatch(params.userId, record.publicKey, params.presentedKey, contests);
197
+ if (matched) {
198
+ row = { ...row, contested: true };
199
+ await rememberResolution(params.dataContext, existing, { ...row, contested: true });
200
+ clearRecoveryTimer(params.userId, params.presentedKey);
201
+ }
202
+ }
203
+ return finishRecovery(params, personal ?? null, record.publicKey, row, now);
204
+ }
205
+ const anchors = record.anchors ?? [];
206
+ const fetcher = (0, anchor_resolution_1.getAnchorFetcher)();
207
+ let outcomes = anchors.map(() => ({ outcome: "abstain" }));
208
+ if (fetcher && anchors.length > 0) {
209
+ outcomes = await Promise.all(anchors.map(async (anchor) => {
210
+ try {
211
+ return await fetcher(anchor, params.userId);
212
+ }
213
+ catch (err) {
214
+ console.warn(`Anchor ${anchor.url} failed for ${params.userId}`, err);
215
+ return { outcome: "abstain" };
216
+ }
217
+ }));
218
+ }
219
+ const resolution = (0, anchor_resolution_1.resolvePresentedKey)({
220
+ userId: params.userId,
221
+ recordPublicKey: record.publicKey,
222
+ presentedPublicKey: params.presentedKey,
223
+ outcomes,
224
+ });
225
+ const contested = resolution.state === "confirmed-recovery" &&
226
+ contestsMatch(params.userId, record.publicKey, params.presentedKey, await loadContests(params.userId));
227
+ const firstSeenAt = existing?.firstSeenAt ?? new Date(now).toISOString();
228
+ await rememberResolution(params.dataContext, existing, {
229
+ userId: params.userId,
230
+ presentedKey: params.presentedKey,
231
+ firstSeenAt,
232
+ lastCheckedAt: new Date(now).toISOString(),
233
+ state: resolution.state,
234
+ successionByRecordKey: resolution.successionByRecordKey,
235
+ contested,
236
+ cacheSeconds: resolution.cacheSeconds,
237
+ });
238
+ if (resolution.state === "confirmed-rotation" && personal && params.adopt !== false) {
239
+ await switchPersonalKey(params.dataContext, personal, params.presentedKey, params.presentedBoxKey);
240
+ return { accepted: true, state: "confirmed-rotation" };
241
+ }
242
+ if (resolution.state !== "confirmed-recovery") {
243
+ return decisionFromState(resolution.state);
244
+ }
245
+ const saved = await (0, key_resolutions_1.KeyResolutions)(params.dataContext).findOne({
246
+ userId: params.userId,
247
+ presentedKey: params.presentedKey,
248
+ });
249
+ return finishRecovery(params, personal ?? null, record.publicKey, saved ?? {
250
+ resolutionId: "",
251
+ userId: params.userId,
252
+ presentedKey: params.presentedKey,
253
+ firstSeenAt,
254
+ lastCheckedAt: new Date(now).toISOString(),
255
+ state: "confirmed-recovery",
256
+ successionByRecordKey: false,
257
+ contested,
258
+ cacheSeconds: resolution.cacheSeconds,
259
+ }, now);
260
+ }
261
+ async function finishRecovery(params, personal, recordPublicKey, row, now) {
262
+ if (row.contested || !recoveryDue(row, now)) {
263
+ if (!row.contested)
264
+ scheduleRecovery(params, row.firstSeenAt);
265
+ await publishRecoveryNotice(params, recordPublicKey, row);
266
+ return { accepted: false, state: "recovery-pending" };
267
+ }
268
+ clearRecoveryTimer(params.userId, params.presentedKey);
269
+ // With `adopt: false` the caller (detectIdentityRotatedAway) records that this device
270
+ // holds the old key; it knows the device's key, which the row may no longer match.
271
+ if (personal && params.adopt !== false) {
272
+ await switchPersonalKey(params.dataContext, personal, params.presentedKey, params.presentedBoxKey);
273
+ }
274
+ await clearRecoveryNotice(params.userId, params.presentedKey);
275
+ return { accepted: true, state: "confirmed-recovery" };
276
+ }
277
+ async function publishRecoveryNotice(params, recordPublicKey, row) {
278
+ const userContext = (0, user_context_singleton_1.peekUserContext)();
279
+ const notice = {
280
+ userId: params.userId,
281
+ recordPublicKey,
282
+ pendingPublicKey: params.presentedKey,
283
+ firstSeenAt: row.firstSeenAt,
284
+ readyAt: new Date(Date.parse(row.firstSeenAt) + recoveryDelayMs()).toISOString(),
285
+ contested: row.contested,
286
+ own: userContext?.userId === params.userId,
287
+ };
288
+ try {
289
+ await exports.identityRecoveryNoticesVar.loadingPromise;
290
+ const current = (0, exports.identityRecoveryNoticesVar)() ?? [];
291
+ const next = current.filter((item) => !(item.userId === notice.userId && item.pendingPublicKey === notice.pendingPublicKey));
292
+ next.push(notice);
293
+ (0, exports.identityRecoveryNoticesVar)(next);
294
+ }
295
+ catch (err) {
296
+ console.warn("Could not persist a recovery notice", err);
297
+ }
298
+ }
299
+ async function clearRecoveryNotice(userId, pendingPublicKey) {
300
+ try {
301
+ await exports.identityRecoveryNoticesVar.loadingPromise;
302
+ const current = (0, exports.identityRecoveryNoticesVar)() ?? [];
303
+ (0, exports.identityRecoveryNoticesVar)(current.filter((item) => !(item.userId === userId && item.pendingPublicKey === pendingPublicKey)));
304
+ }
305
+ catch {
306
+ // The notice is a display aid. The Users row is the record.
307
+ }
308
+ }
309
+ /**
310
+ * When another device of this user presents a key the anchors confirm, records that this
311
+ * device holds the old key and must be paired again. A pending or vetoed key does not.
312
+ */
313
+ async function detectIdentityRotatedAway(me, evidence) {
314
+ if (evidence.userId !== me.userId || evidence.publicKey === me.publicKey)
315
+ return undefined;
316
+ // A handshake can run in a process with no user context (connection unit tests,
317
+ // and anything that has not finished startup). Waiting here would stall the handshake.
318
+ const userContext = (0, user_context_singleton_1.peekUserContext)();
319
+ if (!userContext)
320
+ return undefined;
321
+ await userContext.loadingPromise;
322
+ const decision = await evaluatePresentedKey({
323
+ dataContext: userContext.userDataContext,
324
+ userId: me.userId,
325
+ presentedKey: evidence.publicKey,
326
+ presentedBoxKey: evidence.publicBoxKey,
327
+ adopt: false,
328
+ });
329
+ if (!decision.accepted)
330
+ return undefined;
331
+ if (decision.state !== "confirmed-rotation" && decision.state !== "confirmed-recovery") {
332
+ return undefined;
333
+ }
334
+ const payload = {
335
+ userId: me.userId,
336
+ oldPublicKey: me.publicKey,
337
+ newPublicKey: evidence.publicKey,
338
+ ...(evidence.deviceId ? { seenFromDeviceId: evidence.deviceId } : {}),
339
+ detectedAt: new Date().toISOString(),
340
+ };
341
+ console.error(`This device's key for user ${me.userId} was confirmed rotated away${evidence.deviceId ? ` by device ${evidence.deviceId}` : ""}; pair this device again`);
342
+ try {
343
+ await exports.identityRotatedAwayVar.loadingPromise;
344
+ (0, exports.identityRotatedAwayVar)(payload);
345
+ }
346
+ catch (err) {
347
+ console.warn("Could not persist identityRotatedAway state", err);
348
+ }
349
+ await exports.identityRotatedAwayEvent.emit(payload);
350
+ return payload;
351
+ }
352
+ /**
353
+ * Drops the rotated-away notice when it no longer describes this device: it now holds the
354
+ * key the notice named, or a key the anchors have since confirmed (`anchorsConfirmed`).
355
+ * A device that still holds the retired key keeps its notice.
356
+ */
357
+ async function clearRotatedAwayNotice(params) {
358
+ // The notice lives in a device-scoped pvar, which loads through the process-wide user
359
+ // context. A context that is not installed as the singleton (tests, tooling) has none.
360
+ if (!(0, user_context_singleton_1.peekUserContext)())
361
+ return;
362
+ try {
363
+ await exports.identityRotatedAwayVar.loadingPromise;
364
+ const notice = (0, exports.identityRotatedAwayVar)();
365
+ if (!notice || notice.userId !== params.userId)
366
+ return;
367
+ if (notice.newPublicKey === params.deviceKey || params.anchorsConfirmed) {
368
+ (0, exports.identityRotatedAwayVar)(null);
369
+ }
370
+ }
371
+ catch (err) {
372
+ console.warn("Could not clear identityRotatedAway state", err);
373
+ }
374
+ }
375
+ /**
376
+ * Adopts a recovery before the delay when the person at this device says to.
377
+ * A contest keeps the change pending until this runs.
378
+ */
379
+ async function acceptRecovery(dataContext, userId, pendingKey) {
380
+ const row = await (0, key_resolutions_1.KeyResolutions)(dataContext).findOne({ userId, presentedKey: pendingKey });
381
+ if (!row || row.state !== "confirmed-recovery") {
382
+ throw new Error("No recovery is waiting for that key");
383
+ }
384
+ const personal = await (0, users_1.Users)(dataContext).get(userId);
385
+ if (!personal)
386
+ throw new Error("No profile is stored for that user");
387
+ const boxKey = (0, keys_1.derivePublicBoxKey)(pendingKey);
388
+ if (!boxKey)
389
+ throw new Error("The recovered key is not a signing key");
390
+ clearRecoveryTimer(userId, pendingKey);
391
+ await switchPersonalKey(dataContext, personal, pendingKey, boxKey);
392
+ await clearRecoveryNotice(userId, pendingKey);
393
+ }
394
+ /**
395
+ * Records a contest against `pendingKey` and holds the recovery.
396
+ * Returns false when the signature is not the key on record.
397
+ */
398
+ async function applyKeyContest(dataContext, signed) {
399
+ const statement = signed.contents;
400
+ if (!statement?.userId || !statement.pendingKey)
401
+ return false;
402
+ const personal = await (0, users_1.Users)(dataContext).get(statement.userId);
403
+ const recordPublicKey = personal?.publicKey;
404
+ if (!recordPublicKey)
405
+ return false;
406
+ if (!(0, key_contest_1.verifyKeyContest)({
407
+ signed,
408
+ userId: statement.userId,
409
+ recordPublicKey,
410
+ pendingKey: statement.pendingKey,
411
+ })) {
412
+ return false;
413
+ }
414
+ holdKeyContest(signed);
415
+ const existing = await (0, key_resolutions_1.KeyResolutions)(dataContext).findOne({
416
+ userId: statement.userId,
417
+ presentedKey: statement.pendingKey,
418
+ });
419
+ if (existing) {
420
+ await rememberResolution(dataContext, existing, { ...existing, contested: true });
421
+ }
422
+ clearRecoveryTimer(statement.userId, statement.pendingKey);
423
+ const pendingBoxKey = (0, keys_1.derivePublicBoxKey)(statement.pendingKey);
424
+ if (existing && pendingBoxKey) {
425
+ await publishRecoveryNotice({
426
+ dataContext,
427
+ userId: statement.userId,
428
+ presentedKey: statement.pendingKey,
429
+ presentedBoxKey: pendingBoxKey,
430
+ }, recordPublicKey, { ...existing, contested: true });
431
+ }
432
+ return true;
433
+ }
434
+ /**
435
+ * Decides whether `presentedAnchors` may replace the list on record.
436
+ * The anchors already stored are fetched. On confirmation the personal row's list is updated.
437
+ * The signing key is left as it is.
438
+ */
439
+ async function evaluatePresentedAnchors(params) {
440
+ if ((0, anchor_resolution_1.sameAnchorList)(params.recordAnchors, params.presentedAnchors))
441
+ return "confirmed";
442
+ if (params.recordAnchors.length === 0)
443
+ return "vetoed";
444
+ const fetcher = (0, anchor_resolution_1.getAnchorFetcher)();
445
+ if (!fetcher)
446
+ return "vetoed";
447
+ const outcomes = await Promise.all(params.recordAnchors.map(async (anchor) => {
448
+ try {
449
+ return await fetcher(anchor, params.userId);
450
+ }
451
+ catch (err) {
452
+ console.warn(`Anchor ${anchor.url} failed for ${params.userId}`, err);
453
+ return { outcome: "abstain" };
454
+ }
455
+ }));
456
+ const decision = (0, anchor_resolution_1.resolvePresentedAnchors)({
457
+ userId: params.userId,
458
+ presented: params.presentedAnchors,
459
+ outcomes,
460
+ });
461
+ if (decision !== "confirmed")
462
+ return decision;
463
+ const personal = await (0, users_1.Users)(params.dataContext).get(params.userId);
464
+ if (personal?.publicKey) {
465
+ // The list is not under the row's signature, so a signed row stays signed.
466
+ await (0, users_1.writeResolvedUserKey)(params.dataContext, {
467
+ ...personal,
468
+ anchors: params.presentedAnchors,
469
+ });
470
+ }
471
+ return "confirmed";
472
+ }
@@ -0,0 +1 @@
1
+ import "./user-row-change";