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