@oxyhq/core 20.0.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 (94) hide show
  1. package/NOTICE +10 -9
  2. package/dist/cjs/.tsbuildinfo +1 -1
  3. package/dist/cjs/boot/sessionColdBoot.js +107 -8
  4. package/dist/cjs/i18n/locales/en-US.json +19 -2
  5. package/dist/cjs/i18n/locales/es-ES.json +19 -2
  6. package/dist/cjs/i18n/locales/locales/en-US.json +19 -2
  7. package/dist/cjs/i18n/locales/locales/es-ES.json +19 -2
  8. package/dist/cjs/index.js +50 -16
  9. package/dist/cjs/mixins/OxyServices.auth.js +27 -3
  10. package/dist/cjs/mixins/OxyServices.chains.js +73 -0
  11. package/dist/cjs/mixins/OxyServices.store.js +266 -0
  12. package/dist/cjs/mixins/OxyServices.utility.js +159 -104
  13. package/dist/cjs/mixins/index.js +7 -0
  14. package/dist/cjs/server/rateLimit.js +15 -6
  15. package/dist/cjs/session/SessionClient.js +361 -1
  16. package/dist/cjs/session/accountDialogController.js +121 -147
  17. package/dist/cjs/session/accountSwitchTargets.js +75 -0
  18. package/dist/cjs/session/deviceDirectory.js +143 -0
  19. package/dist/cjs/session/deviceSwitcherRows.js +76 -0
  20. package/dist/cjs/session/projectSessionState.js +8 -1
  21. package/dist/cjs/session/sharedDeviceCredential.js +247 -0
  22. package/dist/esm/.tsbuildinfo +1 -1
  23. package/dist/esm/boot/sessionColdBoot.js +107 -8
  24. package/dist/esm/i18n/locales/en-US.json +19 -2
  25. package/dist/esm/i18n/locales/es-ES.json +19 -2
  26. package/dist/esm/i18n/locales/locales/en-US.json +19 -2
  27. package/dist/esm/i18n/locales/locales/es-ES.json +19 -2
  28. package/dist/esm/index.js +32 -10
  29. package/dist/esm/mixins/OxyServices.auth.js +27 -3
  30. package/dist/esm/mixins/OxyServices.chains.js +70 -0
  31. package/dist/esm/mixins/OxyServices.store.js +263 -0
  32. package/dist/esm/mixins/OxyServices.utility.js +159 -104
  33. package/dist/esm/mixins/index.js +7 -0
  34. package/dist/esm/server/rateLimit.js +15 -6
  35. package/dist/esm/session/SessionClient.js +362 -2
  36. package/dist/esm/session/accountDialogController.js +121 -147
  37. package/dist/esm/session/accountSwitchTargets.js +71 -0
  38. package/dist/esm/session/deviceDirectory.js +135 -0
  39. package/dist/esm/session/deviceSwitcherRows.js +72 -0
  40. package/dist/esm/session/projectSessionState.js +8 -2
  41. package/dist/esm/session/sharedDeviceCredential.js +239 -0
  42. package/dist/types/.tsbuildinfo +1 -1
  43. package/dist/types/boot/sessionColdBoot.d.ts +24 -4
  44. package/dist/types/index.d.ts +15 -3
  45. package/dist/types/mixins/OxyServices.auth.d.ts +75 -3
  46. package/dist/types/mixins/OxyServices.chains.d.ts +156 -0
  47. package/dist/types/mixins/OxyServices.store.d.ts +334 -0
  48. package/dist/types/mixins/OxyServices.utility.d.ts +31 -8
  49. package/dist/types/mixins/index.d.ts +3 -1
  50. package/dist/types/models/session.d.ts +11 -0
  51. package/dist/types/session/SessionClient.d.ts +202 -1
  52. package/dist/types/session/accountDialogController.d.ts +76 -64
  53. package/dist/types/session/accountSwitchTargets.d.ts +64 -0
  54. package/dist/types/session/deviceDirectory.d.ts +182 -0
  55. package/dist/types/session/deviceSwitcherRows.d.ts +92 -0
  56. package/dist/types/session/projectSessionState.d.ts +29 -0
  57. package/dist/types/session/sharedDeviceCredential.d.ts +202 -0
  58. package/package.json +3 -3
  59. package/src/boot/__tests__/sessionColdBoot.sharedDevice.test.ts +325 -0
  60. package/src/boot/sessionColdBoot.ts +133 -9
  61. package/src/i18n/locales/en-US.json +19 -2
  62. package/src/i18n/locales/es-ES.json +19 -2
  63. package/src/index.ts +105 -18
  64. package/src/mixins/OxyServices.auth.ts +67 -5
  65. package/src/mixins/OxyServices.chains.ts +134 -0
  66. package/src/mixins/OxyServices.store.ts +585 -0
  67. package/src/mixins/OxyServices.utility.ts +161 -108
  68. package/src/mixins/__tests__/chains.test.ts +113 -0
  69. package/src/mixins/__tests__/preSessionSkipAuth.test.ts +54 -1
  70. package/src/mixins/__tests__/store.test.ts +304 -0
  71. package/src/mixins/__tests__/userTokenAuth.test.ts +746 -0
  72. package/src/mixins/index.ts +9 -0
  73. package/src/models/session.ts +11 -0
  74. package/src/server/__tests__/rateLimit.test.ts +47 -0
  75. package/src/server/rateLimit.ts +18 -8
  76. package/src/session/SessionClient.ts +386 -1
  77. package/src/session/__tests__/SessionClient.directory.test.ts +688 -0
  78. package/src/session/__tests__/accountDialogController.test.ts +411 -278
  79. package/src/session/__tests__/accountSwitchTargets.test.ts +132 -0
  80. package/src/session/__tests__/deviceDirectory.test.ts +422 -0
  81. package/src/session/__tests__/deviceSwitcherRows.test.ts +223 -0
  82. package/src/session/__tests__/projectSessionState.test.ts +17 -0
  83. package/src/session/__tests__/sharedDeviceCredential.test.ts +300 -0
  84. package/src/session/accountDialogController.ts +141 -179
  85. package/src/session/accountSwitchTargets.ts +87 -0
  86. package/src/session/deviceDirectory.ts +269 -0
  87. package/src/session/deviceSwitcherRows.ts +145 -0
  88. package/src/session/projectSessionState.ts +9 -3
  89. package/src/session/sharedDeviceCredential.ts +349 -0
  90. package/dist/cjs/session/accountProjection.js +0 -213
  91. package/dist/esm/session/accountProjection.js +0 -207
  92. package/dist/types/session/accountProjection.d.ts +0 -198
  93. package/src/session/__tests__/accountProjection.test.ts +0 -447
  94. package/src/session/accountProjection.ts +0 -354
