@oxyhq/core 20.1.0 → 21.0.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.
Files changed (70) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/boot/sessionColdBoot.js +107 -8
  3. package/dist/cjs/i18n/locales/en-US.json +19 -2
  4. package/dist/cjs/i18n/locales/es-ES.json +19 -2
  5. package/dist/cjs/i18n/locales/locales/en-US.json +19 -2
  6. package/dist/cjs/i18n/locales/locales/es-ES.json +19 -2
  7. package/dist/cjs/index.js +50 -16
  8. package/dist/cjs/mixins/OxyServices.auth.js +27 -3
  9. package/dist/cjs/session/SessionClient.js +361 -1
  10. package/dist/cjs/session/accountDialogController.js +121 -147
  11. package/dist/cjs/session/accountSwitchTargets.js +75 -0
  12. package/dist/cjs/session/deviceDirectory.js +143 -0
  13. package/dist/cjs/session/deviceSwitcherRows.js +76 -0
  14. package/dist/cjs/session/projectSessionState.js +8 -1
  15. package/dist/cjs/session/sharedDeviceCredential.js +247 -0
  16. package/dist/esm/.tsbuildinfo +1 -1
  17. package/dist/esm/boot/sessionColdBoot.js +107 -8
  18. package/dist/esm/i18n/locales/en-US.json +19 -2
  19. package/dist/esm/i18n/locales/es-ES.json +19 -2
  20. package/dist/esm/i18n/locales/locales/en-US.json +19 -2
  21. package/dist/esm/i18n/locales/locales/es-ES.json +19 -2
  22. package/dist/esm/index.js +32 -10
  23. package/dist/esm/mixins/OxyServices.auth.js +27 -3
  24. package/dist/esm/session/SessionClient.js +362 -2
  25. package/dist/esm/session/accountDialogController.js +121 -147
  26. package/dist/esm/session/accountSwitchTargets.js +71 -0
  27. package/dist/esm/session/deviceDirectory.js +135 -0
  28. package/dist/esm/session/deviceSwitcherRows.js +72 -0
  29. package/dist/esm/session/projectSessionState.js +8 -2
  30. package/dist/esm/session/sharedDeviceCredential.js +239 -0
  31. package/dist/types/.tsbuildinfo +1 -1
  32. package/dist/types/boot/sessionColdBoot.d.ts +24 -4
  33. package/dist/types/index.d.ts +8 -3
  34. package/dist/types/mixins/OxyServices.auth.d.ts +75 -3
  35. package/dist/types/models/session.d.ts +11 -0
  36. package/dist/types/session/SessionClient.d.ts +202 -1
  37. package/dist/types/session/accountDialogController.d.ts +76 -64
  38. package/dist/types/session/accountSwitchTargets.d.ts +64 -0
  39. package/dist/types/session/deviceDirectory.d.ts +182 -0
  40. package/dist/types/session/deviceSwitcherRows.d.ts +92 -0
  41. package/dist/types/session/projectSessionState.d.ts +29 -0
  42. package/dist/types/session/sharedDeviceCredential.d.ts +202 -0
  43. package/package.json +3 -3
  44. package/src/boot/__tests__/sessionColdBoot.sharedDevice.test.ts +325 -0
  45. package/src/boot/sessionColdBoot.ts +133 -9
  46. package/src/i18n/locales/en-US.json +19 -2
  47. package/src/i18n/locales/es-ES.json +19 -2
  48. package/src/index.ts +75 -18
  49. package/src/mixins/OxyServices.auth.ts +67 -5
  50. package/src/mixins/__tests__/preSessionSkipAuth.test.ts +54 -1
  51. package/src/models/session.ts +11 -0
  52. package/src/session/SessionClient.ts +386 -1
  53. package/src/session/__tests__/SessionClient.directory.test.ts +688 -0
  54. package/src/session/__tests__/accountDialogController.test.ts +411 -278
  55. package/src/session/__tests__/accountSwitchTargets.test.ts +132 -0
  56. package/src/session/__tests__/deviceDirectory.test.ts +422 -0
  57. package/src/session/__tests__/deviceSwitcherRows.test.ts +223 -0
  58. package/src/session/__tests__/projectSessionState.test.ts +17 -0
  59. package/src/session/__tests__/sharedDeviceCredential.test.ts +300 -0
  60. package/src/session/accountDialogController.ts +141 -179
  61. package/src/session/accountSwitchTargets.ts +87 -0
  62. package/src/session/deviceDirectory.ts +269 -0
  63. package/src/session/deviceSwitcherRows.ts +145 -0
  64. package/src/session/projectSessionState.ts +9 -3
  65. package/src/session/sharedDeviceCredential.ts +349 -0
  66. package/dist/cjs/session/accountProjection.js +0 -213
  67. package/dist/esm/session/accountProjection.js +0 -207
  68. package/dist/types/session/accountProjection.d.ts +0 -198
  69. package/src/session/__tests__/accountProjection.test.ts +0 -447
  70. package/src/session/accountProjection.ts +0 -354
