@joe-broadhead/messaging-bin-win32-x64 0.5.0 → 0.6.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.
@@ -7276,14 +7276,14 @@ var init_direct = __esm(() => {
7276
7276
  });
7277
7277
 
7278
7278
  // packages/core/dist/schema/ipc/device.js
7279
- var BindingState, RealmAlarmSummary, DeviceStatus, DeviceListEntry, LinkText, Sas, LinkPreview, KeyFingerprint, LoginUserCode, LoginPurpose, LoginKind, LoginShown, LoginState, DeviceRemoveParams, DeviceApproveParams, DEVICE_METHODS;
7279
+ var BindingState, RealmAlarmSummary, DeviceStatus, DeviceChangeView, DeviceListEntry, LinkText, Sas, LinkPreview, KeyFingerprint, LoginUserCode, LoginPurpose, LoginKind, LoginShown, LoginState, DeviceRemoveParams, DeviceApproveParams, IdentityReplaceParams, SignInStep, DirectoryEraseParams, DEVICE_METHODS;
7280
7280
  var init_device = __esm(() => {
7281
7281
  init_zod();
7282
7282
  init_common();
7283
7283
  init_directory();
7284
7284
  init_errors3();
7285
7285
  init_jelly();
7286
- BindingState = _enum(["none", "bound", "attest-due", "removed"]);
7286
+ BindingState = _enum(["none", "bound", "attest-due", "removed", "replace"]);
7287
7287
  RealmAlarmSummary = object({
7288
7288
  kind: _enum(["directory", "restored"]),
7289
7289
  reason: string2().max(500),
@@ -7298,6 +7298,16 @@ var init_device = __esm(() => {
7298
7298
  sth: object({ size: number2().int().positive(), root: Hash, ts: Millis }).optional(),
7299
7299
  alarm: RealmAlarmSummary.optional()
7300
7300
  });
7301
+ DeviceChangeView = object({
7302
+ change: _enum(["added", "vouched", "removed", "replaced"]),
7303
+ device: MachineId,
7304
+ label: MachineLabel,
7305
+ kind: DeviceKind.optional(),
7306
+ vouch: _enum(["jelly", "device", "admin"]),
7307
+ via: _enum(["host", "browser"]).optional(),
7308
+ approver: MachineLabel.optional(),
7309
+ at: Millis
7310
+ });
7301
7311
  DeviceListEntry = object({
7302
7312
  device: MachineId,
7303
7313
  label: MachineLabel,
@@ -7314,7 +7324,7 @@ var init_device = __esm(() => {
7314
7324
  LinkPreview = object({ device: MachineId, label: MachineLabel, kind: DeviceKind, sas: Sas });
7315
7325
  KeyFingerprint = string2().regex(/^[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}$/, "key fingerprint");
7316
7326
  LoginUserCode = string2().regex(/^[A-HJ-NP-Z2-9]{5}-[A-HJ-NP-Z2-9]{5}$/, "user code");
7317
- LoginPurpose = _enum(["bind", "remove"]);
7327
+ LoginPurpose = _enum(["bind", "remove", "replace", "erase"]);
7318
7328
  LoginKind = _enum(["laptop", "server", "other"]);
7319
7329
  LoginShown = object({
7320
7330
  verificationUriComplete: string2().url().max(2048),
@@ -7332,6 +7342,14 @@ var init_device = __esm(() => {
7332
7342
  ]);
7333
7343
  DeviceRemoveParams = strictObject({ device: MachineId, reason: _enum(["self", "removed", "lost"]).optional(), token: Jws.optional() });
7334
7344
  DeviceApproveParams = strictObject({ link: LinkText, sas: Sas });
7345
+ IdentityReplaceParams = strictObject({
7346
+ token: Jws.optional(),
7347
+ label: MachineLabel.optional()
7348
+ });
7349
+ SignInStep = LoginShown.extend({ state: literal("sign-in"), purpose: LoginPurpose, interval: number2().int().positive() });
7350
+ DirectoryEraseParams = strictObject({
7351
+ token: Jws.optional()
7352
+ });
7335
7353
  DEVICE_METHODS = {
7336
7354
  "device.status": { params: strictObject({}), result: DeviceStatus },
7337
7355
  "device.bind": {
@@ -7361,6 +7379,47 @@ var init_device = __esm(() => {
7361
7379
  "device.approve": {
7362
7380
  params: DeviceApproveParams,
7363
7381
  result: object({ device: MachineId, seq: number2().int().nonnegative() })
7382
+ },
7383
+ "identity.replace": {
7384
+ params: IdentityReplaceParams,
7385
+ result: discriminatedUnion("state", [
7386
+ object({ state: literal("done"), person: PersonId, device: MachineId, predecessor: MachineId, seq: number2().int().nonnegative() }),
7387
+ object({ state: literal("token"), device: MachineId }),
7388
+ SignInStep.extend({ device: MachineId })
7389
+ ])
7390
+ },
7391
+ "identity.replace.preview": {
7392
+ params: IdentityReplaceParams,
7393
+ result: object({
7394
+ predecessor: object({
7395
+ device: MachineId,
7396
+ label: MachineLabel,
7397
+ kind: DeviceKind.optional(),
7398
+ vouch: _enum(["jelly", "device"]),
7399
+ via: _enum(["host", "browser"]).optional(),
7400
+ since: Millis.optional(),
7401
+ current: boolean2()
7402
+ }),
7403
+ alarm: _enum(["keys_missing", "restored"]).nullable(),
7404
+ device: MachineId.optional(),
7405
+ signIns: number2().int().min(0).max(2),
7406
+ sentence: string2().max(600)
7407
+ })
7408
+ },
7409
+ "directory.erase": {
7410
+ params: DirectoryEraseParams,
7411
+ result: discriminatedUnion("state", [object({ state: literal("done"), person: PersonId, devices: number2().int().nonnegative() }), SignInStep])
7412
+ },
7413
+ "directory.erase.preview": {
7414
+ params: DirectoryEraseParams,
7415
+ result: object({
7416
+ person: PersonId,
7417
+ devices: array(object({ device: MachineId, label: MachineLabel, kind: DeviceKind.optional(), self: boolean2() })),
7418
+ signIns: number2().int().min(0).max(1),
7419
+ perDay: number2().int().positive(),
7420
+ lost: array(string2().max(300)).max(10),
7421
+ kept: array(string2().max(300)).max(10)
7422
+ })
7364
7423
  }
7365
7424
  };
7366
7425
  });
@@ -7541,7 +7600,7 @@ var init_consent2 = __esm(() => {
7541
7600
  HeldRequest = union([AskRequest, ContactRequestView, ContactSuggestion]);
7542
7601
  RequestAnswer = _enum(["once", "always", "accept", "decline", "block"]);
7543
7602
  RequestText = string2().min(1).refine((text) => text.length <= LIMITS.requestChars, { message: `at most ${LIMITS.requestChars} UTF-16 code units` });
7544
- PersonReach = object({ person: PersonId, can: CardCan, source: _enum(["card", "default"]), at: Millis.optional() });
7603
+ PersonReach = object({ person: PersonId, can: CardCan, source: _enum(["card", "default"]), at: Millis.optional(), devicesChangedAt: Millis.optional() });
7545
7604
  RequestAnswerParams = strictObject({ id: Ulid, answer: RequestAnswer, all: boolean2().optional() });
7546
7605
  ContactRequestParams = strictObject({ email: Email, text: RequestText, suggestion: Ulid.optional() });
7547
7606
  CONSENT_METHODS = {
@@ -7586,6 +7645,7 @@ var init_surfaces = __esm(() => {
7586
7645
  init_directory_frames();
7587
7646
  init_errors3();
7588
7647
  init_consent2();
7648
+ init_device();
7589
7649
  init_shapes();
7590
7650
  ConversationRef = string2().regex(/^(?:dm:(?:[a-z][a-z0-9-]{0,31}|p_[a-z2-7]{26})|channel:[a-z][a-z0-9-]{0,31}|agent:(?:[a-z][a-z0-9-]{0,31}|p_[a-z2-7]{26})\/[a-z0-9][a-z0-9._-]{0,63})$/, "dm:<person>, channel:<name> or agent:<person>/<agent>");
7591
7651
  ConvPlace = union([ChatSeq, ConvCursor]);
@@ -7690,13 +7750,16 @@ var init_surfaces = __esm(() => {
7690
7750
  },
7691
7751
  "people.get": {
7692
7752
  params: strictObject({ person: PersonId }),
7693
- result: PersonSummary.extend({ devices: array(object({ device: MachineId, label: MachineLabel, self: boolean2(), away: literal(true).optional() })) })
7753
+ result: PersonSummary.extend({
7754
+ devices: array(object({ device: MachineId, label: MachineLabel, self: boolean2(), away: literal(true).optional() })),
7755
+ deviceChanges: array(DeviceChangeView).max(10).optional()
7756
+ })
7694
7757
  }
7695
7758
  };
7696
7759
  });
7697
7760
 
7698
7761
  // packages/core/dist/schema/ipc/jelly.js
7699
- var NoticeId, NOTICE_LINE_CHARS = 120, Notice, NoticeCounts, DaemonHealth, AgentSummary, AgentConversation, JELLY_METHODS;
7762
+ var NoticeId, NOTICE_LINE_CHARS = 120, NoticeDevice, Notice, NoticeCounts, DaemonHealth, AgentSummary, AgentConversation, JELLY_METHODS;
7700
7763
  var init_jelly2 = __esm(() => {
7701
7764
  init_zod();
7702
7765
  init_common();
@@ -7707,12 +7770,18 @@ var init_jelly2 = __esm(() => {
7707
7770
  init_shapes();
7708
7771
  init_surfaces();
7709
7772
  NoticeId = string2().regex(/^[mra]_[0-9A-Za-z]{1,64}$/, "notice id");
7773
+ NoticeDevice = DeviceChangeView.extend({
7774
+ replaces: object({ device: MachineId, label: MachineLabel }).optional(),
7775
+ current: boolean2(),
7776
+ remove: _enum(["token", "sign-in", "team"]).optional()
7777
+ });
7710
7778
  Notice = object({
7711
7779
  id: NoticeId,
7712
7780
  category: _enum(["message", "request", "alert"]),
7713
7781
  conversation: ConversationRef.optional(),
7714
7782
  from: object({ name: string2().max(200), email: string2().max(320).optional(), org: _enum(["same", "other", "none"]) }).optional(),
7715
7783
  line: string2().max(NOTICE_LINE_CHARS).optional(),
7784
+ device: NoticeDevice.optional(),
7716
7785
  at: Millis
7717
7786
  });
7718
7787
  NoticeCounts = object({ unread: number2().int().nonnegative(), requests: number2().int().nonnegative() });
@@ -8061,6 +8130,8 @@ var init_ipc = __esm(() => {
8061
8130
  "notice.ack",
8062
8131
  "device.approve.preview",
8063
8132
  "device.remove.preview",
8133
+ "identity.replace.preview",
8134
+ "directory.erase.preview",
8064
8135
  "channel.create",
8065
8136
  "channel.invite",
8066
8137
  "channel.leave",
@@ -8081,6 +8152,8 @@ var init_ipc = __esm(() => {
8081
8152
  "device.login.cancel",
8082
8153
  "device.approve",
8083
8154
  "identity.rotateEnc",
8155
+ "identity.replace",
8156
+ "directory.erase",
8084
8157
  "team.create",
8085
8158
  "team.invite",
8086
8159
  "team.join",
@@ -8161,7 +8234,7 @@ var init_b64u = __esm(() => {
8161
8234
  });
8162
8235
 
8163
8236
  // packages/core/dist/version.js
8164
- var VERSION = "0.5.0";
8237
+ var VERSION = "0.6.0";
8165
8238
 
8166
8239
  // packages/core/dist/jcs.js
8167
8240
  function hasLoneSurrogate(text) {
@@ -9970,6 +10043,13 @@ class DeviceLog {
9970
10043
  token(seq) {
9971
10044
  return this.#state.tokens.get(seq);
9972
10045
  }
10046
+ spentSignIn(jti) {
10047
+ for (const [seq, token] of this.#state.tokens) {
10048
+ if (token.claims.src === JELLY_DEVICE_SOURCE && token.claims.jti === jti && this.#state.entries[seq].op !== "unbind")
10049
+ return true;
10050
+ }
10051
+ return false;
10052
+ }
9973
10053
  bindingDeadline(seq) {
9974
10054
  const token = this.#state.tokens.get(seq);
9975
10055
  return token === undefined ? undefined : token.claims.exp * 1000 + LIMITS.clockSkewMs;
@@ -10022,6 +10102,14 @@ class DeviceLog {
10022
10102
  const { claims } = token;
10023
10103
  if (personId(claims.iss, claims.sub) !== this.person)
10024
10104
  throw refuse("Jelly token names another person");
10105
+ if (claims.src === JELLY_DEVICE_SOURCE) {
10106
+ for (const [seq, used] of state.tokens) {
10107
+ if (used.claims.src !== JELLY_DEVICE_SOURCE || used.claims.jti !== claims.jti)
10108
+ continue;
10109
+ if (parsed.op !== "unbind" || state.entries[seq].op !== "unbind")
10110
+ throw refuse("a sign-in's token vouches for one act: this one was used");
10111
+ }
10112
+ }
10025
10113
  if (parsed.ts < claims.iat * 1000 - LIMITS.clockSkewMs || parsed.ts > claims.exp * 1000 + LIMITS.clockSkewMs)
10026
10114
  throw refuse("log entry is dated outside its Jelly token's life");
10027
10115
  tokens.set(parsed.seq, token);
@@ -10106,6 +10194,7 @@ var init_device_log = __esm(() => {
10106
10194
  init_keys();
10107
10195
  init_sign();
10108
10196
  init_common();
10197
+ init_jelly();
10109
10198
  init_directory();
10110
10199
  init_errors3();
10111
10200
  init_jelly_token();
@@ -15952,14 +16041,16 @@ class RosterIdentity {
15952
16041
  #machine;
15953
16042
  #catchUp;
15954
16043
  #now;
15955
- #listeners = { "devices-changed": [], "self-changed": [], alarm: [] };
16044
+ #listeners = { "devices-changed": [], "self-changed": [], "device-log": [], alarm: [] };
15956
16045
  #me;
16046
+ #length;
15957
16047
  constructor(options) {
15958
16048
  this.roster = options.roster;
15959
16049
  this.#machine = options.machine;
15960
16050
  this.#catchUp = options.catchUp;
15961
16051
  this.#now = options.now;
15962
16052
  this.#me = this.#selfRecord();
16053
+ this.#length = options.roster.entries().length;
15963
16054
  }
15964
16055
  get realm() {
15965
16056
  return this.roster.realm;
@@ -15990,6 +16081,31 @@ class RosterIdentity {
15990
16081
  successor(_device) {
15991
16082
  return;
15992
16083
  }
16084
+ deviceChanges(person) {
16085
+ const labels = new Map;
16086
+ const out = [];
16087
+ for (const entry of this.roster.entries()) {
16088
+ const member = entry.kind === "member.add" ? entry.body : entry.kind === "invite.redeem" ? entry.body.member : entry.kind === "genesis" ? entry.body.founder : undefined;
16089
+ if (member) {
16090
+ labels.set(member.machine, member.label);
16091
+ if (member.person === person && entry.kind !== "genesis")
16092
+ out.push({ seq: entry.seq, change: "added", device: member.machine, label: member.label, vouch: "admin", by: entry.by, at: entry.ts });
16093
+ } else if (entry.kind === "member.remove") {
16094
+ const owner = this.#ownerAt(entry.body.machine);
16095
+ if (owner === person)
16096
+ out.push({ seq: entry.seq, change: "removed", device: entry.body.machine, label: labels.get(entry.body.machine) ?? "machine", vouch: "admin", by: entry.by, at: entry.ts });
16097
+ }
16098
+ }
16099
+ return out;
16100
+ }
16101
+ #ownerAt(machine) {
16102
+ for (const entry of this.roster.entries()) {
16103
+ const member = entry.kind === "member.add" ? entry.body : entry.kind === "invite.redeem" ? entry.body.member : entry.kind === "genesis" ? entry.body.founder : undefined;
16104
+ if (member?.machine === machine)
16105
+ return member.person;
16106
+ }
16107
+ return;
16108
+ }
15993
16109
  async devices(person, opts = {}) {
15994
16110
  if (opts.atLeast !== undefined && this.relate(opts.atLeast).relation === "ahead") {
15995
16111
  throw new MessagingError("roster_conflict", "the roster has not reached that anchor");
@@ -16033,6 +16149,20 @@ class RosterIdentity {
16033
16149
  }
16034
16150
  for (const listener of [...this.#listeners["devices-changed"]])
16035
16151
  listener();
16152
+ const entries = this.roster.entries();
16153
+ if (entries.length !== this.#length) {
16154
+ const persons = new Set;
16155
+ for (const entry of entries.slice(this.#length)) {
16156
+ const member = entry.kind === "member.add" ? entry.body : entry.kind === "invite.redeem" ? entry.body.member : undefined;
16157
+ const person = member?.person ?? (entry.kind === "member.remove" ? this.#ownerAt(entry.body.machine) : undefined);
16158
+ if (person !== undefined)
16159
+ persons.add(person);
16160
+ }
16161
+ this.#length = entries.length;
16162
+ if (persons.size > 0)
16163
+ for (const listener of [...this.#listeners["device-log"]])
16164
+ listener([...persons].sort());
16165
+ }
16036
16166
  }
16037
16167
  #selfRecord() {
16038
16168
  const me = this.roster.member(this.#machine);
@@ -16248,16 +16378,18 @@ class DirectoryIdentity {
16248
16378
  #options;
16249
16379
  #cache;
16250
16380
  #keyring;
16251
- #listeners = { "devices-changed": [], "self-changed": [], alarm: [] };
16381
+ #listeners = { "devices-changed": [], "self-changed": [], "device-log": [], alarm: [] };
16252
16382
  #head;
16253
16383
  #persons = new Map;
16254
16384
  #index = new Map;
16255
16385
  #own;
16256
16386
  #relations = new Map;
16257
16387
  #watching = new Set;
16388
+ #watchRead = Promise.resolve();
16258
16389
  #alarm;
16259
16390
  #selfRecord;
16260
16391
  #monitoredAt = Number.NEGATIVE_INFINITY;
16392
+ #bindAccepted = false;
16261
16393
  #pendingLink;
16262
16394
  constructor(options) {
16263
16395
  this.#options = options;
@@ -16317,7 +16449,10 @@ class DirectoryIdentity {
16317
16449
  const person = this.#own.person;
16318
16450
  if (!person)
16319
16451
  return false;
16320
- const record = this.#persons.get(person)?.state.log.device(this.#machine());
16452
+ const held = this.#persons.get(person)?.state;
16453
+ if (held?.erased)
16454
+ return true;
16455
+ const record = held?.log.device(this.#machine());
16321
16456
  return record !== undefined && !record.current;
16322
16457
  }
16323
16458
  persons() {
@@ -16338,6 +16473,12 @@ class DirectoryIdentity {
16338
16473
  }
16339
16474
  return;
16340
16475
  }
16476
+ deviceChanges(person) {
16477
+ const held = isHosted(person) ? this.#persons.get(person) : undefined;
16478
+ if (!held || held.state.erased)
16479
+ return [];
16480
+ return deviceChangesOf(held.state.log);
16481
+ }
16341
16482
  async devices(person, opts = {}) {
16342
16483
  if (!isHosted(person))
16343
16484
  return { person, devices: [], anchor: this.anchor(), fetchedAt: this.#options.now() };
@@ -16432,15 +16573,23 @@ class DirectoryIdentity {
16432
16573
  }
16433
16574
  }
16434
16575
  watch(persons) {
16435
- let grew = false;
16576
+ const added = [];
16436
16577
  for (const person of persons) {
16437
16578
  if (!isHosted(person) || this.#watching.has(person) || this.#watching.size >= LIMITS.watchPersons)
16438
16579
  continue;
16439
16580
  this.#watching.add(person);
16440
- grew = true;
16581
+ added.push(person);
16441
16582
  }
16442
- if (grew)
16443
- this.#call((link) => link.watch([...this.#watching])).catch(() => {});
16583
+ if (added.length === 0)
16584
+ return this.#watchRead;
16585
+ this.#watchRead = this.#call((link) => link.watch([...this.#watching])).then(async () => {
16586
+ const held = added.filter((person) => this.#persons.has(person));
16587
+ if (held.length === 0)
16588
+ return;
16589
+ await this.#latest();
16590
+ await this.#fetch(held);
16591
+ }).catch(() => {});
16592
+ return this.#watchRead;
16444
16593
  }
16445
16594
  on(event, listener) {
16446
16595
  const listeners = this.#listeners[event];
@@ -16451,6 +16600,10 @@ class DirectoryIdentity {
16451
16600
  listeners.splice(at, 1);
16452
16601
  };
16453
16602
  }
16603
+ linkPerson() {
16604
+ const binding = this.binding();
16605
+ return binding.state !== "none" || this.#bindAccepted ? binding.person : undefined;
16606
+ }
16454
16607
  alarm() {
16455
16608
  return this.#alarm;
16456
16609
  }
@@ -16460,6 +16613,8 @@ class DirectoryIdentity {
16460
16613
  binding() {
16461
16614
  const person = this.#own.person;
16462
16615
  const record = person ? this.#persons.get(person)?.state.log.device(this.#machine()) : undefined;
16616
+ if (person && this.#persons.get(person)?.state.erased)
16617
+ return { state: "removed", person };
16463
16618
  if (!person || !record)
16464
16619
  return { state: "none", ...person ? { person } : {} };
16465
16620
  if (!record.current)
@@ -16500,13 +16655,29 @@ class DirectoryIdentity {
16500
16655
  authority: { kind: "jelly", token }
16501
16656
  });
16502
16657
  this.#signed(entry, person);
16503
- return current ? await this.#call((link) => link.append(entry)) : await this.#call((link) => link.bind(entry));
16658
+ if (current)
16659
+ return await this.#call((link) => link.append(entry));
16660
+ const bound = await this.#call((link) => link.bind(entry));
16661
+ this.#bindAccepted = true;
16662
+ return bound;
16504
16663
  });
16505
16664
  await this.#adoptAnnounced(done.sth);
16506
16665
  await this.#fetch([person]);
16507
16666
  await this.publishKeys();
16508
16667
  return { person, seq: done.seq };
16509
16668
  }
16669
+ async awaitSelf() {
16670
+ const person = this.#own.person;
16671
+ if (!person)
16672
+ throw new MessagingError("no_identity", "this device is not bound to an account");
16673
+ for (let tries = 0;this.self() === undefined || !this.#index.has(this.#machine()); tries++) {
16674
+ if (tries >= LOG_RETRIES)
16675
+ throw unavailable("the directory has not committed this device's binding and keys yet");
16676
+ await new Promise((resolve) => setTimeout(resolve, LOG_RETRY_MS));
16677
+ await this.#latest();
16678
+ await this.#fetch([person]);
16679
+ }
16680
+ }
16510
16681
  async publishKeys() {
16511
16682
  if (this.#alarm?.kind === "restored")
16512
16683
  throw new MessagingError("invalid_state", "this device runs from an older copy of its home; it publishes no keys");
@@ -16557,6 +16728,45 @@ class DirectoryIdentity {
16557
16728
  await this.#fetch([person]);
16558
16729
  return { seq: done.seq };
16559
16730
  }
16731
+ async erase(token) {
16732
+ const identity = this.#options.identity();
16733
+ const person = this.#own.person;
16734
+ if (!person || this.binding().state === "none" || this.removed())
16735
+ throw new MessagingError("no_identity", "this device is not bound to an account");
16736
+ if (this.binding().record?.vouch !== "jelly")
16737
+ throw new MessagingError("forbidden", "a device approved by link erases nothing; erase from a device Jelly ID vouched for");
16738
+ const entries = await this.#extendLog(async () => {
16739
+ await this.#latest();
16740
+ await this.#fetch([person]);
16741
+ const log = this.#persons.get(person).state.log;
16742
+ let head = log.head();
16743
+ const out = [];
16744
+ const devices = log.devices().sort((a, b) => Number(a.device === identity.machine) - Number(b.device === identity.machine));
16745
+ for (const device of devices) {
16746
+ const self = device.device === identity.machine;
16747
+ const entry = signLogEntry(identity, {
16748
+ v: PROTOCOL,
16749
+ realm: this.realm,
16750
+ person,
16751
+ seq: head.seq + 1,
16752
+ prev: head.hash,
16753
+ ts: this.#options.now(),
16754
+ op: "unbind",
16755
+ device: device.device,
16756
+ reason: self ? "self" : "removed",
16757
+ authority: self ? { kind: "self" } : { kind: "jelly", token }
16758
+ });
16759
+ out.push(entry);
16760
+ head = { seq: entry.seq, hash: logEntryHash(entry) };
16761
+ }
16762
+ for (const entry of out)
16763
+ this.#signed(entry, person);
16764
+ await this.#call((link) => link.erase(out, token));
16765
+ return out;
16766
+ });
16767
+ await this.#latest().then(() => this.#fetch([person])).catch(() => {});
16768
+ return { person, devices: entries.length, seq: entries.at(-1).seq };
16769
+ }
16560
16770
  createLink(args) {
16561
16771
  if (this.#own.person !== undefined) {
16562
16772
  throw new MessagingError("invalid_state", this.removed() ? "this device was removed from its account; it cannot be bound again" : "this device is already bound");
@@ -16775,15 +16985,25 @@ class DirectoryIdentity {
16775
16985
  throw new MessagingError("directory_inconsistent", "the directory answered at another tree head");
16776
16986
  const now = this.#options.now();
16777
16987
  const views = new Map(answer.persons.map((view) => [view.person, view]));
16988
+ const grew = [];
16778
16989
  for (const person of persons) {
16779
16990
  const view = views.get(person);
16780
16991
  if (!view)
16781
16992
  continue;
16993
+ const before = this.#persons.get(person)?.state;
16782
16994
  const state = await this.#verify(view, head);
16783
16995
  this.#persons.set(person, { state, size: head.sth.size, fetchedAt: now });
16784
16996
  this.#cache.putPerson({ person, entries: [...state.log.entries()], keys: [...state.keys.values()], erased: state.erased, size: head.sth.size, fetchedAt: now });
16997
+ if (state.log.length !== (before?.log.length ?? 0) || state.erased !== (before?.erased ?? false))
16998
+ grew.push(person);
16785
16999
  }
16786
17000
  this.#changed();
17001
+ if (grew.length > 0)
17002
+ for (const listener of [...this.#listeners["device-log"]])
17003
+ listener(grew);
17004
+ const unwatched = persons.filter((person) => views.has(person) && !this.#watching.has(person));
17005
+ if (unwatched.length > 0)
17006
+ this.watch(unwatched);
16787
17007
  }
16788
17008
  async#verify(view, head) {
16789
17009
  const known = this.#persons.get(view.person)?.state;
@@ -16905,6 +17125,56 @@ ${this.#index.get(me.machine)?.enc ?? ""}`;
16905
17125
  return mine === theirs ? "same" : "other";
16906
17126
  }
16907
17127
  }
17128
+ function deviceChangesOf(log) {
17129
+ const vouch = new Map;
17130
+ const record = new Map;
17131
+ const out = [];
17132
+ for (const entry of log.entries()) {
17133
+ const token = log.token(entry.seq);
17134
+ const via = token === undefined ? undefined : token.claims.hst !== undefined ? "host" : "browser";
17135
+ if (entry.op === "bind") {
17136
+ const v = entry.authority.kind === "jelly" ? "jelly" : "device";
17137
+ vouch.set(entry.device, v);
17138
+ record.set(entry.device, { label: entry.label, kind: entry.kind, host: via === "host" });
17139
+ out.push({
17140
+ seq: entry.seq,
17141
+ change: "added",
17142
+ device: entry.device,
17143
+ label: entry.label,
17144
+ kind: entry.kind,
17145
+ vouch: v,
17146
+ ...via ? { via } : {},
17147
+ ...entry.authority.kind === "device" ? { approver: entry.authority.approver } : {},
17148
+ by: entry.by,
17149
+ at: entry.ts
17150
+ });
17151
+ } else if (entry.op === "attest") {
17152
+ const known = record.get(entry.device);
17153
+ const next = { label: entry.label ?? known?.label ?? "device", kind: entry.kind ?? known?.kind ?? "other", host: via === "host" };
17154
+ record.set(entry.device, next);
17155
+ const raised = vouch.get(entry.device) !== "jelly";
17156
+ vouch.set(entry.device, "jelly");
17157
+ if (raised)
17158
+ out.push({ seq: entry.seq, change: "vouched", device: entry.device, label: next.label, kind: next.kind, vouch: "jelly", ...via ? { via } : {}, by: entry.by, at: entry.ts });
17159
+ } else {
17160
+ const known = record.get(entry.device);
17161
+ const v = vouch.get(entry.device) ?? "device";
17162
+ out.push({
17163
+ seq: entry.seq,
17164
+ change: entry.reason === "replaced" ? "replaced" : "removed",
17165
+ device: entry.device,
17166
+ label: known?.label ?? "device",
17167
+ ...known ? { kind: known.kind } : {},
17168
+ vouch: v,
17169
+ ...v === "jelly" && known ? { via: known.host ? "host" : "browser" } : {},
17170
+ by: entry.by,
17171
+ reason: entry.reason,
17172
+ at: entry.ts
17173
+ });
17174
+ }
17175
+ }
17176
+ return out;
17177
+ }
16908
17178
  function orgOf(email, domains) {
16909
17179
  if (email === undefined)
16910
17180
  return;
@@ -16989,7 +17259,7 @@ class DirectoryHome {
16989
17259
  url: this.#options.config.url,
16990
17260
  realm: realm.realm,
16991
17261
  identity: this.#options.identity,
16992
- person: () => realm.binding().person,
17262
+ person: () => realm.linkPerson(),
16993
17263
  pushes: {
16994
17264
  changed: (persons, sth) => void realm.changed(persons, sth).catch((error) => this.#failed("directory.changed", error)),
16995
17265
  unbound: (entry, sth) => void realm.changed([entry.person], sth).catch((error) => this.#failed("directory.unbound", error)),
@@ -17141,7 +17411,27 @@ class DirectoryHome {
17141
17411
  this.#login.cancel();
17142
17412
  return { ok: true };
17143
17413
  }
17414
+ async signIn(purpose, args) {
17415
+ this.#removing = undefined;
17416
+ this.#binding = undefined;
17417
+ const started = await this.#login.start({ purpose, label: args.label, kind: args.kind });
17418
+ return {
17419
+ purpose,
17420
+ verificationUriComplete: started.verificationUriComplete,
17421
+ verificationUri: started.verificationUri,
17422
+ userCode: started.userCode,
17423
+ fingerprint: started.fingerprint,
17424
+ expires: started.expires,
17425
+ interval: started.interval
17426
+ };
17427
+ }
17144
17428
  async#completeLogin(token, purpose) {
17429
+ if (purpose === "replace" || purpose === "erase") {
17430
+ const step = purpose === "replace" ? this.#options.replaceStep : this.#options.eraseStep;
17431
+ if (!step)
17432
+ throw new MessagingError("invalid_state", `this daemon does not ${purpose} through a sign-in`);
17433
+ return step(token);
17434
+ }
17145
17435
  if (purpose === "remove") {
17146
17436
  const removing = this.#removing;
17147
17437
  if (!removing)
@@ -17207,6 +17497,28 @@ var init_directory_home = __esm(() => {
17207
17497
  init_directory_identity();
17208
17498
  });
17209
17499
 
17500
+ // packages/daemon/dist/realm/directory-cache.js
17501
+ function memoryDirectoryCache(from) {
17502
+ let head = from?.head();
17503
+ const persons = new Map((from?.persons() ?? []).map((p) => [p.person, p]));
17504
+ let archive = from?.archive() ?? [];
17505
+ let own = from?.own() ?? { keysSeq: -1, signed: [] };
17506
+ let alarm = from?.alarm();
17507
+ const copy = (value) => structuredClone(value);
17508
+ return {
17509
+ head: () => copy(head),
17510
+ setHead: (value) => void (head = copy(value)),
17511
+ persons: () => [...persons.values()].map(copy),
17512
+ putPerson: (value) => void persons.set(value.person, copy(value)),
17513
+ archive: () => copy(archive),
17514
+ setArchive: (keys) => void (archive = copy(keys)),
17515
+ own: () => copy(own),
17516
+ setOwn: (value) => void (own = copy(value)),
17517
+ alarm: () => copy(alarm),
17518
+ setAlarm: (value) => void (alarm = copy(value))
17519
+ };
17520
+ }
17521
+
17210
17522
  // packages/daemon/dist/jelly.js
17211
17523
  import { closeSync as closeSync6, constants as constants6, fstatSync as fstatSync3, readSync } from "fs";
17212
17524
  function jellyLabel(text) {
@@ -17646,6 +17958,12 @@ class Notices {
17646
17958
  alert(kind, at) {
17647
17959
  this.#file({ id: `a_${kind.replace(/_/g, "")}${at}`, category: "alert", source: kind, at });
17648
17960
  }
17961
+ device(seq, at) {
17962
+ this.#file({ id: `a_dev${seq}`, category: "alert", source: `device:${seq}`, at });
17963
+ }
17964
+ trust(grantId, person, until, at) {
17965
+ this.#file({ id: `a_trust${grantId}`, category: "alert", person, source: `trust:${until}`, at });
17966
+ }
17649
17967
  #file(row) {
17650
17968
  if (!this.#host.store.putNotice(row))
17651
17969
  return;
@@ -17711,7 +18029,15 @@ class Notices {
17711
18029
  const own = row.person === this.#host.me();
17712
18030
  notice.from = profile ? { name: profile.name.slice(0, 200), ...profile.email !== undefined ? { email: profile.email.slice(0, 320) } : {}, org: profile.org } : { name: row.person, org: own ? "same" : "none" };
17713
18031
  }
17714
- if (row.category === "alert")
18032
+ if (row.category === "alert" && row.source.startsWith("device:")) {
18033
+ const device = this.#host.device?.(Number(row.source.slice("device:".length)));
18034
+ if (device)
18035
+ notice.device = device;
18036
+ notice.line = device ? deviceLine(device) : "A device of your account changed.";
18037
+ } else if (row.category === "alert" && row.source.startsWith("trust:")) {
18038
+ const until = Number(row.source.slice("trust:".length));
18039
+ notice.line = `Trust you gave this person ends on ${Number.isSafeInteger(until) ? new Date(until).toISOString().slice(0, 10) : "a date soon"}; renew it or let it lapse.`;
18040
+ } else if (row.category === "alert")
17715
18041
  notice.line = ALERT_TEXT[row.source] ?? "An alarm was raised on this device.";
17716
18042
  else if (row.category === "message" && lines) {
17717
18043
  const line = this.#host.store.line(row.source);
@@ -17721,6 +18047,18 @@ class Notices {
17721
18047
  return notice;
17722
18048
  }
17723
18049
  }
18050
+ function vouchWords2(d) {
18051
+ if (d.vouch === "admin")
18052
+ return "admitted by the team's admin";
18053
+ if (d.vouch === "device")
18054
+ return d.approver !== undefined ? `approved on '${d.approver}'` : "approved by link";
18055
+ return d.via === "browser" ? "signed in through the browser" : "vouched by Jelly ID on a Jelly host";
18056
+ }
18057
+ function deviceLine(d) {
18058
+ const what = `'${d.label}'${d.kind !== undefined ? ` (${d.kind})` : ""}`;
18059
+ const line = d.change === "added" ? `New device on your account: ${what}, ${vouchWords2(d)}.` : d.change === "vouched" ? `${what} on your account is now vouched by Jelly ID: ${vouchWords2(d)}.` : d.change === "replaced" ? `'${d.replaces?.label ?? "a device"}' was replaced by ${what}, ${vouchWords2(d)}.` : `${what} was removed from your account.`;
18060
+ return line.length <= NOTICE_LINE_CHARS ? line : `${line.slice(0, NOTICE_LINE_CHARS - 1)}\u2026`;
18061
+ }
17724
18062
  function firstLine(text) {
17725
18063
  const first = cleanPeerText(text.split(/\r\n|\n|\r/, 1)[0] ?? "", { singleLine: true });
17726
18064
  if (first.length <= NOTICE_LINE_CHARS)
@@ -19945,6 +20283,16 @@ class Grants {
19945
20283
  hasGrant(person) {
19946
20284
  return this.#current(grantSubjectKey({ person })) !== undefined;
19947
20285
  }
20286
+ expiringTrust(withinMs) {
20287
+ const now = this.#host.now();
20288
+ const current = this.#currentDevice();
20289
+ return this.#all().flatMap(({ grant }) => {
20290
+ const until = grant.trustedUntil;
20291
+ if (!("person" in grant.subject) || until === undefined || until <= now || until > now + withinMs || !current(grant.by))
20292
+ return [];
20293
+ return [{ id: grant.id, person: grant.subject.person, until }];
20294
+ });
20295
+ }
19948
20296
  list(p) {
19949
20297
  const only = p.subject === undefined ? undefined : this.#subjectKey(p.subject, false);
19950
20298
  const grants = this.#all().filter(({ subject }) => only === undefined || subject === only).map(({ grant }) => this.#view(grant)).sort((a, b) => b.ts - a.ts || (a.id < b.id ? 1 : -1));
@@ -20019,6 +20367,25 @@ class Grants {
20019
20367
  this.#host.log("grant.sync", { id: env.id, what: conv.what, merged });
20020
20368
  this.sweep();
20021
20369
  }
20370
+ resignFrom(predecessor) {
20371
+ const { identity } = this.#host.joined();
20372
+ const now = this.#host.now();
20373
+ const resigned = [];
20374
+ for (const { subject, grant } of this.#all()) {
20375
+ if (grant.by !== predecessor)
20376
+ continue;
20377
+ const copy = resignGrant(identity, grant, { now, keep: "all" });
20378
+ this.#store.putGrant(subject, copy);
20379
+ resigned.push(copy);
20380
+ if ("person" in copy.subject)
20381
+ this.#host.changed?.(copy.subject.person);
20382
+ }
20383
+ if (resigned.length > 0) {
20384
+ this.#sync("grant", resigned);
20385
+ this.#host.log("grant.resigned", { grants: resigned.length });
20386
+ }
20387
+ return resigned.length;
20388
+ }
20022
20389
  sweep() {
20023
20390
  const { identity, realm } = this.#host.joined();
20024
20391
  if (realm.removed())
@@ -20814,6 +21181,7 @@ class Daemon {
20814
21181
  const realm = this.#realm ?? this.#directory?.realm;
20815
21182
  return realm ? await realm.profile(person) : undefined;
20816
21183
  },
21184
+ device: (seq) => this.#noticeDevice(seq),
20817
21185
  emit: (event) => this.#emit(event)
20818
21186
  });
20819
21187
  this.#keyring = new Keyring({
@@ -20919,6 +21287,7 @@ class Daemon {
20919
21287
  }
20920
21288
  this.#log("info", "daemon.started", { pid: process.pid, version: VERSION, joined: identity !== undefined, store: identityStore?.kind, ...jelly ? { deployment: "jelly" } : {} });
20921
21289
  this.#state = "running";
21290
+ this.#deviceNotices(true);
20922
21291
  this.#startTransport();
20923
21292
  this.#sweepTimer = setInterval(() => this.sweep(), this.#tuning.sweepMs);
20924
21293
  this.#sweepTimer.unref();
@@ -20958,7 +21327,12 @@ class Daemon {
20958
21327
  this.#alerts();
20959
21328
  },
20960
21329
  onDeliver: (raw) => this.#onDeliver(raw),
20961
- state: (connected) => this.#onHostedState(connected)
21330
+ state: (connected) => this.#onHostedState(connected),
21331
+ replaceStep: (token) => this.#replaceWith(token),
21332
+ eraseStep: async (token) => {
21333
+ const done = await this.#eraseWith(token);
21334
+ return { person: done.person, device: this.#identity.machine, seq: done.seq };
21335
+ }
20962
21336
  });
20963
21337
  this.#directory = home;
20964
21338
  const realm = home.realm;
@@ -20976,6 +21350,7 @@ class Daemon {
20976
21350
  else
20977
21351
  this.#presenceSoon();
20978
21352
  });
21353
+ realm.on("device-log", (persons) => this.#deviceLogGrew(persons));
20979
21354
  this.#removed = realm.removed();
20980
21355
  this.#transport = home.transport();
20981
21356
  home.start();
@@ -21076,7 +21451,11 @@ class Daemon {
21076
21451
  return {
21077
21452
  "daemon.status": () => this.status(),
21078
21453
  "device.status": () => this.#deviceStatus(),
21079
- "device.bind": (p) => this.#homeDirectory("device.bind").bind(this.#jellyHost ? { ...p, label: this.#jellyHost.label, kind: this.#jellyHost.kind } : p),
21454
+ "device.bind": async (p) => {
21455
+ if (this.#store.getKv(PREDECESSOR_KEY) !== undefined && this.#directory)
21456
+ return this.#replaceWith(p.token, p.label);
21457
+ return this.#homeDirectory("device.bind").bind(this.#jellyHost ? { ...p, label: this.#jellyHost.label, kind: this.#jellyHost.kind } : p);
21458
+ },
21080
21459
  "device.list": () => this.#directory?.list() ?? this.#rosterDevices(),
21081
21460
  "device.remove": (p) => this.#homeDirectory("device.remove").remove(p),
21082
21461
  "device.link": (p) => this.#homeDirectory("device.link").link(p),
@@ -21089,6 +21468,10 @@ class Daemon {
21089
21468
  "device.login.cancel": () => this.#homeDirectory("device.login.cancel").loginCancel(),
21090
21469
  "device.approve.preview": (p) => this.#homeDirectory("device.approve.preview").approvePreview(p),
21091
21470
  "device.approve": (p) => this.#homeDirectory("device.approve").approve(p),
21471
+ "identity.replace": (p) => this.#replace(p),
21472
+ "identity.replace.preview": (p) => this.#replacePreview(p),
21473
+ "directory.erase": (p) => this.#erase(p),
21474
+ "directory.erase.preview": (p) => this.#erasePreview(p),
21092
21475
  "daemon.stop": () => this.#stop(),
21093
21476
  "session.register": (p, ctx) => this.#register(p, ctx),
21094
21477
  "session.update": (p, ctx) => this.#updateSession(this.#bound(p, ctx)),
@@ -21189,10 +21572,30 @@ class Daemon {
21189
21572
  const out = [];
21190
21573
  for (const person of all) {
21191
21574
  const org = realm.kind === "roster" ? "same" : await realm.profile(person).then((profile) => profile.org, () => "none");
21192
- out.push(this.#cards.reach(person, org === "same"));
21575
+ const reach = this.#cards.reach(person, org === "same");
21576
+ const changedAt = this.#recentDeviceChanges(realm, person).at(0)?.at;
21577
+ out.push(changedAt === undefined ? reach : { ...reach, devicesChangedAt: changedAt });
21193
21578
  }
21194
21579
  return out;
21195
21580
  }
21581
+ #recentDeviceChanges(realm, person) {
21582
+ const since = this.#now() - DEVICES_CHANGED_MS;
21583
+ const changes = realm.deviceChanges(person);
21584
+ const labelOf = (device) => changes.filter((c) => c.device === device).at(-1)?.label;
21585
+ return changes.filter((change) => change.change !== "vouched" && change.at > since).reverse().slice(0, 10).map((change) => {
21586
+ const approver = change.approver !== undefined ? labelOf(change.approver) : undefined;
21587
+ return {
21588
+ change: change.change,
21589
+ device: change.device,
21590
+ label: MachineLabel.safeParse(change.label).success ? change.label : "device",
21591
+ ...change.kind !== undefined ? { kind: change.kind } : {},
21592
+ vouch: change.vouch === "service" ? "device" : change.vouch,
21593
+ ...change.via !== undefined ? { via: change.via } : {},
21594
+ ...approver !== undefined && MachineLabel.safeParse(approver).success ? { approver } : {},
21595
+ at: change.at
21596
+ };
21597
+ });
21598
+ }
21196
21599
  async#grantSubject(p) {
21197
21600
  const { realm } = this.#joined();
21198
21601
  if ("person" in p.subject)
@@ -21293,6 +21696,7 @@ class Daemon {
21293
21696
  now: this.#now
21294
21697
  });
21295
21698
  realm.on("devices-changed", () => this.#realmChanged());
21699
+ realm.on("device-log", (persons) => this.#deviceLogGrew(persons));
21296
21700
  return realm;
21297
21701
  }
21298
21702
  #admin() {
@@ -21790,7 +22194,9 @@ class Daemon {
21790
22194
  self: device.machine === identity.machine,
21791
22195
  ...away(device.machine) ? { away: true } : {}
21792
22196
  }));
21793
- return { person: p.person, ...profile, devices };
22197
+ const own = p.person === this.#ownPerson();
22198
+ const deviceChanges = own ? [] : this.#recentDeviceChanges(realm, p.person);
22199
+ return { person: p.person, ...profile, devices, ...deviceChanges.length > 0 ? { deviceChanges } : {} };
21794
22200
  }
21795
22201
  #seal(type, toMachine, session, payload, extra = {}) {
21796
22202
  const { identity, realm, me } = this.#joined();
@@ -22927,8 +23333,14 @@ ${row.receipt ? "receipt" : "envelope"}`;
22927
23333
  await transport?.stop().catch(() => {});
22928
23334
  }
22929
23335
  #deviceStatus() {
22930
- if (this.#directory)
22931
- return this.#directory.status();
23336
+ if (this.#directory) {
23337
+ const status = this.#directory.status();
23338
+ return this.#store.getKv(PREDECESSOR_KEY) !== undefined && status.binding !== "removed" ? { ...status, binding: "replace" } : status;
23339
+ }
23340
+ if (this.#config.realm) {
23341
+ const person = this.#store.directoryCache().own().person;
23342
+ return { realm: "directory", binding: "none", ...person ? { person } : {} };
23343
+ }
22932
23344
  const identity = this.#identity;
22933
23345
  const me = identity ? this.#realm?.device(identity.machine) : undefined;
22934
23346
  const binding = this.#removed ? "removed" : me ? "bound" : "none";
@@ -23065,6 +23477,232 @@ ${row.receipt ? "receipt" : "envelope"}`;
23065
23477
  const { rows, more } = this.#store.conversation(session.key, p.conversation.slice("agent:".length), { before: p.before }, p.limit ?? 30);
23066
23478
  return { kind: "entries", conversation: p.conversation, entries: rows.map(convEntry), more };
23067
23479
  }
23480
+ #heldRealm(config) {
23481
+ if (this.#directory)
23482
+ return this.#directory.realm;
23483
+ const throwaway = generateIdentity();
23484
+ return new DirectoryIdentity({ config, identity: () => throwaway, link: () => {
23485
+ return;
23486
+ }, cache: memoryDirectoryCache(this.#store.directoryCache()), jelly: this.#jelly ?? jellyFromIssuer(config.issuer), now: this.#now });
23487
+ }
23488
+ #predecessor() {
23489
+ return this.#store.getKv(PREDECESSOR_KEY) ?? this.#store.getKv(MACHINE_KEY) ?? this.#identity?.machine;
23490
+ }
23491
+ async#replacePreview(p) {
23492
+ const config = this.#config.realm;
23493
+ if (!config)
23494
+ throw new MessagingError("invalid_state", "identity.replace is for a directory realm; a roster realm replaces a machine with team.remove and a new invite");
23495
+ const person = this.#store.directoryCache().own().person;
23496
+ if (!person)
23497
+ throw new MessagingError("no_identity", "this home was never bound to an account: bind it instead (device.bind or messaging login)");
23498
+ const realm = this.#heldRealm(config);
23499
+ const pred = this.#predecessor();
23500
+ const record = realm.ownDevices().find((d) => d.device === pred);
23501
+ if (!record)
23502
+ throw new MessagingError("not_found", "this home's device is not in its account's log");
23503
+ const pending = this.#store.getKv(PREDECESSOR_KEY) !== undefined && this.#identity !== undefined && this.#identity.machine !== pred;
23504
+ const bound = pending && realm.binding().state !== "none";
23505
+ const signIns = !pending ? 2 : !bound ? 2 : record.current ? 1 : 0;
23506
+ const alarm = this.#keysMissing ? "keys_missing" : realm.alarm()?.kind === "restored" ? "restored" : null;
23507
+ const label = record.label;
23508
+ const why = alarm === "keys_missing" ? " (its key files are missing)" : alarm === "restored" ? " (it runs from an older copy of its home)" : "";
23509
+ const sentence = `This device gets a new key and replaces '${label}'${why} on your account. Your conversations, grants and history stay here, and tasks continue; ` + `mail still queued at the relay for '${label}' is lost.` + (signIns === 2 ? " Through the browser you approve two sign-ins at Jelly ID: one binds the new key, one removes the old." : signIns === 1 ? ` You approve one sign-in at Jelly ID, to remove '${label}'.` : "");
23510
+ return {
23511
+ predecessor: {
23512
+ device: pred,
23513
+ label,
23514
+ kind: record.kind,
23515
+ vouch: record.vouch,
23516
+ ...record.vouch === "jelly" ? { via: record.host !== undefined ? "host" : "browser" } : {},
23517
+ since: record.since,
23518
+ current: record.current
23519
+ },
23520
+ alarm,
23521
+ ...pending ? { device: this.#identity.machine } : {},
23522
+ signIns,
23523
+ sentence: sentence.slice(0, 600)
23524
+ };
23525
+ }
23526
+ async#replace(p) {
23527
+ const config = this.#config.realm;
23528
+ if (!config)
23529
+ throw new MessagingError("invalid_state", "identity.replace is for a directory realm; a roster realm replaces a machine with team.remove and a new invite");
23530
+ if (this.#removed || this.#directory?.realm.removed())
23531
+ throw new MessagingError("invalid_state", "this device was removed from its account; it cannot replace anything");
23532
+ const person = this.#store.directoryCache().own().person;
23533
+ if (!person)
23534
+ throw new MessagingError("no_identity", "this home was never bound to an account: bind it instead (device.bind or messaging login)");
23535
+ const pred = this.#predecessor();
23536
+ if (!pred)
23537
+ throw new MessagingError("no_identity", "this home names no device to replace");
23538
+ const record = this.#heldRealm(config).ownDevices().find((d) => d.device === pred);
23539
+ if (!record)
23540
+ throw new MessagingError("not_found", "this home's device is not in its account's log");
23541
+ if (this.#store.getKv(PREDECESSOR_KEY) === undefined || this.#identity === undefined || this.#identity.machine === pred) {
23542
+ if (!record.current)
23543
+ throw new MessagingError("invalid_state", "this home's device was removed from its account; it cannot be replaced");
23544
+ await this.#newKeys(pred, config);
23545
+ }
23546
+ return this.#replaceNext(p);
23547
+ }
23548
+ async#replaceNext(p) {
23549
+ const home = this.#directory;
23550
+ const me = this.#identity.machine;
23551
+ if (p.token !== undefined) {
23552
+ const done = await this.#replaceWith(p.token, p.label);
23553
+ if (this.#store.getKv(PREDECESSOR_KEY) === undefined)
23554
+ return { state: "done", person: done.person, device: me, predecessor: done.predecessor, seq: done.seq };
23555
+ } else if (this.#replaceFinished()) {
23556
+ return this.#replaceDone();
23557
+ }
23558
+ if (this.#store.getKv(PREDECESSOR_KEY) === undefined)
23559
+ return this.#replaceDone();
23560
+ if (this.#jellyHost || p.token !== undefined)
23561
+ return { state: "token", device: me };
23562
+ const pred = this.#store.getKv(PREDECESSOR_KEY);
23563
+ const record = home.realm.ownDevices().find((d) => d.device === pred);
23564
+ const label = p.label ?? this.#store.getKv(REPLACE_LABEL_KEY) ?? record?.label ?? "device";
23565
+ const kind = record && record.kind !== "jelly-host" ? record.kind : "laptop";
23566
+ const started = await home.signIn("replace", { label, kind });
23567
+ return { state: "sign-in", device: me, ...started };
23568
+ }
23569
+ #replaceFinished() {
23570
+ const pred = this.#store.getKv(PREDECESSOR_KEY);
23571
+ if (pred === undefined)
23572
+ return true;
23573
+ const realm = this.#directory?.realm;
23574
+ if (!realm || realm.binding().state === "none")
23575
+ return false;
23576
+ return realm.ownDevices().find((d) => d.device === pred)?.current !== true;
23577
+ }
23578
+ #replaceDone() {
23579
+ const realm = this.#directory.realm;
23580
+ const pred = this.#store.getKv(PREDECESSOR_KEY) ?? this.#store.getKv(REPLACED_KEY);
23581
+ this.#store.deleteKv(PREDECESSOR_KEY);
23582
+ const me = this.#identity.machine;
23583
+ const binding = realm.binding();
23584
+ const record = pred !== undefined ? realm.ownDevices().find((d) => d.device === pred) : undefined;
23585
+ return { state: "done", person: binding.person, device: me, predecessor: pred ?? me, seq: record?.unbound ? realm.deviceChanges(binding.person).find((c) => c.device === pred && c.change === "replaced")?.seq ?? 0 : 0 };
23586
+ }
23587
+ async#replaceWith(token, label) {
23588
+ const home = this.#directory;
23589
+ const pred = this.#store.getKv(PREDECESSOR_KEY);
23590
+ if (!home || pred === undefined)
23591
+ throw new MessagingError("invalid_state", "no replacement is pending: start one with identity.replace");
23592
+ const realm = home.realm;
23593
+ const me = this.#identity.machine;
23594
+ const record = realm.ownDevices().find((d) => d.device === pred);
23595
+ let seq = 0;
23596
+ let spent = false;
23597
+ if (realm.binding().state === "none") {
23598
+ const kind = this.#jellyHost?.kind ?? (record && record.kind !== "jelly-host" ? record.kind : "laptop");
23599
+ const done = await home.bind({ token, label: this.#jellyHost?.label ?? label ?? this.#store.getKv(REPLACE_LABEL_KEY) ?? record?.label ?? "device", kind });
23600
+ seq = done.seq;
23601
+ spent = signInToken(token);
23602
+ this.#log("info", "identity.replace.bound", { seq });
23603
+ }
23604
+ await realm.awaitSelf();
23605
+ this.#tx(() => this.#grants.resignFrom(pred));
23606
+ let unbound = false;
23607
+ if (!spent && realm.ownDevices().find((d) => d.device === pred)?.current) {
23608
+ const done = await realm.unbind(pred, { reason: "replaced", token });
23609
+ seq = done.seq;
23610
+ unbound = true;
23611
+ this.#log("info", "identity.replace.done", { seq });
23612
+ }
23613
+ if (unbound || this.#replaceFinished()) {
23614
+ this.#store.setKv(REPLACED_KEY, pred);
23615
+ this.#store.deleteKv(PREDECESSOR_KEY);
23616
+ this.#store.deleteKv(REPLACE_LABEL_KEY);
23617
+ this.#emit({ type: "contacts" });
23618
+ }
23619
+ return { person: realm.binding().person, device: me, predecessor: pred, seq };
23620
+ }
23621
+ async#newKeys(pred, config) {
23622
+ const label = this.#heldRealm(config).ownDevices().find((d) => d.device === pred)?.label;
23623
+ this.#store.setKv(PREDECESSOR_KEY, pred);
23624
+ if (label !== undefined)
23625
+ this.#store.setKv(REPLACE_LABEL_KEY, label);
23626
+ const keys = { identity: generateIdentity(), retained: [] };
23627
+ let identityStore = this.#keyring.store;
23628
+ if (identityStore)
23629
+ identityStore.save(keys);
23630
+ else
23631
+ identityStore = saveNewIdentity(this.paths.identity, keys, identitySettings(this.#config), this.#storeDeps());
23632
+ this.#store.setKv(MACHINE_KEY, keys.identity.machine);
23633
+ this.#log("info", "identity.replace.keys", { store: identityStore.kind });
23634
+ const old = this.#directory;
23635
+ this.#directory = undefined;
23636
+ this.#realm = undefined;
23637
+ this.#transport = undefined;
23638
+ if (old) {
23639
+ await old.stop();
23640
+ this.#onHostedState(false);
23641
+ }
23642
+ const cache = this.#store.directoryCache();
23643
+ if (cache.alarm()?.kind === "restored")
23644
+ cache.setAlarm(undefined);
23645
+ for (const row of this.#store.outbox())
23646
+ if (row.env.from.device === pred)
23647
+ this.#store.deleteOutbox(row.id);
23648
+ this.#keysMissing = false;
23649
+ this.#keyring.adopt(keys, identityStore);
23650
+ this.#openDirectory(config, keys, identityStore);
23651
+ }
23652
+ #erasePreview(_p) {
23653
+ const home = this.#homeDirectory("directory.erase.preview");
23654
+ const binding = home.realm.binding();
23655
+ if (!binding.person || binding.state === "none" || binding.state === "removed")
23656
+ throw new MessagingError("no_identity", "this device is not bound to an account");
23657
+ const devices = home.realm.ownDevices().filter((d) => d.current);
23658
+ const me = this.#identity.machine;
23659
+ return {
23660
+ person: binding.person,
23661
+ devices: devices.map((d) => ({ device: d.device, label: d.label, kind: d.kind, self: d.device === me })),
23662
+ signIns: this.#jellyHost || binding.record?.host !== undefined ? 0 : 1,
23663
+ perDay: ERASURES_PER_DAY,
23664
+ lost: [
23665
+ `Every device of your account (${devices.length}, this one included) is removed from Messaging, and none can be bound again.`,
23666
+ "Your Jelly account can never be bound to Messaging again: the erasure is for good, and a new sign-in is refused.",
23667
+ "The directory deletes your name, email and handle, and every device record and key statement it held for you.",
23668
+ "Messages still queued at the relay for your devices are deleted.",
23669
+ "Your contacts see a deleted account, and drop the grants and cards they hold for you."
23670
+ ],
23671
+ kept: ["Each device keeps its own history (conversations, transcripts, grants) on its disk until you delete its home.", "Your Jelly account itself is not deleted: only its Messaging account is."]
23672
+ };
23673
+ }
23674
+ async#erase(p) {
23675
+ const home = this.#homeDirectory("directory.erase");
23676
+ const binding = home.realm.binding();
23677
+ if (!binding.person || binding.state === "none" || binding.state === "removed")
23678
+ throw new MessagingError("no_identity", "this device is not bound to an account");
23679
+ if (binding.record?.vouch !== "jelly")
23680
+ throw new MessagingError("forbidden", "a device approved by link erases nothing; erase from a device Jelly ID vouched for");
23681
+ if (p.token !== undefined) {
23682
+ const done = await this.#eraseWith(p.token);
23683
+ return { state: "done", person: done.person, devices: done.devices };
23684
+ }
23685
+ if (this.#jellyHost)
23686
+ throw new MessagingError("invalid_request", "under a Jelly host, directory.erase needs a fresh token for this device from its host (token)");
23687
+ const record = binding.record;
23688
+ const started = await home.signIn("erase", { label: record.label, kind: record.kind !== "jelly-host" ? record.kind : "laptop" });
23689
+ return { state: "sign-in", ...started };
23690
+ }
23691
+ async#eraseWith(token) {
23692
+ const home = this.#homeDirectory("directory.erase");
23693
+ let done;
23694
+ try {
23695
+ done = await home.realm.erase(token);
23696
+ } catch (error) {
23697
+ if (!(error instanceof MessagingError) || error.code !== "rate_limited")
23698
+ throw error;
23699
+ const wait = error.retryAfterMs;
23700
+ const when = typeof wait === "number" && wait > 0 ? `; try again in about ${Math.max(1, Math.ceil(wait / 60000))} min` : "; try again tomorrow";
23701
+ throw new MessagingError("rate_limited", `the directory allows ${ERASURES_PER_DAY} erasures per person a day${when}`);
23702
+ }
23703
+ this.#log("warn", "directory.erased", { devices: done.devices });
23704
+ return done;
23705
+ }
23068
23706
  #removePreview(p) {
23069
23707
  const entry = this.#homeDirectory("device.remove.preview").list().devices.find((d) => d.device === p.device);
23070
23708
  if (!entry)
@@ -23078,6 +23716,93 @@ ${row.receipt ? "receipt" : "envelope"}`;
23078
23716
  ...entry.since !== undefined ? { since: entry.since } : {}
23079
23717
  };
23080
23718
  }
23719
+ #deviceLogGrew(persons) {
23720
+ try {
23721
+ this.#deviceNotices();
23722
+ } catch (error) {
23723
+ this.#log("error", "notice.device.failed", { code: codeOf(error) });
23724
+ }
23725
+ if (persons.some((person) => person !== this.#me()))
23726
+ this.#emit({ type: "contacts" });
23727
+ }
23728
+ #ownPerson() {
23729
+ return this.#directory?.person() ?? this.#me();
23730
+ }
23731
+ #deviceNotices(start = false) {
23732
+ const realm = this.#realm ?? this.#directory?.realm;
23733
+ const person = this.#ownPerson();
23734
+ const me = this.#identity?.machine;
23735
+ if (!realm || !person || !me)
23736
+ return;
23737
+ const changes = realm.deviceChanges(person);
23738
+ const head = changes.at(-1)?.seq ?? -1;
23739
+ let last = this.#store.getKv(DEVICE_NOTICES_KEY);
23740
+ if (last === undefined) {
23741
+ const admitted = changes.find((change) => change.device === me && change.change === "added");
23742
+ if (start || admitted === undefined) {
23743
+ this.#store.setKv(DEVICE_NOTICES_KEY, String(head));
23744
+ return;
23745
+ }
23746
+ last = String(admitted.seq);
23747
+ }
23748
+ const fresh = changes.filter((change) => change.seq > Number(last));
23749
+ if (fresh.length === 0)
23750
+ return;
23751
+ const replacers = new Set(fresh.filter((change) => change.change === "replaced").map((change) => change.by));
23752
+ for (const change of fresh) {
23753
+ if (change.device === me || change.by === me)
23754
+ continue;
23755
+ if (change.change === "added" && replacers.has(change.device))
23756
+ continue;
23757
+ this.#notices.device(change.seq, change.at);
23758
+ }
23759
+ this.#store.setKv(DEVICE_NOTICES_KEY, String(Math.max(head, Number(last))));
23760
+ }
23761
+ #noticeDevice(seq) {
23762
+ const realm = this.#realm ?? this.#directory?.realm;
23763
+ const person = this.#ownPerson();
23764
+ if (!realm || !person)
23765
+ return;
23766
+ const changes = realm.deviceChanges(person);
23767
+ const change = changes.find((c) => c.seq === seq);
23768
+ if (!change)
23769
+ return;
23770
+ const labelOf = (device) => changes.filter((c) => c.device === device).at(-1)?.label;
23771
+ const gone = (device) => changes.some((c) => c.device === device && (c.change === "removed" || c.change === "replaced"));
23772
+ const subject = change.change === "replaced" ? change.by : change.device;
23773
+ const latest = change.change === "replaced" ? changes.filter((c) => c.device === subject && c.change !== "removed" && c.change !== "replaced").at(-1) : change;
23774
+ const label = latest?.label ?? labelOf(subject) ?? "device";
23775
+ const current = !gone(subject);
23776
+ const view = {
23777
+ change: change.change,
23778
+ device: subject,
23779
+ label: MachineLabel.safeParse(label).success ? label : "device",
23780
+ ...latest?.kind !== undefined ? { kind: latest.kind } : {},
23781
+ vouch: ((v) => v === "service" ? "device" : v)(latest?.vouch ?? change.vouch),
23782
+ ...(latest?.via ?? change.via) !== undefined ? { via: latest?.via ?? change.via } : {},
23783
+ ...change.approver !== undefined && labelOf(change.approver) !== undefined ? { approver: labelOf(change.approver) } : {},
23784
+ ...change.change === "replaced" ? { replaces: { device: change.device, label: change.label } } : {},
23785
+ current,
23786
+ at: change.at
23787
+ };
23788
+ const remove = current ? this.#removeWith() : undefined;
23789
+ if (remove)
23790
+ view.remove = remove;
23791
+ return view;
23792
+ }
23793
+ #removeWith() {
23794
+ const home = this.#directory;
23795
+ if (!home)
23796
+ return this.#roster?.isAdmin(this.#identity?.machine ?? "") ? "team" : undefined;
23797
+ const self = home.list().devices.find((device) => device.self && device.current);
23798
+ if (!self || self.vouched !== "jelly")
23799
+ return;
23800
+ return self.via === "browser" ? "sign-in" : "token";
23801
+ }
23802
+ #trustNotices() {
23803
+ for (const grant of this.#grants.expiringTrust(TRUST_NOTICE_MS))
23804
+ this.#notices.trust(grant.id, grant.person, grant.until, this.#now());
23805
+ }
23081
23806
  #alerts() {
23082
23807
  const realmAlarm = this.#directory?.status().alarm;
23083
23808
  const roster = this.#keysMissing ? "keys_missing" : this.#keyring.restored() ? "restored" : undefined;
@@ -23095,6 +23820,16 @@ ${row.receipt ? "receipt" : "envelope"}`;
23095
23820
  }
23096
23821
  if (realmAlarm)
23097
23822
  this.#notices.alert(realmAlarm.kind, realmAlarm.at);
23823
+ this.#deviceNotices();
23824
+ if (this.#joinedOrUndefined())
23825
+ this.#trustNotices();
23826
+ }
23827
+ #joinedOrUndefined() {
23828
+ try {
23829
+ return this.#joined();
23830
+ } catch {
23831
+ return;
23832
+ }
23098
23833
  }
23099
23834
  #homeDirectory(method) {
23100
23835
  if (this.#directory)
@@ -23463,7 +24198,15 @@ function jellyConfig(path, jelly) {
23463
24198
  function isLineTarget(to) {
23464
24199
  return to.startsWith("#") || !to.includes("/");
23465
24200
  }
23466
- var DAY3 = 86400000, KEYCHAIN_LEFTOVERS_KEY = "keychain.stale", DEFAULT_TUNING, KEPT_MS, INPUT_TIMEOUT_MS, TASK_BYTES = 1048576, MACHINE_KEY = "identity.machine", DEFAULT_INVITE_TTL_MS, LIST_BYTES = 1572864;
24201
+ function signInToken(token) {
24202
+ try {
24203
+ const claims = parseWire(Buffer.from(token.split(".")[1] ?? "", "base64url").toString("utf8"));
24204
+ return typeof claims === "object" && claims !== null && claims.src === "device";
24205
+ } catch {
24206
+ return false;
24207
+ }
24208
+ }
24209
+ var DAY3 = 86400000, KEYCHAIN_LEFTOVERS_KEY = "keychain.stale", DEFAULT_TUNING, KEPT_MS, INPUT_TIMEOUT_MS, TASK_BYTES = 1048576, PREDECESSOR_KEY = "identity.predecessor", REPLACE_LABEL_KEY = "identity.replace.label", REPLACED_KEY = "identity.replaced", ERASURES_PER_DAY = 10, DEVICE_NOTICES_KEY = "notice.devices.seq", TRUST_NOTICE_MS, DEVICES_CHANGED_MS, MACHINE_KEY = "identity.machine", DEFAULT_INVITE_TTL_MS, LIST_BYTES = 1572864;
23467
24210
  var init_daemon = __esm(() => {
23468
24211
  init_dist();
23469
24212
  init_dist2();
@@ -23480,6 +24223,7 @@ var init_daemon = __esm(() => {
23480
24223
  init_policy();
23481
24224
  init_roster_identity();
23482
24225
  init_directory_home();
24226
+ init_directory_identity();
23483
24227
  init_jelly3();
23484
24228
  init_jelly_env();
23485
24229
  init_answer_preview();
@@ -23509,6 +24253,8 @@ var init_daemon = __esm(() => {
23509
24253
  };
23510
24254
  KEPT_MS = DAY3;
23511
24255
  INPUT_TIMEOUT_MS = DAY3;
24256
+ TRUST_NOTICE_MS = 7 * DAY3;
24257
+ DEVICES_CHANGED_MS = 7 * DAY3;
23512
24258
  DEFAULT_INVITE_TTL_MS = DAY3;
23513
24259
  });
23514
24260
 
@@ -25072,6 +25818,9 @@ class RelayDirectoryLink {
25072
25818
  throw shape();
25073
25819
  return { seq: reply.seq, sth: reply.sth };
25074
25820
  }
25821
+ async erase(entries, token) {
25822
+ parse5(DirOkFrame, await link((await this.#authed()).dirErase(entries, token)));
25823
+ }
25075
25824
  async keys(statement) {
25076
25825
  const reply = parse5(DirOkFrame, await link((await this.#authed()).dirKeys(statement)));
25077
25826
  if (reply.sth === undefined)
@@ -26454,6 +27203,12 @@ var people = {
26454
27203
  }
26455
27204
  ctx.out.line(`${ctx.out.style.bold(inline(person.name, 64))} handle ${inline(person.handle, 64)} org ${person.org} vouched ${person.vouched}`);
26456
27205
  ctx.out.lines(table(["DEVICE", "LABEL", ""], person.devices.map((d) => [d.device, inline(d.label, 32), d.self ? "this device" : ""]), ctx.out.style));
27206
+ if (person.deviceChanges !== undefined) {
27207
+ const now = ctx.now();
27208
+ ctx.out.line(`${ctx.out.style.yellow(`${inline(person.name, 64)}'s devices changed:`)}`);
27209
+ for (const change of person.deviceChanges)
27210
+ ctx.out.line(` ${change.change} '${inline(change.label, 32)}' (${vouchWords(change)}) ${ago(change.at, now)}`);
27211
+ }
26457
27212
  return;
26458
27213
  }
26459
27214
  if (ctx.json) {
@@ -26570,8 +27325,9 @@ var notices = {
26570
27325
  usage: "[--lines] [--ack]",
26571
27326
  details: [
26572
27327
  "Each line is a message for you (a DM or channel line from someone else, or your own agent's line to you),",
26573
- "a request held for you to answer (`messaging requests`), or an alert about this device. --lines adds a",
26574
- "message's first line (peer text). --ack marks every notice shown as seen."
27328
+ "a request held for you to answer (`messaging requests`), or an alert: about this device, a device added to or",
27329
+ "removed from your account (with how to remove one that is not yours), or trust you gave that ends within 7 days.",
27330
+ "--lines adds a message's first line (peer text). --ack marks every notice shown as seen."
26575
27331
  ].join(`
26576
27332
  `),
26577
27333
  options: {
@@ -26598,13 +27354,25 @@ var notices = {
26598
27354
  ago(n.at, now),
26599
27355
  n.category,
26600
27356
  n.from ? inline(n.from.name, 40) : "",
26601
- n.conversation !== undefined ? inline(n.conversation, 60) : "",
27357
+ n.conversation !== undefined ? inline(n.conversation, 60) : n.device !== undefined ? `device ${n.device.device}` : "",
26602
27358
  n.line !== undefined ? inline(n.line, 120) : ""
26603
27359
  ]), ctx.out.style));
27360
+ const removable = list.flatMap((n) => n.device?.remove !== undefined && n.device.current ? [n.device] : []);
27361
+ for (const d of removable) {
27362
+ const how = d.remove === "sign-in" ? `messaging login --remove ${d.device}` : d.remove === "team" ? `messaging team remove ${d.device}` : "Remove it from the Jelly app on this device";
27363
+ ctx.out.note(`Not yours? '${inline(d.label, 32)}': ${how}`);
27364
+ }
26604
27365
  if (values.ack === true)
26605
27366
  ctx.out.line(ctx.out.style.dim(`${list.length} marked seen`));
26606
27367
  }
26607
27368
  };
27369
+ function vouchWords(d) {
27370
+ if (d.vouch === "admin")
27371
+ return "admitted by the team's admin";
27372
+ if (d.vouch === "device")
27373
+ return d.approver !== undefined ? `approved on '${inline(d.approver, 32)}'` : "approved by link";
27374
+ return d.via === "browser" ? "signed in through the browser" : "vouched by Jelly ID";
27375
+ }
26608
27376
  var DIRECTORY_COMMANDS = [status, who2, people, devices, notices];
26609
27377
 
26610
27378
  // packages/daemon/dist/cli/commands/identity.js
@@ -26874,6 +27642,55 @@ function aborted2(signal) {
26874
27642
  signal.addEventListener("abort", () => resolve(), { once: true });
26875
27643
  });
26876
27644
  }
27645
+ async function showSignIn(ctx, started, open) {
27646
+ if (!ctx.json) {
27647
+ const { bold } = ctx.out.style;
27648
+ const lead = started.purpose === "remove" ? "To remove that device, sign in at Jelly ID:" : started.purpose === "replace" ? "To replace your old device, sign in at Jelly ID with this one:" : started.purpose === "erase" ? "To erase your account, sign in at Jelly ID:" : "To sign this device in, open:";
27649
+ ctx.out.line(lead);
27650
+ ctx.out.line(` ${started.verificationUriComplete}`);
27651
+ ctx.out.line(`and check that the page shows the code ${bold(started.userCode)} and the key ${bold(started.fingerprint)}.`);
27652
+ ctx.out.line(`(Or open ${started.verificationUri} and type the code.)`);
27653
+ }
27654
+ if (open) {
27655
+ const opened = await (ctx.io.openUrl ?? openInBrowser)(started.verificationUriComplete);
27656
+ if (!opened)
27657
+ ctx.out.note("Could not open a browser here; open the link on any device.");
27658
+ } else if (!ctx.json && (ctx.io.env.SSH_CONNECTION !== undefined || ctx.io.env.SSH_TTY !== undefined)) {
27659
+ ctx.out.note("Over SSH: open the link on your laptop or phone; the code ties it to this machine.");
27660
+ }
27661
+ }
27662
+ async function waitSignIn(ctx, client, started) {
27663
+ const minutes = Math.max(1, Math.round((started.expires - ctx.now()) / 60000));
27664
+ if (!ctx.json)
27665
+ ctx.out.note(`Waiting for your approval (the code expires in ${minutes} min; Ctrl-C cancels)...`);
27666
+ const interrupted = aborted2(ctx.io.signal);
27667
+ let state = { ...started, state: "pending" };
27668
+ while (state.state === "pending") {
27669
+ const asked = client.call("device.login.status", { waitMs: STATUS_WAIT_MS }, { timeoutMs: STATUS_WAIT_MS + 15000 });
27670
+ const next = await Promise.race([asked, interrupted.then(() => {
27671
+ return;
27672
+ })]);
27673
+ if (next === undefined) {
27674
+ asked.catch(() => {});
27675
+ await client.call("device.login.cancel", {}).catch(() => {});
27676
+ ctx.out.note("Cancelled.");
27677
+ return;
27678
+ }
27679
+ state = next;
27680
+ }
27681
+ if (state.state === "failed")
27682
+ throw new MessagingError(state.code, state.message);
27683
+ return state;
27684
+ }
27685
+ async function confirmed(ctx, yes, question, word = "y") {
27686
+ if (yes)
27687
+ return true;
27688
+ if (ctx.json)
27689
+ throw new UsageError("with --json there is no prompt: confirm with --yes");
27690
+ ctx.out.prompt(`${question} Type ${word} to confirm: `);
27691
+ const answer = (await readLine(ctx.io.stdin) ?? "").trim().toLowerCase();
27692
+ return word === "y" ? /^y(es)?$/.test(answer) : answer === word;
27693
+ }
26877
27694
  var login = {
26878
27695
  name: "login",
26879
27696
  summary: "Sign this device in to your account through the browser (hosted realm)",
@@ -26917,45 +27734,15 @@ var login = {
26917
27734
  ctx.out.json(started);
26918
27735
  return;
26919
27736
  }
26920
- if (!ctx.json) {
26921
- const { bold } = ctx.out.style;
26922
- ctx.out.line(started.purpose === "remove" ? "To remove that device, sign in at Jelly ID:" : "To sign this device in, open:");
26923
- ctx.out.line(` ${started.verificationUriComplete}`);
26924
- ctx.out.line(`and check that the page shows the code ${bold(started.userCode)} and the key ${bold(started.fingerprint)}.`);
26925
- ctx.out.line(`(Or open ${started.verificationUri} and type the code.)`);
26926
- }
26927
- if (values.open === true) {
26928
- const opened = await (ctx.io.openUrl ?? openInBrowser)(started.verificationUriComplete);
26929
- if (!opened)
26930
- ctx.out.note("Could not open a browser here; open the link on any device.");
26931
- } else if (!ctx.json && (ctx.io.env.SSH_CONNECTION !== undefined || ctx.io.env.SSH_TTY !== undefined)) {
26932
- ctx.out.note("Over SSH: open the link on your laptop or phone; the code ties it to this machine.");
26933
- }
27737
+ await showSignIn(ctx, started, values.open === true);
26934
27738
  const minutes = Math.max(1, Math.round((started.expires - ctx.now()) / 60000));
26935
27739
  if (!wait) {
26936
27740
  ctx.out.note(`The daemon waits ${minutes} min for your approval. See it with: messaging devices`);
26937
27741
  return;
26938
27742
  }
26939
- if (!ctx.json)
26940
- ctx.out.note(`Waiting for your approval (the code expires in ${minutes} min; Ctrl-C cancels)...`);
26941
- const signal = ctx.io.signal;
26942
- const interrupted = aborted2(signal);
26943
- let state = { ...started, state: "pending" };
26944
- while (state.state === "pending") {
26945
- const asked = client.call("device.login.status", { waitMs: STATUS_WAIT_MS }, { timeoutMs: STATUS_WAIT_MS + 15000 });
26946
- const next = await Promise.race([asked, interrupted.then(() => {
26947
- return;
26948
- })]);
26949
- if (next === undefined) {
26950
- asked.catch(() => {});
26951
- await client.call("device.login.cancel", {}).catch(() => {});
26952
- ctx.out.note("Cancelled.");
26953
- return 130;
26954
- }
26955
- state = next;
26956
- }
26957
- if (state.state === "failed")
26958
- throw new MessagingError(state.code, state.message);
27743
+ const state = await waitSignIn(ctx, client, started);
27744
+ if (state === undefined)
27745
+ return 130;
26959
27746
  if (ctx.json) {
26960
27747
  ctx.out.json(state);
26961
27748
  return state.state === "done" ? undefined : 1;
@@ -26972,7 +27759,128 @@ var login = {
26972
27759
  }
26973
27760
  }
26974
27761
  };
26975
- var LOGIN_COMMANDS = [login];
27762
+ var replace = {
27763
+ name: "identity replace",
27764
+ summary: "Replace this home's device with new keys after a restore (hosted realm)",
27765
+ usage: "[--label <label>] [--open] [--yes]",
27766
+ details: [
27767
+ "For a home restored from a backup or a snapshot (alarm keys_missing or restored), or for a fresh key on purpose.",
27768
+ "This device gets a new key, binds it to your account and removes the old device (reason replaced): your",
27769
+ "conversations, grants and history stay, and tasks continue. Mail still queued at the relay for the old device is",
27770
+ "lost. Through the browser you approve two sign-ins at Jelly ID: one binds the new key, one removes the old."
27771
+ ].join(`
27772
+ `),
27773
+ options: {
27774
+ label: { type: "string", value: "<label>", description: "The new device's label (default: the old one's)" },
27775
+ open: { type: "boolean", description: "Open each link in this machine's browser" },
27776
+ yes: { type: "boolean", short: "y", description: "Do not ask for confirmation" }
27777
+ },
27778
+ args: { min: 0, max: 0 },
27779
+ async run(ctx, _positionals, values) {
27780
+ const label = stringValue(values, "label");
27781
+ const client = await ctx.client();
27782
+ const preview = await client.call("identity.replace.preview", {});
27783
+ if (!ctx.json)
27784
+ ctx.out.line(inline(preview.sentence, 600));
27785
+ if (!await confirmed(ctx, values.yes === true, `Replace '${inline(preview.predecessor.label, 32)}' (device ${preview.predecessor.device}) with this device?`)) {
27786
+ ctx.out.note("Not replaced.");
27787
+ return 1;
27788
+ }
27789
+ const params = label !== undefined ? { label: parseWith2(MachineLabel, label, "--label must be a lowercase label, e.g. desk") } : {};
27790
+ for (let step = 0;step < 3; step++) {
27791
+ const result = await client.call("identity.replace", params);
27792
+ if (result.state === "done") {
27793
+ if (ctx.json)
27794
+ ctx.out.json(result);
27795
+ else
27796
+ ctx.out.line(`Replaced: this device (${result.device}) replaces ${result.predecessor} on ${inline(result.person, 64)}.`);
27797
+ return;
27798
+ }
27799
+ if (result.state === "token") {
27800
+ if (ctx.json)
27801
+ ctx.out.json(result);
27802
+ else
27803
+ ctx.out.note(`This device (${result.device}) waits for its Jelly host to bind the new key; the host's app finishes the replacement.`);
27804
+ return;
27805
+ }
27806
+ await showSignIn(ctx, result, values.open === true);
27807
+ const state = await waitSignIn(ctx, client, result);
27808
+ if (state === undefined)
27809
+ return 130;
27810
+ if (state.state !== "done") {
27811
+ ctx.out.note("The sign-in was cancelled (another sign-in started, or the daemon stopped).");
27812
+ return 1;
27813
+ }
27814
+ if ((await client.call("device.status", {})).binding !== "replace") {
27815
+ const done = { state: "done", person: state.person, device: result.device, predecessor: preview.predecessor.device, seq: state.seq };
27816
+ if (ctx.json)
27817
+ ctx.out.json(done);
27818
+ else
27819
+ ctx.out.line(`Replaced: this device (${result.device}) replaces ${preview.predecessor.device} on ${inline(state.person, 64)}.`);
27820
+ return;
27821
+ }
27822
+ if (!ctx.json)
27823
+ ctx.out.note("The new key is bound. One more sign-in removes the old device.");
27824
+ }
27825
+ throw new MessagingError("internal", "the replacement did not finish; run messaging identity replace again");
27826
+ }
27827
+ };
27828
+ var erase = {
27829
+ name: "erase",
27830
+ summary: "Erase your account from Messaging, for good (hosted realm)",
27831
+ usage: "[--open] [--yes]",
27832
+ details: [
27833
+ "Removes every device of your account and deletes what the directory holds about you. Your Jelly account can",
27834
+ "never be bound to Messaging again. The directory allows 10 erasures per person a day. Shows what is lost and",
27835
+ "asks you to type erase, unless --yes. A device approved by link cannot erase; one signed in through the browser",
27836
+ "approves one more sign-in at Jelly ID."
27837
+ ].join(`
27838
+ `),
27839
+ options: {
27840
+ open: { type: "boolean", description: "Open the link in this machine's browser" },
27841
+ yes: { type: "boolean", short: "y", description: "Do not ask for confirmation" }
27842
+ },
27843
+ args: { min: 0, max: 0 },
27844
+ async run(ctx, _positionals, values) {
27845
+ const client = await ctx.client();
27846
+ const preview = await client.call("directory.erase.preview", {});
27847
+ if (!ctx.json) {
27848
+ ctx.out.line(`Erasing ${inline(preview.person, 64)} from Messaging. What is lost:`);
27849
+ for (const line of preview.lost)
27850
+ ctx.out.line(` - ${inline(line, 300)}`);
27851
+ ctx.out.line(`Devices removed: ${preview.devices.map((d) => `'${inline(d.label, 32)}'${d.self ? " (this one)" : ""}`).join(", ")}`);
27852
+ ctx.out.line("What stays:");
27853
+ for (const line of preview.kept)
27854
+ ctx.out.line(` - ${inline(line, 300)}`);
27855
+ ctx.out.note(`The directory allows ${preview.perDay} erasures per person a day.`);
27856
+ }
27857
+ if (!await confirmed(ctx, values.yes === true, "This cannot be undone.", "erase")) {
27858
+ ctx.out.note("Not erased.");
27859
+ return 1;
27860
+ }
27861
+ const result = await client.call("directory.erase", {});
27862
+ if (result.state === "done") {
27863
+ if (ctx.json)
27864
+ ctx.out.json(result);
27865
+ else
27866
+ ctx.out.line(`Erased: ${result.devices} device(s) removed; this account is gone from Messaging.`);
27867
+ return;
27868
+ }
27869
+ await showSignIn(ctx, result, values.open === true);
27870
+ const state = await waitSignIn(ctx, client, result);
27871
+ if (state === undefined)
27872
+ return 130;
27873
+ if (state.state !== "done") {
27874
+ ctx.out.note("The sign-in was cancelled (another sign-in started, or the daemon stopped).");
27875
+ return 1;
27876
+ }
27877
+ if (ctx.json)
27878
+ ctx.out.json({ state: "done", person: state.person, devices: preview.devices.length });
27879
+ else
27880
+ ctx.out.line(`Erased: ${preview.devices.length} device(s) removed; this account is gone from Messaging.`);
27881
+ }
27882
+ };
27883
+ var LOGIN_COMMANDS = [login, replace, erase];
26976
27884
 
26977
27885
  // packages/daemon/dist/cli/commands/messages.js
26978
27886
  init_dist();
@@ -27724,13 +28632,13 @@ var HANDLES_INTERRUPT = new Set(["listen", "login"]);
27724
28632
  var GROUPS = {
27725
28633
  daemon: "Run the local daemon, messagingd",
27726
28634
  team: "Manage the team: create, invite, join, members, roles (only the CLI may do this)",
27727
- identity: "Show, move or rotate this machine's keys (only the CLI may do this)",
28635
+ identity: "Show, move, rotate or replace this machine's keys (only the CLI may do this)",
27728
28636
  chat: "Your DMs and channels: list, read, post, search"
27729
28637
  };
27730
28638
  var SECTIONS = [
27731
28639
  ["Daemon", ["daemon start", "daemon stop", "status"]],
27732
28640
  ["Team", ["team prepare", "team create", "team card", "team invite", "team join", "team pair", "team add", "team claim", "team pair-cancel", "team members", "team remove", "team role", "team revoke-invite"]],
27733
- ["Identity", ["identity show", "identity migrate", "identity rotate-enc"]],
28641
+ ["Identity", ["identity show", "identity migrate", "identity rotate-enc", "identity replace", "erase"]],
27734
28642
  ["People", ["login", "people", "devices", "grants", "grant", "requests", "contact", "card", "notices"]],
27735
28643
  ["Messages", ["who", "send", "inbox", "read", "message-status"]],
27736
28644
  ["Chats", ["chat list", "chat read", "chat post", "chat search"]],