@@ -17,6 +17,7 @@ import { OxyServicesReputationMixin } from './OxyServices.reputation';
17
17
  import { OxyServicesAssetsMixin } from './OxyServices.assets';
18
18
  import { OxyServicesAccountsMixin } from './OxyServices.accounts';
19
19
  import { OxyServicesConnectedAppsMixin } from './OxyServices.connectedApps';
20
+ import { OxyServicesStoreMixin } from './OxyServices.store';
20
21
  import { OxyServicesLocationMixin } from './OxyServices.location';
21
22
  import { OxyServicesAnalyticsMixin } from './OxyServices.analytics';
22
23
  import { OxyServicesDevicesMixin } from './OxyServices.devices';
@@ -28,6 +29,7 @@ import { OxyServicesContactsMixin } from './OxyServices.contacts';
28
29
  import { OxyServicesNotificationsMixin } from './OxyServices.notifications';
29
30
  import { OxyServicesAppDataMixin } from './OxyServices.appData';
30
31
  import { OxyServicesCivicMixin } from './OxyServices.civic';
32
+ import { OxyServicesChainsMixin } from './OxyServices.chains';
31
33
  import { OxyServicesNodesMixin } from './OxyServices.nodes';
32
34
  import { OxyServicesLinksMixin } from './OxyServices.links';
33
35
  import { OxyServicesFollowGraphMixin } from './OxyServices.followGraph';
@@ -55,6 +57,7 @@ type AllMixinInstances =
55
57
  & InstanceType<ReturnType<typeof OxyServicesAssetsMixin<typeof OxyServicesBase>>>
56
58
  & InstanceType<ReturnType<typeof OxyServicesAccountsMixin<typeof OxyServicesBase>>>
57
59
  & InstanceType<ReturnType<typeof OxyServicesConnectedAppsMixin<typeof OxyServicesBase>>>
60
+ & InstanceType<ReturnType<typeof OxyServicesStoreMixin<typeof OxyServicesBase>>>
58
61
  & InstanceType<ReturnType<typeof OxyServicesLocationMixin<typeof OxyServicesBase>>>
59
62
  & InstanceType<ReturnType<typeof OxyServicesAnalyticsMixin<typeof OxyServicesBase>>>
60
63
  & InstanceType<ReturnType<typeof OxyServicesDevicesMixin<typeof OxyServicesBase>>>
@@ -65,6 +68,7 @@ type AllMixinInstances =
65
68
  & InstanceType<ReturnType<typeof OxyServicesNotificationsMixin<typeof OxyServicesBase>>>
66
69
  & InstanceType<ReturnType<typeof OxyServicesAppDataMixin<typeof OxyServicesBase>>>
67
70
  & InstanceType<ReturnType<typeof OxyServicesCivicMixin<typeof OxyServicesBase>>>
71
+ & InstanceType<ReturnType<typeof OxyServicesChainsMixin<typeof OxyServicesBase>>>
68
72
  & InstanceType<ReturnType<typeof OxyServicesNodesMixin<typeof OxyServicesBase>>>
69
73
  & InstanceType<ReturnType<typeof OxyServicesLinksMixin<typeof OxyServicesBase>>>
70
74
  & InstanceType<ReturnType<typeof OxyServicesFollowGraphMixin<typeof OxyServicesBase>>>