@@ -4,6 +4,7 @@ exports.SessionClient = void 0;
4
4
  const contracts_1 = require("@oxyhq/contracts");
5
5
  const logger_1 = require("../logger");
6
6
  const cacheKey_1 = require("../utils/cacheKey");
7
+ const deviceDirectory_1 = require("./deviceDirectory");
7
8
  const socketLoader_1 = require("./socketLoader");
8
9
  /**
9
10
  * Same-origin `BroadcastChannel` name for instant, network-free session-state
@@ -19,7 +20,20 @@ class SessionClient {
19
20
  this.host = host;
20
21
  this.options = options;
21
22
  this.state = null;
23
+ /**
24
+ * The device DIRECTORY (ADR 0002) — principals, the contexts each may act as,
25
+ * and which context is active. Held BESIDE `state` rather than replacing it:
26
+ * `state` is the flat compatibility projection every app renders until Phase 7
27
+ * moves it, and the two describe the same device at the same `revision`.
28
+ *
29
+ * `null` until something asks for it. A client that never calls
30
+ * {@link refreshDirectory} has no consumer for a directory and never pays for
31
+ * the round trip — which is also what keeps the whole account lane's request
32
+ * count unchanged.
33
+ */
34
+ this.directory = null;
22
35
  this.listeners = new Set();
36
+ this.directoryListeners = new Set();
23
37
  this.socket = null;
24
38
  this.tokenUnsub = null;
25
39
  this.started = false;