@@ -123,6 +127,10 @@ const MIXIN_PIPELINE: MixinFunction[] = [
123
127
  // OAuth-consent surface (public app identity + connected-app grants). Kept
124
128
  // separate from account ownership.
125
129
  OxyServicesConnectedAppsMixin,
130
+ // The app store: the public storefront, the reviews on it, and the listing a
131
+ // publisher edits. A module OVER the platform — turn it off and OAuth still
132
+ // works — so it is its own surface rather than more of `accounts`.
133
+ OxyServicesStoreMixin,
126
134
  OxyServicesLocationMixin,
127
135
  OxyServicesAnalyticsMixin,
128
136
  OxyServicesDevicesMixin,
@@ -137,6 +145,7 @@ const MIXIN_PIPELINE: MixinFunction[] = [
137
145
  OxyServicesAppDataMixin,
138
146
  // Civic / Commons "Oxy ID" (public signed cards, Oxy ID QR payload)
139
147
  OxyServicesCivicMixin,
148
+ OxyServicesChainsMixin,
140
149
  // User nodes / decentralization (Fase 5): register/read/revoke/manage the
141
150
  // caller's personal data node + ingest hint.
142
151
  OxyServicesNodesMixin,
@@ -14,6 +14,17 @@ export interface ClientSession {
14
14
  * account-chooser ordering, not for any token-refresh mechanism.
15
15
  */
16
16
  authuser?: number;
17
+ /**
18
+ * The HUMAN operating this account, when it is a delegated session — the
19
+ * audit actor behind "The Oxy Collective". Absent when the session belongs to
20
+ * the account itself.
21
+ *
22
+ * The flat wire shape has carried it since the multi-account model shipped and
23
+ * nothing read it, so an operated org rendered exactly like a directly
24
+ * signed-in one. `SessionClient.getActiveContext()` is the richer answer
25
+ * (ADR 0002); this is the same fact on the compatibility lane.
26
+ */
27
+ operatedByUserId?: string;
17
28
  }
18
29
 
19
30
  export interface StorageKeys {
@@ -125,6 +125,53 @@ describe('@oxyhq/core/server rate limiter', () => {
125
125
  expect(req.observedKey).toBe('user:validated-user');
126
126
  });
127
127
 
128
+ it('does not clobber an identity a preceding middleware already resolved', () => {
129
+ // The limiter mutates the SHARED `req`, so the unconditional
130
+ // `req.userId = null` that `oxy.auth({ optional: true })` writes for every
131
+ // request it cannot authenticate is not merely a bucketing detail — it
132
+ // erases the identity for every handler downstream of the limiter too.
133
+ // The resolver must therefore skip entirely when a user is already present.
134
+ // This handler stands in for that erasure.
135
+ const clobberingAuth = jest.fn(
136
+ (req: RateLimitTestRequest, _res: Response, next: NextFunction) => {
137
+ req.userId = null;
138
+ req.user = null;
139
+ req.sessionId = null;
140
+ next();
141
+ },
142
+ );
143
+ const oxy = makeOxy(clobberingAuth as unknown as RequestHandler);
144
+ const req = makeRequest({
145
+ userId: 'resolved-by-the-app',
146
+ user: { id: 'resolved-by-the-app' },
147
+ sessionId: 'app-session',
148
+ });
149
+
150
+ createOxyRateLimit(oxy)(req, {} as Response, jest.fn());
151
+
152
+ expect(req.userId).toBe('resolved-by-the-app');
153
+ expect(req.user).toEqual({ id: 'resolved-by-the-app' });
154
+ expect(req.sessionId).toBe('app-session');
155
+ expect(req.observedKey).toBe('user:resolved-by-the-app');
156
+ expect(clobberingAuth).not.toHaveBeenCalled();
157
+ });
158
+
159
+ it('still resolves the session when no identity is present yet', () => {
160
+ const authHandler = jest.fn((req: RateLimitTestRequest, _res: Response, next: NextFunction) => {
161
+ req.userId = 'resolved-by-oxy';
162
+ req.user = { id: 'resolved-by-oxy' };
163
+ req.sessionId = 'oxy-session';
164
+ next();
165
+ });
166
+ const oxy = makeOxy(authHandler as unknown as RequestHandler);
167
+ const req = makeRequest();
168
+
169
+ createOxyRateLimit(oxy)(req, {} as Response, jest.fn());
170
+
171
+ expect(authHandler).toHaveBeenCalledTimes(1);
172
+ expect(req.observedKey).toBe('user:resolved-by-oxy');
173
+ });
174
+
128
175
  it('continues through the anonymous limiter if optional auth returns an error', () => {
129
176
  const oxy = makeOxy((_req: Request, _res: Response, next: NextFunction) => {
130
177
  next(new Error('token rejected'));
@@ -3,6 +3,7 @@ import { isIPv4, isIPv6 } from 'node:net';
3
3
  import type { Request, RequestHandler } from 'express';
4
4
  import rateLimit, { type Store } from 'express-rate-limit';
5
5
  import type { OxyServices } from '../OxyServices';
6
+ import { createOptionalOxyAuth } from './auth';
6
7
 
7
8
  /**
8
9
  * Server-only rate limiting for Oxy backends.
@@ -25,8 +26,9 @@ import type { OxyServices } from '../OxyServices';
25
26
  * WHAT IT PROVIDES
26
27
  * ----------------
27
28
  * `createOxyRateLimit(oxy, options)` returns a SINGLE composed middleware that:
28
- * 1. Resolves the user via `oxy.auth({ optional: true })` (idempotent — it
29
- * skips re-verification if a prior middleware already set `req.user`).
29
+ * 1. Resolves the user via `createOptionalOxyAuth` (idempotent — it skips
30
+ * resolution entirely if a prior middleware already resolved a user, so
31
+ * the limiter can never erase an identity it did not create).
30
32
  * 2. Applies an `express-rate-limit` limiter keyed PER USER when
31
33
  * authenticated, falling back to the (IPv6-safe) IP otherwise, with
32
34
  * generous, media-app-realistic defaults and sensible exemptions.
@@ -203,11 +205,12 @@ function hashAnonymousIp(ip: string): string {
203
205
  /**
204
206
  * Resolve the trusted authenticated rate-limit key.
205
207
  *
206
- * `oxy.auth({ optional: true })` preserves legacy non-session user tokens by
207
- * decoding their JWT claims locally. Those claims are not cryptographically
208
- * verified and therefore MUST NOT influence abuse-control buckets. Only use
209
- * identities that came from a server-validated session or a verified service
210
- * token/delegation.
208
+ * Only identities that came from a server-validated session or a verified
209
+ * service token/delegation may pick a bucket. `req.sessionId` is the marker
210
+ * for the former: `oxy.auth()` sets it only after `validateSession()` came
211
+ * back valid, so requiring it here means an identity written by some OTHER
212
+ * middleware — which this package cannot vouch for — shares the anonymous
213
+ * per-IP bucket rather than getting the authenticated quota.
211
214
  */
212
215
  function resolveTrustedAuthenticatedKey(req: OxyAuthedRequest): string | null {
213
216
  const userId = req.userId ?? req.user?.id ?? req.user?._id;
@@ -260,7 +263,14 @@ export function createOxyRateLimit(
260
263
 
261
264
  // Idempotent optional-auth resolver. Reuses the SAME session resolution as
262
265
  // every protected route, so the limiter keys by the real user identity.
263
- const resolveSession = oxy.auth({ ...auth, optional: true });
266
+ //
267
+ // `createOptionalOxyAuth` — NOT the raw `oxy.auth({ optional: true })` —
268
+ // because only the former skips resolution when a preceding middleware has
269
+ // already resolved a user. The raw middleware writes `req.userId = null` on
270
+ // every request it cannot authenticate, and because it mutates the shared
271
+ // `req` that erasure is visible to every handler downstream of the limiter,
272
+ // not just to the bucket calculation.
273
+ const resolveSession = createOptionalOxyAuth(oxy, { auth });
264
274
 
265
275
  const skip = (req: Request): boolean =>
266
276
  isBuiltInExempt(req) || (exempt ? exempt(req) : false);
@@ -1,13 +1,19 @@
1
1
  import {
2
+ deviceActivateResponseSchema,
3
+ deviceDirectorySchema,
4
+ deviceDirectorySyncSchema,
2
5
  deviceSessionStateSchema,
3
6
  deviceSessionSyncSchema,
4
7
  safeParseContract,
5
8
  SESSION_ACCOUNTS_CHANGED_EVENT,
6
9
  sessionAccountsChangedEventSchema,
10
+ type DeviceDirectory,
7
11
  type DeviceSessionState,
12
+ type DeviceSessionSync,
8
13
  } from '@oxyhq/contracts';
9
14
  import { logger } from '../logger';
10
15
  import { computeIdentityTag } from '../utils/cacheKey';
16
+ import { resolveActiveContext, type DeviceContext } from './deviceDirectory';
11
17
  import { getSocketIO } from './socketLoader';
12
18
  import type { MinimalSocket, SocketIOFactory } from './socketLoader';
13
19
 
@@ -98,6 +104,7 @@ export interface SessionClientOptions {
98
104
  }
99
105
 
100
106
  type StateListener = (state: DeviceSessionState | null) => void;
107
+ type DirectoryListener = (directory: DeviceDirectory | null) => void;
101
108
 
102
109
  /**
103
110
  * Same-origin `BroadcastChannel` name for instant, network-free session-state
@@ -118,7 +125,20 @@ interface SessionBroadcastChannel {
118
125
 
119
126
  export class SessionClient {
120
127
  private state: DeviceSessionState | null = null;
128
+ /**
129
+ * The device DIRECTORY (ADR 0002) — principals, the contexts each may act as,
130
+ * and which context is active. Held BESIDE `state` rather than replacing it:
131
+ * `state` is the flat compatibility projection every app renders until Phase 7
132
+ * moves it, and the two describe the same device at the same `revision`.
133
+ *
134
+ * `null` until something asks for it. A client that never calls
135
+ * {@link refreshDirectory} has no consumer for a directory and never pays for
136
+ * the round trip — which is also what keeps the whole account lane's request
137
+ * count unchanged.
138
+ */
139
+ private directory: DeviceDirectory | null = null;
121
140
  private readonly listeners = new Set<StateListener>();
141
+ private readonly directoryListeners = new Set<DirectoryListener>();
122
142
  protected socket: MinimalSocket | null = null;
123
143
  private tokenUnsub: (() => void) | null = null;
124
144
  private started = false;
@@ -138,6 +158,27 @@ export class SessionClient {
138
158
  return this.state;
139
159
  }
140
160
 
161
+ /**
162
+ * The device directory, or `null` when this client has never read one.
163
+ * Populated by {@link refreshDirectory} / {@link activateContext} and kept
164
+ * fresh from there on.
165
+ */
166
+ getDirectory(): DeviceDirectory | null {
167
+ return this.directory;
168
+ }
169
+
170
+ /**
171
+ * The active `principal acting as account` pair, with the actor and the
172
+ * subject kept apart. `null` when no directory has been read, or when the
173
+ * device genuinely has no active context.
174
+ *
175
+ * This is the answer `getState()` cannot give: `activeAccountId` names the
176
+ * subject and says nothing about whose authentication is behind it.
177
+ */
178
+ getActiveContext(): DeviceContext | null {
179
+ return resolveActiveContext(this.directory);
180
+ }
181
+
141
182
  /**
142
183
  * The account this client's bearer is pinned to, or `null` when it follows the
143
184
  * device's active account (the default). Resolvers are expected to be a plain
@@ -155,6 +196,20 @@ export class SessionClient {
155
196
  };
156
197
  }
157
198
 
199
+ /**
200
+ * Subscribe to the directory half. Fires from the SAME {@link notify} as
201
+ * {@link subscribe}, so the flat state and the directory are never published
202
+ * at two different points of the ordering sequence — a directory subscriber
203
+ * and a state subscriber woken by one transition always see the same device
204
+ * revision under the same bearer.
205
+ */
206
+ subscribeDirectory(listener: DirectoryListener): () => void {
207
+ this.directoryListeners.add(listener);
208
+ return () => {
209
+ this.directoryListeners.delete(listener);
210
+ };
211
+ }
212
+
158
213
  /**
159
214
  * Subscribe to a named server-pushed Socket.IO event (e.g. `civic:attested`).
160
215
  * Listeners survive reconnects and socket re-creation; the returned function
@@ -197,6 +252,13 @@ export class SessionClient {
197
252
  logger.error('[SessionClient] subscriber threw', error);
198
253
  }
199
254
  }
255
+ for (const listener of this.directoryListeners) {
256
+ try {
257
+ listener(this.directory);
258
+ } catch (error) {
259
+ logger.error('[SessionClient] directory subscriber threw', error);
260
+ }
261
+ }
200
262
  }
201
263
 
202
264
  /**
@@ -238,6 +300,13 @@ export class SessionClient {
238
300
  }
239
301
  const previousState = this.state;
240
302
  this.state = next;
303
+ if (next.accounts.length === 0) {
304
+ // A device with nobody signed in has no principals either. The directory
305
+ // is only ever refreshed by a bearer-carrying read and a sign-out leaves
306
+ // no bearer, so `settleDirectory` below cannot correct it — without this,
307
+ // the switcher would go on rendering the people who used to be here.
308
+ this.directory = null;
309
+ }
241
310
  const pinnedAccountId = this.pinnedAccountId();
242
311
  // Plant the sync-supplied active token (it is for `next.activeAccountId`)
243
312
  // now — before the notify below — so the bearer matches the new active
@@ -271,7 +340,7 @@ export class SessionClient {
271
340
  next.accounts.length > 0 &&
272
341
  (activeAccountId === null || computeIdentityTag(this.host.getAccessToken()) !== activeAccountId);
273
342
 
274
- const finishApply = (): void => {
343
+ const publish = (): void => {
275
344
  this.notify();
276
345
  if (next.accounts.length === 0 && this.options.onUnauthenticated) {
277
346
  try {
@@ -282,6 +351,22 @@ export class SessionClient {
282
351
  }
283
352
  };
284
353
 
354
+ // The directory half of the same ordering invariant: when this client holds
355
+ // a directory and the flat state has just moved past it, re-read the
356
+ // directory BEFORE anyone is notified — otherwise a directory-rendering
357
+ // consumer observes the PREVIOUS subject under the new subject's bearer,
358
+ // which is the account-switch race mirrored. `settleDirectory` returns null
359
+ // (and this stays synchronous) for every client that never read a
360
+ // directory, i.e. the whole account lane.
361
+ const finishApply = (): void => {
362
+ const settling = this.settleDirectory(next);
363
+ if (settling === null) {
364
+ publish();
365
+ return;
366
+ }
367
+ void settling.then(publish);
368
+ };
369
+
285
370
  if (needsMintBeforeNotify) {
286
371
  void transport.ensureActiveToken(next).then(finishApply).catch((error) => {
287
372
  logger.warn('[SessionClient] ensureActiveToken failed — reverting session state', { component: 'SessionClient' }, error);
@@ -325,6 +410,20 @@ export class SessionClient {
325
410
  logger.warn('[SessionClient] discarded invalid session sync', { component: 'SessionClient', issues, keys });
326
411
  return;
327
412
  }
413
+ this.commitSync(sync);
414
+ }
415
+
416
+ /**
417
+ * The apply + token-plant half of {@link applySync}, on an ALREADY-VALIDATED
418
+ * sync. Split out so the context-aware removal lane — whose response is a
419
+ * different wire shape and therefore a different parse — reuses this ordering
420
+ * verbatim instead of re-deriving it. Two implementations of
421
+ * "plant before notify" is two chances to get it wrong once.
422
+ *
423
+ * Returns whether `applyState` applied, so a caller holding a second half
424
+ * (the directory) can decide whether anything still needs publishing.
425
+ */
426
+ private commitSync(sync: DeviceSessionSync): boolean {
328
427
  // A `sync` is always the response to a direct REST call this client made
329
428
  // (bootstrap / switch / signOut / add) → a `request`-origin, authoritative
330
429
  // verdict. Hand the active token to `applyState`: in the applied path it is
@@ -349,6 +448,166 @@ export class SessionClient {
349
448
  ) {
350
449
  this.host.setTokens(sync.activeToken.accessToken);
351
450
  }
451
+ return applied;
452
+ }
453
+
454
+ /**
455
+ * Validate + last-writer-wins, `deviceId`-SCOPED exactly as {@link applyState}
456
+ * is for the flat half: a directory belonging to a DIFFERENT device resets the
457
+ * baseline and is accepted at any revision, so a freshly-converged device
458
+ * cannot lose to a retired device's higher number.
459
+ *
460
+ * The comparison itself is deliberately WEAKER than the flat state's. There it
461
+ * is `revision <= current` — correct, because a `DeviceSessionState` arrives
462
+ * out of band over a socket, so a straggler can genuinely land after a newer
463
+ * one. A directory only ever arrives as the response to a request THIS client
464
+ * just made, so the newest response is the freshest answer and only a strictly
465
+ * LOWER revision can be a straggler (two GETs racing).
466
+ *
467
+ * Equal-revision reads are not redundant, and rejecting them was a bug: the
468
+ * directory includes rows projected from the account GRAPH, and the server
469
+ * materializes a context for every account a principal may act as WITHOUT
470
+ * bumping `revision` — deliberately, since `revision` tracks what the device
471
+ * holds and must never advance on a read. So a newly-granted `account:act_as`,
472
+ * and a removed-then-rematerialized context under its NEW id, both appear at
473
+ * an unchanged revision. Under `<=` neither would ever be seen until some
474
+ * unrelated device mutation happened to move the number.
475
+ *
476
+ * Notifies nothing. Every caller decides where in its own ordering sequence
477
+ * the publish belongs.
478
+ */
479
+ private applyDirectory(raw: unknown): boolean {
480
+ const next = safeParseContract(deviceDirectorySchema, raw);
481
+ if (!next) {
482
+ logger.warn('[SessionClient] discarded invalid device directory');
483
+ return false;
484
+ }
485
+ if (
486
+ this.directory &&
487
+ next.deviceId === this.directory.deviceId &&
488
+ next.revision < this.directory.revision
489
+ ) {
490
+ return false;
491
+ }
492
+ this.directory = next;
493
+ return true;
494
+ }
495
+
496
+ /** `GET /session/device/directory` → {@link applyDirectory}. No notify. */
497
+ private async fetchDirectory(): Promise<boolean> {
498
+ const res = await this.host.makeRequest<unknown>('GET', '/session/device/directory', undefined, { cache: false });
499
+ return this.applyDirectory(res);
500
+ }
501
+
502
+ /**
503
+ * Re-read the directory when the flat state has moved past it, returning the
504
+ * in-flight work so the caller can hold its notify until both halves describe
505
+ * the same revision.
506
+ *
507
+ * `null` — meaning "nothing to settle, stay synchronous" — when this client
508
+ * holds no directory (nobody reads one), when the directory is already at or
509
+ * ahead of the state, or when there is no bearer to make the call with.
510
+ * Never rejects: a failed refresh leaves the previous directory in place and
511
+ * the next transition tries again; it must not swallow the flat state's
512
+ * notify.
513
+ */
514
+ private settleDirectory(state: DeviceSessionState): Promise<void> | null {
515
+ const held = this.directory;
516
+ if (held === null) {
517
+ return null;
518
+ }
519
+ if (held.deviceId === state.deviceId && held.revision >= state.revision) {
520
+ return null;
521
+ }
522
+ if (!this.host.getAccessToken()) {
523
+ return null;
524
+ }
525
+ return this.fetchDirectory().then(
526
+ () => undefined,
527
+ (error: unknown) => {
528
+ logger.warn('[SessionClient] directory refresh failed', { component: 'SessionClient' }, error);
529
+ },
530
+ );
531
+ }
532
+
533
+ /**
534
+ * The highest device revision this client currently knows for `deviceId`,
535
+ * across BOTH halves, or `null` when it knows nothing about that device.
536
+ *
537
+ * Used to read the server's `changed` flag, which
538
+ * `POST /session/device/activate` deliberately does not carry: the revision
539
+ * already says whether the device moved, and a second field saying the same
540
+ * thing is a second field that can disagree with the first.
541
+ */
542
+ private knownRevisionFor(deviceId: string): number | null {
543
+ const fromDirectory = this.directory?.deviceId === deviceId ? this.directory.revision : null;
544
+ const fromState = this.state?.deviceId === deviceId ? this.state.revision : null;
545
+ if (fromDirectory === null) return fromState;
546
+ if (fromState === null) return fromDirectory;
547
+ return Math.max(fromDirectory, fromState);
548
+ }
549
+
550
+ /**
551
+ * Plant the bearer `POST /session/device/activate` returned for the newly
552
+ * active context, under the same guards {@link applyState} applies to a
553
+ * sync-supplied `activeToken`: never for a null active context, never a
554
+ * foreign account's token while pinned, and never a redundant re-plant of the
555
+ * token already held.
556
+ *
557
+ * `activeToken: null` is not an error — it is an identity-pinned client, or a
558
+ * caller whose application is not entitled to a bearer for the new context.
559
+ */
560
+ private plantActiveContextToken(directory: DeviceDirectory, accessToken: string | undefined): void {
561
+ if (!accessToken) {
562
+ return;
563
+ }
564
+ const subjectAccountId = resolveActiveContext(directory)?.subject.accountId ?? null;
565
+ if (subjectAccountId === null) {
566
+ return;
567
+ }
568
+ const pinnedAccountId = this.pinnedAccountId();
569
+ if (pinnedAccountId !== null && subjectAccountId !== pinnedAccountId) {
570
+ return;
571
+ }
572
+ if (accessToken === this.host.getAccessToken()) {
573
+ return;
574
+ }
575
+ this.host.setTokens(accessToken);
576
+ }
577
+
578
+ /**
579
+ * Bring the FLAT projection back in step after a context activation.
580
+ *
581
+ * The activation response answers with the directory and a bearer and
582
+ * deliberately not with `DeviceSessionState` (ADR 0002) — but every app that
583
+ * has not moved to the directory still renders from `getState()`, and leaving
584
+ * it a revision behind would show the PREVIOUS subject under the new
585
+ * subject's bearer. So it is settled BEFORE the notify, not after.
586
+ *
587
+ * This is also what converges the bearer when the activation returned no
588
+ * token: `GET /session/device/state` mints one for the active account, and
589
+ * `applyState`'s own mint-before-notify gate holds its notify until it lands.
590
+ *
591
+ * Non-fatal on failure — the directory is applied and (usually) the bearer is
592
+ * planted; the socket push or the next bootstrap catches the flat half up. A
593
+ * network blip must not turn a completed activation into a thrown error.
594
+ */
595
+ private async reconcileFlatState(directory: DeviceDirectory): Promise<void> {
596
+ if (
597
+ this.state &&
598
+ this.state.deviceId === directory.deviceId &&
599
+ this.state.revision >= directory.revision
600
+ ) {
601
+ return;
602
+ }
603
+ if (!this.host.getAccessToken()) {
604
+ return;
605
+ }
606
+ try {
607
+ await this.bootstrap();
608
+ } catch (error) {
609
+ logger.warn('[SessionClient] flat-state reconcile after activation failed', { component: 'SessionClient' }, error);
610
+ }
352
611
  }
353
612
 
354
613
  async bootstrap(): Promise<void> {
@@ -356,18 +615,144 @@ export class SessionClient {
356
615
  this.applySync(res);
357
616
  }
358
617
 
618
+ /**
619
+ * Read `GET /session/device/directory` and publish it.
620
+ *
621
+ * Calling this is what opts a client into the directory: from here on every
622
+ * applied device state re-reads it (see {@link settleDirectory}), so the two
623
+ * halves stay at one revision without the caller polling.
624
+ */
625
+ async refreshDirectory(): Promise<void> {
626
+ if (await this.fetchDirectory()) {
627
+ this.notify();
628
+ }
629
+ }
630
+
631
+ /**
632
+ * `POST /session/device/activate` — make one `principal acting as account`
633
+ * context active (ADR 0002).
634
+ *
635
+ * The body is `{ contextId }` and nothing else: an `accountId` cannot name
636
+ * what to activate on a device where two people can both reach the same
637
+ * organization, and the server refuses a body carrying one rather than
638
+ * guessing inside an authorization path.
639
+ *
640
+ * The sequence is the ADR's ordering invariant, in order — commit the bearer
641
+ * for the new context, publish the new snapshot, notify — with the flat
642
+ * projection reconciled in the middle so no consumer can observe the two
643
+ * halves disagreeing.
644
+ *
645
+ * An IDEMPOTENT activation (the target was already active) moves no revision,
646
+ * so it reconciles nothing and wakes no sibling tab, mirroring the server's
647
+ * "bumps nothing and broadcasts nothing". `switchAccount` remains the
648
+ * compatibility path for callers still keyed on account ids.
649
+ */
650
+ async activateContext(contextId: string): Promise<void> {
651
+ const res = await this.host.makeRequest<unknown>('POST', '/session/device/activate', { contextId }, { cache: false });
652
+ const activation = safeParseContract(deviceActivateResponseSchema, res);
653
+ if (!activation) {
654
+ logger.warn('[SessionClient] discarded invalid activation response');
655
+ return;
656
+ }
657
+ const known = this.knownRevisionFor(activation.directory.deviceId);
658
+ const moved = known === null || activation.directory.revision > known;
659
+ this.plantActiveContextToken(activation.directory, activation.activeToken?.accessToken);
660
+ // Applied BEFORE the reconcile, and only then: the reconcile's own
661
+ // `GET /session/device/state` runs the full apply path, whose
662
+ // `settleDirectory` would otherwise see a stale directory and issue a
663
+ // second, redundant `GET /session/device/directory` for the revision we are
664
+ // already holding in hand.
665
+ const applied = this.applyDirectory(activation.directory);
666
+ if (moved) {
667
+ await this.reconcileFlatState(activation.directory);
668
+ }
669
+ if (applied) {
670
+ this.notify();
671
+ }
672
+ if (moved) {
673
+ this.postCommitPing();
674
+ }
675
+ }
676
+
359
677
  async switchAccount(accountId: string): Promise<void> {
360
678
  const res = await this.host.makeRequest<unknown>('POST', '/session/device/switch', { accountId }, { cache: false });
361
679
  this.applySync(res);
362
680
  this.postCommitPing();
363
681
  }
364
682
 
683
+ /**
684
+ * The FLAT removal meanings, unchanged: `{ accountId }` removes that account
685
+ * however it is reached — plus the operator cascade — and `{ all: true }`
686
+ * removes the whole device including its credentials.
687
+ *
688
+ * `{ accountId }` is deliberately still account-grained. On a device holding
689
+ * two people it removes BOTH of their routes to that account, which is the
690
+ * right meaning for "sign this account out of this device" and the wrong one
691
+ * for "this person is done here" — see {@link signOutContext} and
692
+ * {@link signOutPrincipal} for the two that can tell those apart.
693
+ */
365
694
  async signOut(target: { accountId: string } | { all: true }): Promise<void> {
366
695
  const res = await this.host.makeRequest<unknown>('POST', '/session/device/signout', target, { cache: false });
367
696
  this.applySync(res);
368
697
  this.postCommitPing();
369
698
  }
370
699
 
700
+ /**
701
+ * Remove ONE `principal → account` pair, and only that pair.
702
+ *
703
+ * Never the account across the device: the same organization reached through
704
+ * a second person is a different session, a different audit actor and a
705
+ * different revocation path, and it stays. That distinction is unreachable
706
+ * through {@link signOut}, whose `accountId` cannot name which route to drop.
707
+ *
708
+ * Removal is not permanent while the membership lives — the server offers the
709
+ * pair again on the next directory read, as `onDevice: false` under a NEW id.
710
+ */
711
+ async signOutContext(contextId: string): Promise<void> {
712
+ await this.removeFromDevice({ contextId });
713
+ }
714
+
715
+ /**
716
+ * Remove ONE PERSON and every context they reach — and nobody else's,
717
+ * including when another principal independently operates the same account.
718
+ */
719
+ async signOutPrincipal(principalId: string): Promise<void> {
720
+ await this.removeFromDevice({ principalId });
721
+ }
722
+
723
+ /**
724
+ * The shared apply path for both context-aware removals.
725
+ *
726
+ * The response is `{directory, state, activeToken}` — its own contract, never
727
+ * `deviceSessionSyncSchema`, which would strip the directory silently. Both
728
+ * halves move in one server transition (a removal elects a replacement active
729
+ * context), so both are applied before anything is published: the directory
730
+ * first, so the flat apply's own `settleDirectory` sees a current directory
731
+ * and does not issue a redundant `GET /session/device/directory` for the
732
+ * revision already in hand.
733
+ *
734
+ * Token-before-notify is `commitSync`'s, reused verbatim rather than
735
+ * re-derived — including the equal-revision plant when a socket push already
736
+ * applied this revision.
737
+ */
738
+ private async removeFromDevice(target: { contextId: string } | { principalId: string }): Promise<void> {
739
+ const res = await this.host.makeRequest<unknown>('POST', '/session/device/signout', target, { cache: false });
740
+ const removal = safeParseContract(deviceDirectorySyncSchema, res);
741
+ if (!removal) {
742
+ logger.warn('[SessionClient] discarded invalid device removal response');
743
+ return;
744
+ }
745
+ const directoryApplied = this.applyDirectory(removal.directory);
746
+ const stateApplied = this.commitSync({ state: removal.state, activeToken: removal.activeToken });
747
+ // `commitSync` publishes whenever the flat state moved. When only the
748
+ // directory did — a socket push already applied this revision — the
749
+ // directory half would otherwise never reach a subscriber.
750
+ if (directoryApplied && !stateApplied) {
751
+ this.notify();
752
+ }
753
+ this.postCommitPing();
754
+ }
755
+
371
756
  async addCurrentAccount(): Promise<void> {
372
757
  const res = await this.host.makeRequest<unknown>('POST', '/session/device/add', undefined, { cache: false });
373
758
  this.applySync(res);