@@ -33,6 +47,25 @@ class SessionClient {
33
47
  getState() {
34
48
  return this.state;
35
49
  }
50
+ /**
51
+ * The device directory, or `null` when this client has never read one.
52
+ * Populated by {@link refreshDirectory} / {@link activateContext} and kept
53
+ * fresh from there on.
54
+ */
55
+ getDirectory() {
56
+ return this.directory;
57
+ }
58
+ /**
59
+ * The active `principal acting as account` pair, with the actor and the
60
+ * subject kept apart. `null` when no directory has been read, or when the
61
+ * device genuinely has no active context.
62
+ *
63
+ * This is the answer `getState()` cannot give: `activeAccountId` names the
64
+ * subject and says nothing about whose authentication is behind it.
65
+ */
66
+ getActiveContext() {
67
+ return (0, deviceDirectory_1.resolveActiveContext)(this.directory);
68
+ }
36
69
  /**
37
70
  * The account this client's bearer is pinned to, or `null` when it follows the
38
71
  * device's active account (the default). Resolvers are expected to be a plain
@@ -48,6 +81,19 @@ class SessionClient {
48
81
  this.listeners.delete(listener);
49
82
  };
50
83
  }
84
+ /**
85
+ * Subscribe to the directory half. Fires from the SAME {@link notify} as
86
+ * {@link subscribe}, so the flat state and the directory are never published
87
+ * at two different points of the ordering sequence — a directory subscriber
88
+ * and a state subscriber woken by one transition always see the same device
89
+ * revision under the same bearer.
90
+ */
91
+ subscribeDirectory(listener) {
92
+ this.directoryListeners.add(listener);
93
+ return () => {
94
+ this.directoryListeners.delete(listener);
95
+ };
96
+ }
51
97
  /**
52
98
  * Subscribe to a named server-pushed Socket.IO event (e.g. `civic:attested`).
53
99
  * Listeners survive reconnects and socket re-creation; the returned function
@@ -92,6 +138,14 @@ class SessionClient {
92
138
  logger_1.logger.error('[SessionClient] subscriber threw', error);
93
139
  }
94
140
  }
141
+ for (const listener of this.directoryListeners) {
142
+ try {
143
+ listener(this.directory);
144
+ }
145
+ catch (error) {
146
+ logger_1.logger.error('[SessionClient] directory subscriber threw', error);
147
+ }
148
+ }
95
149
  }
96
150
  /**
97
151
  * Validate + last-writer-wins by revision. Returns true if applied.
@@ -130,6 +184,13 @@ class SessionClient {
130
184
  }
131
185
  const previousState = this.state;
132
186
  this.state = next;
187
+ if (next.accounts.length === 0) {
188
+ // A device with nobody signed in has no principals either. The directory
189
+ // is only ever refreshed by a bearer-carrying read and a sign-out leaves
190
+ // no bearer, so `settleDirectory` below cannot correct it — without this,
191
+ // the switcher would go on rendering the people who used to be here.
192
+ this.directory = null;
193
+ }
133
194
  const pinnedAccountId = this.pinnedAccountId();
134
195
  // Plant the sync-supplied active token (it is for `next.activeAccountId`)
135
196
  // now — before the notify below — so the bearer matches the new active
@@ -159,7 +220,7 @@ class SessionClient {
159
220
  const needsMintBeforeNotify = transport != null &&
160
221
  next.accounts.length > 0 &&
161
222
  (activeAccountId === null || (0, cacheKey_1.computeIdentityTag)(this.host.getAccessToken()) !== activeAccountId);
162
- const finishApply = () => {
223
+ const publish = () => {
163
224
  this.notify();
164
225
  if (next.accounts.length === 0 && this.options.onUnauthenticated) {
165
226
  try {
@@ -170,6 +231,21 @@ class SessionClient {
170
231
  }
171
232
  }
172
233
  };
234
+ // The directory half of the same ordering invariant: when this client holds
235
+ // a directory and the flat state has just moved past it, re-read the
236
+ // directory BEFORE anyone is notified — otherwise a directory-rendering
237
+ // consumer observes the PREVIOUS subject under the new subject's bearer,
238
+ // which is the account-switch race mirrored. `settleDirectory` returns null
239
+ // (and this stays synchronous) for every client that never read a
240
+ // directory, i.e. the whole account lane.
241
+ const finishApply = () => {
242
+ const settling = this.settleDirectory(next);
243
+ if (settling === null) {
244
+ publish();
245
+ return;
246
+ }
247
+ void settling.then(publish);
248
+ };
173
249
  if (needsMintBeforeNotify) {
174
250
  void transport.ensureActiveToken(next).then(finishApply).catch((error) => {
175
251
  logger_1.logger.warn('[SessionClient] ensureActiveToken failed — reverting session state', { component: 'SessionClient' }, error);
@@ -211,6 +287,19 @@ class SessionClient {
211
287
  logger_1.logger.warn('[SessionClient] discarded invalid session sync', { component: 'SessionClient', issues, keys });
212
288
  return;
213
289
  }
290
+ this.commitSync(sync);
291
+ }
292
+ /**
293
+ * The apply + token-plant half of {@link applySync}, on an ALREADY-VALIDATED
294
+ * sync. Split out so the context-aware removal lane — whose response is a
295
+ * different wire shape and therefore a different parse — reuses this ordering
296
+ * verbatim instead of re-deriving it. Two implementations of
297
+ * "plant before notify" is two chances to get it wrong once.
298
+ *
299
+ * Returns whether `applyState` applied, so a caller holding a second half
300
+ * (the directory) can decide whether anything still needs publishing.
301
+ */
302
+ commitSync(sync) {
214
303
  // A `sync` is always the response to a direct REST call this client made
215
304
  // (bootstrap / switch / signOut / add) → a `request`-origin, authoritative
216
305
  // verdict. Hand the active token to `applyState`: in the applied path it is
@@ -233,21 +322,292 @@ class SessionClient {
233
322
  sync.activeToken.accessToken !== this.host.getAccessToken()) {
234
323
  this.host.setTokens(sync.activeToken.accessToken);
235
324
  }
325
+ return applied;
326
+ }
327
+ /**
328
+ * Validate + last-writer-wins, `deviceId`-SCOPED exactly as {@link applyState}
329
+ * is for the flat half: a directory belonging to a DIFFERENT device resets the
330
+ * baseline and is accepted at any revision, so a freshly-converged device
331
+ * cannot lose to a retired device's higher number.
332
+ *
333
+ * The comparison itself is deliberately WEAKER than the flat state's. There it
334
+ * is `revision <= current` — correct, because a `DeviceSessionState` arrives
335
+ * out of band over a socket, so a straggler can genuinely land after a newer
336
+ * one. A directory only ever arrives as the response to a request THIS client
337
+ * just made, so the newest response is the freshest answer and only a strictly
338
+ * LOWER revision can be a straggler (two GETs racing).
339
+ *
340
+ * Equal-revision reads are not redundant, and rejecting them was a bug: the
341
+ * directory includes rows projected from the account GRAPH, and the server
342
+ * materializes a context for every account a principal may act as WITHOUT
343
+ * bumping `revision` — deliberately, since `revision` tracks what the device
344
+ * holds and must never advance on a read. So a newly-granted `account:act_as`,
345
+ * and a removed-then-rematerialized context under its NEW id, both appear at
346
+ * an unchanged revision. Under `<=` neither would ever be seen until some
347
+ * unrelated device mutation happened to move the number.
348
+ *
349
+ * Notifies nothing. Every caller decides where in its own ordering sequence
350
+ * the publish belongs.
351
+ */
352
+ applyDirectory(raw) {
353
+ const next = (0, contracts_1.safeParseContract)(contracts_1.deviceDirectorySchema, raw);
354
+ if (!next) {
355
+ logger_1.logger.warn('[SessionClient] discarded invalid device directory');
356
+ return false;
357
+ }
358
+ if (this.directory &&
359
+ next.deviceId === this.directory.deviceId &&
360
+ next.revision < this.directory.revision) {
361
+ return false;
362
+ }
363
+ this.directory = next;
364
+ return true;
365
+ }
366
+ /** `GET /session/device/directory` → {@link applyDirectory}. No notify. */
367
+ async fetchDirectory() {
368
+ const res = await this.host.makeRequest('GET', '/session/device/directory', undefined, { cache: false });
369
+ return this.applyDirectory(res);
370
+ }
371
+ /**
372
+ * Re-read the directory when the flat state has moved past it, returning the
373
+ * in-flight work so the caller can hold its notify until both halves describe
374
+ * the same revision.
375
+ *
376
+ * `null` — meaning "nothing to settle, stay synchronous" — when this client
377
+ * holds no directory (nobody reads one), when the directory is already at or
378
+ * ahead of the state, or when there is no bearer to make the call with.
379
+ * Never rejects: a failed refresh leaves the previous directory in place and
380
+ * the next transition tries again; it must not swallow the flat state's
381
+ * notify.
382
+ */
383
+ settleDirectory(state) {
384
+ const held = this.directory;
385
+ if (held === null) {
386
+ return null;
387
+ }
388
+ if (held.deviceId === state.deviceId && held.revision >= state.revision) {
389
+ return null;
390
+ }
391
+ if (!this.host.getAccessToken()) {
392
+ return null;
393
+ }
394
+ return this.fetchDirectory().then(() => undefined, (error) => {
395
+ logger_1.logger.warn('[SessionClient] directory refresh failed', { component: 'SessionClient' }, error);
396
+ });
397
+ }
398
+ /**
399
+ * The highest device revision this client currently knows for `deviceId`,
400
+ * across BOTH halves, or `null` when it knows nothing about that device.
401
+ *
402
+ * Used to read the server's `changed` flag, which
403
+ * `POST /session/device/activate` deliberately does not carry: the revision
404
+ * already says whether the device moved, and a second field saying the same
405
+ * thing is a second field that can disagree with the first.
406
+ */
407
+ knownRevisionFor(deviceId) {
408
+ const fromDirectory = this.directory?.deviceId === deviceId ? this.directory.revision : null;
409
+ const fromState = this.state?.deviceId === deviceId ? this.state.revision : null;
410
+ if (fromDirectory === null)
411
+ return fromState;
412
+ if (fromState === null)
413
+ return fromDirectory;
414
+ return Math.max(fromDirectory, fromState);
415
+ }
416
+ /**
417
+ * Plant the bearer `POST /session/device/activate` returned for the newly
418
+ * active context, under the same guards {@link applyState} applies to a
419
+ * sync-supplied `activeToken`: never for a null active context, never a
420
+ * foreign account's token while pinned, and never a redundant re-plant of the
421
+ * token already held.
422
+ *
423
+ * `activeToken: null` is not an error — it is an identity-pinned client, or a
424
+ * caller whose application is not entitled to a bearer for the new context.
425
+ */
426
+ plantActiveContextToken(directory, accessToken) {
427
+ if (!accessToken) {
428
+ return;
429
+ }
430
+ const subjectAccountId = (0, deviceDirectory_1.resolveActiveContext)(directory)?.subject.accountId ?? null;
431
+ if (subjectAccountId === null) {
432
+ return;
433
+ }
434
+ const pinnedAccountId = this.pinnedAccountId();
435
+ if (pinnedAccountId !== null && subjectAccountId !== pinnedAccountId) {
436
+ return;
437
+ }
438
+ if (accessToken === this.host.getAccessToken()) {
439
+ return;
440
+ }
441
+ this.host.setTokens(accessToken);
442
+ }
443
+ /**
444
+ * Bring the FLAT projection back in step after a context activation.
445
+ *
446
+ * The activation response answers with the directory and a bearer and
447
+ * deliberately not with `DeviceSessionState` (ADR 0002) — but every app that
448
+ * has not moved to the directory still renders from `getState()`, and leaving
449
+ * it a revision behind would show the PREVIOUS subject under the new
450
+ * subject's bearer. So it is settled BEFORE the notify, not after.
451
+ *
452
+ * This is also what converges the bearer when the activation returned no
453
+ * token: `GET /session/device/state` mints one for the active account, and
454
+ * `applyState`'s own mint-before-notify gate holds its notify until it lands.
455
+ *
456
+ * Non-fatal on failure — the directory is applied and (usually) the bearer is
457
+ * planted; the socket push or the next bootstrap catches the flat half up. A
458
+ * network blip must not turn a completed activation into a thrown error.
459
+ */
460
+ async reconcileFlatState(directory) {
461
+ if (this.state &&
462
+ this.state.deviceId === directory.deviceId &&
463
+ this.state.revision >= directory.revision) {
464
+ return;
465
+ }
466
+ if (!this.host.getAccessToken()) {
467
+ return;
468
+ }
469
+ try {
470
+ await this.bootstrap();
471
+ }
472
+ catch (error) {
473
+ logger_1.logger.warn('[SessionClient] flat-state reconcile after activation failed', { component: 'SessionClient' }, error);
474
+ }
236
475
  }
237
476
  async bootstrap() {
238
477
  const res = await this.host.makeRequest('GET', '/session/device/state', undefined, { cache: false });
239
478
  this.applySync(res);
240
479
  }
480
+ /**
481
+ * Read `GET /session/device/directory` and publish it.
482
+ *
483
+ * Calling this is what opts a client into the directory: from here on every
484
+ * applied device state re-reads it (see {@link settleDirectory}), so the two
485
+ * halves stay at one revision without the caller polling.
486
+ */
487
+ async refreshDirectory() {
488
+ if (await this.fetchDirectory()) {
489
+ this.notify();
490
+ }
491
+ }
492
+ /**
493
+ * `POST /session/device/activate` — make one `principal acting as account`
494
+ * context active (ADR 0002).
495
+ *
496
+ * The body is `{ contextId }` and nothing else: an `accountId` cannot name
497
+ * what to activate on a device where two people can both reach the same
498
+ * organization, and the server refuses a body carrying one rather than
499
+ * guessing inside an authorization path.
500
+ *
501
+ * The sequence is the ADR's ordering invariant, in order — commit the bearer
502
+ * for the new context, publish the new snapshot, notify — with the flat
503
+ * projection reconciled in the middle so no consumer can observe the two
504
+ * halves disagreeing.
505
+ *
506
+ * An IDEMPOTENT activation (the target was already active) moves no revision,
507
+ * so it reconciles nothing and wakes no sibling tab, mirroring the server's
508
+ * "bumps nothing and broadcasts nothing". `switchAccount` remains the
509
+ * compatibility path for callers still keyed on account ids.
510
+ */
511
+ async activateContext(contextId) {
512
+ const res = await this.host.makeRequest('POST', '/session/device/activate', { contextId }, { cache: false });
513
+ const activation = (0, contracts_1.safeParseContract)(contracts_1.deviceActivateResponseSchema, res);
514
+ if (!activation) {
515
+ logger_1.logger.warn('[SessionClient] discarded invalid activation response');
516
+ return;
517
+ }
518
+ const known = this.knownRevisionFor(activation.directory.deviceId);
519
+ const moved = known === null || activation.directory.revision > known;
520
+ this.plantActiveContextToken(activation.directory, activation.activeToken?.accessToken);
521
+ // Applied BEFORE the reconcile, and only then: the reconcile's own
522
+ // `GET /session/device/state` runs the full apply path, whose
523
+ // `settleDirectory` would otherwise see a stale directory and issue a
524
+ // second, redundant `GET /session/device/directory` for the revision we are
525
+ // already holding in hand.
526
+ const applied = this.applyDirectory(activation.directory);
527
+ if (moved) {
528
+ await this.reconcileFlatState(activation.directory);
529
+ }
530
+ if (applied) {
531
+ this.notify();
532
+ }
533
+ if (moved) {
534
+ this.postCommitPing();
535
+ }
536
+ }
241
537
  async switchAccount(accountId) {
242
538
  const res = await this.host.makeRequest('POST', '/session/device/switch', { accountId }, { cache: false });
243
539
  this.applySync(res);
244
540
  this.postCommitPing();
245
541
  }
542
+ /**
543
+ * The FLAT removal meanings, unchanged: `{ accountId }` removes that account
544
+ * however it is reached — plus the operator cascade — and `{ all: true }`
545
+ * removes the whole device including its credentials.
546
+ *
547
+ * `{ accountId }` is deliberately still account-grained. On a device holding
548
+ * two people it removes BOTH of their routes to that account, which is the
549
+ * right meaning for "sign this account out of this device" and the wrong one
550
+ * for "this person is done here" — see {@link signOutContext} and
551
+ * {@link signOutPrincipal} for the two that can tell those apart.
552
+ */
246
553
  async signOut(target) {
247
554
  const res = await this.host.makeRequest('POST', '/session/device/signout', target, { cache: false });
248
555
  this.applySync(res);
249
556
  this.postCommitPing();
250
557
  }
558
+ /**
559
+ * Remove ONE `principal → account` pair, and only that pair.
560
+ *
561
+ * Never the account across the device: the same organization reached through
562
+ * a second person is a different session, a different audit actor and a
563
+ * different revocation path, and it stays. That distinction is unreachable
564
+ * through {@link signOut}, whose `accountId` cannot name which route to drop.
565
+ *
566
+ * Removal is not permanent while the membership lives — the server offers the
567
+ * pair again on the next directory read, as `onDevice: false` under a NEW id.
568
+ */
569
+ async signOutContext(contextId) {
570
+ await this.removeFromDevice({ contextId });
571
+ }
572
+ /**
573
+ * Remove ONE PERSON and every context they reach — and nobody else's,
574
+ * including when another principal independently operates the same account.
575
+ */
576
+ async signOutPrincipal(principalId) {
577
+ await this.removeFromDevice({ principalId });
578
+ }
579
+ /**
580
+ * The shared apply path for both context-aware removals.
581
+ *
582
+ * The response is `{directory, state, activeToken}` — its own contract, never
583
+ * `deviceSessionSyncSchema`, which would strip the directory silently. Both
584
+ * halves move in one server transition (a removal elects a replacement active
585
+ * context), so both are applied before anything is published: the directory
586
+ * first, so the flat apply's own `settleDirectory` sees a current directory
587
+ * and does not issue a redundant `GET /session/device/directory` for the
588
+ * revision already in hand.
589
+ *
590
+ * Token-before-notify is `commitSync`'s, reused verbatim rather than
591
+ * re-derived — including the equal-revision plant when a socket push already
592
+ * applied this revision.
593
+ */
594
+ async removeFromDevice(target) {
595
+ const res = await this.host.makeRequest('POST', '/session/device/signout', target, { cache: false });
596
+ const removal = (0, contracts_1.safeParseContract)(contracts_1.deviceDirectorySyncSchema, res);
597
+ if (!removal) {
598
+ logger_1.logger.warn('[SessionClient] discarded invalid device removal response');
599
+ return;
600
+ }
601
+ const directoryApplied = this.applyDirectory(removal.directory);
602
+ const stateApplied = this.commitSync({ state: removal.state, activeToken: removal.activeToken });
603
+ // `commitSync` publishes whenever the flat state moved. When only the
604
+ // directory did — a socket push already applied this revision — the
605
+ // directory half would otherwise never reach a subscriber.
606
+ if (directoryApplied && !stateApplied) {
607
+ this.notify();
608
+ }
609
+ this.postCommitPing();
610
+ }
251
611
  async addCurrentAccount() {
252
612
  const res = await this.host.makeRequest('POST', '/session/device/add', undefined, { cache: false });
253
613
  this.applySync(res);