@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,4 +1,5 @@
|
|
|
1
|
-
import { type DeviceSessionState } from '@oxyhq/contracts';
|
|
1
|
+
import { type DeviceDirectory, type DeviceSessionState } from '@oxyhq/contracts';
|
|
2
|
+
import { type DeviceContext } from './deviceDirectory';
|
|
2
3
|
import type { MinimalSocket, SocketIOFactory } from './socketLoader';
|
|
3
4
|
export interface TokenTransport {
|
|
4
5
|
/** Ensure this app holds a per-domain access token for state.activeAccountId (mint via the persisted refresh family / shared keychain). Best-effort. */
|
|
@@ -84,11 +85,25 @@ export interface SessionClientOptions {
|
|
|
84
85
|
getPinnedAccountId?: () => string | null;
|
|
85
86
|
}
|
|
86
87
|
type StateListener = (state: DeviceSessionState | null) => void;
|
|
88
|
+
type DirectoryListener = (directory: DeviceDirectory | null) => void;
|
|
87
89
|
export declare class SessionClient {
|
|
88
90
|
protected readonly host: SessionClientHost;
|
|
89
91
|
protected readonly options: SessionClientOptions;
|
|
90
92
|
private state;
|
|
93
|
+
/**
|
|
94
|
+
* The device DIRECTORY (ADR 0002) — principals, the contexts each may act as,
|
|
95
|
+
* and which context is active. Held BESIDE `state` rather than replacing it:
|
|
96
|
+
* `state` is the flat compatibility projection every app renders until Phase 7
|
|
97
|
+
* moves it, and the two describe the same device at the same `revision`.
|
|
98
|
+
*
|
|
99
|
+
* `null` until something asks for it. A client that never calls
|
|
100
|
+
* {@link refreshDirectory} has no consumer for a directory and never pays for
|
|
101
|
+
* the round trip — which is also what keeps the whole account lane's request
|
|
102
|
+
* count unchanged.
|
|
103
|
+
*/
|
|
104
|
+
private directory;
|
|
91
105
|
private readonly listeners;
|
|
106
|
+
private readonly directoryListeners;
|
|
92
107
|
protected socket: MinimalSocket | null;
|
|
93
108
|
private tokenUnsub;
|
|
94
109
|
private started;
|
|
@@ -100,6 +115,21 @@ export declare class SessionClient {
|
|
|
100
115
|
private readonly boundServerEvents;
|
|
101
116
|
constructor(host: SessionClientHost, options?: SessionClientOptions);
|
|
102
117
|
getState(): DeviceSessionState | null;
|
|
118
|
+
/**
|
|
119
|
+
* The device directory, or `null` when this client has never read one.
|
|
120
|
+
* Populated by {@link refreshDirectory} / {@link activateContext} and kept
|
|
121
|
+
* fresh from there on.
|
|
122
|
+
*/
|
|
123
|
+
getDirectory(): DeviceDirectory | null;
|
|
124
|
+
/**
|
|
125
|
+
* The active `principal acting as account` pair, with the actor and the
|
|
126
|
+
* subject kept apart. `null` when no directory has been read, or when the
|
|
127
|
+
* device genuinely has no active context.
|
|
128
|
+
*
|
|
129
|
+
* This is the answer `getState()` cannot give: `activeAccountId` names the
|
|
130
|
+
* subject and says nothing about whose authentication is behind it.
|
|
131
|
+
*/
|
|
132
|
+
getActiveContext(): DeviceContext | null;
|
|
103
133
|
/**
|
|
104
134
|
* The account this client's bearer is pinned to, or `null` when it follows the
|
|
105
135
|
* device's active account (the default). Resolvers are expected to be a plain
|
|
@@ -108,6 +138,14 @@ export declare class SessionClient {
|
|
|
108
138
|
*/
|
|
109
139
|
private pinnedAccountId;
|
|
110
140
|
subscribe(listener: StateListener): () => void;
|
|
141
|
+
/**
|
|
142
|
+
* Subscribe to the directory half. Fires from the SAME {@link notify} as
|
|
143
|
+
* {@link subscribe}, so the flat state and the directory are never published
|
|
144
|
+
* at two different points of the ordering sequence — a directory subscriber
|
|
145
|
+
* and a state subscriber woken by one transition always see the same device
|
|
146
|
+
* revision under the same bearer.
|
|
147
|
+
*/
|
|
148
|
+
subscribeDirectory(listener: DirectoryListener): () => void;
|
|
111
149
|
/**
|
|
112
150
|
* Subscribe to a named server-pushed Socket.IO event (e.g. `civic:attested`).
|
|
113
151
|
* Listeners survive reconnects and socket re-creation; the returned function
|
|
@@ -143,13 +181,176 @@ export declare class SessionClient {
|
|
|
143
181
|
* account that is no longer active.
|
|
144
182
|
*/
|
|
145
183
|
private applySync;
|
|
184
|
+
/**
|
|
185
|
+
* The apply + token-plant half of {@link applySync}, on an ALREADY-VALIDATED
|
|
186
|
+
* sync. Split out so the context-aware removal lane — whose response is a
|
|
187
|
+
* different wire shape and therefore a different parse — reuses this ordering
|
|
188
|
+
* verbatim instead of re-deriving it. Two implementations of
|
|
189
|
+
* "plant before notify" is two chances to get it wrong once.
|
|
190
|
+
*
|
|
191
|
+
* Returns whether `applyState` applied, so a caller holding a second half
|
|
192
|
+
* (the directory) can decide whether anything still needs publishing.
|
|
193
|
+
*/
|
|
194
|
+
private commitSync;
|
|
195
|
+
/**
|
|
196
|
+
* Validate + last-writer-wins, `deviceId`-SCOPED exactly as {@link applyState}
|
|
197
|
+
* is for the flat half: a directory belonging to a DIFFERENT device resets the
|
|
198
|
+
* baseline and is accepted at any revision, so a freshly-converged device
|
|
199
|
+
* cannot lose to a retired device's higher number.
|
|
200
|
+
*
|
|
201
|
+
* The comparison itself is deliberately WEAKER than the flat state's. There it
|
|
202
|
+
* is `revision <= current` — correct, because a `DeviceSessionState` arrives
|
|
203
|
+
* out of band over a socket, so a straggler can genuinely land after a newer
|
|
204
|
+
* one. A directory only ever arrives as the response to a request THIS client
|
|
205
|
+
* just made, so the newest response is the freshest answer and only a strictly
|
|
206
|
+
* LOWER revision can be a straggler (two GETs racing).
|
|
207
|
+
*
|
|
208
|
+
* Equal-revision reads are not redundant, and rejecting them was a bug: the
|
|
209
|
+
* directory includes rows projected from the account GRAPH, and the server
|
|
210
|
+
* materializes a context for every account a principal may act as WITHOUT
|
|
211
|
+
* bumping `revision` — deliberately, since `revision` tracks what the device
|
|
212
|
+
* holds and must never advance on a read. So a newly-granted `account:act_as`,
|
|
213
|
+
* and a removed-then-rematerialized context under its NEW id, both appear at
|
|
214
|
+
* an unchanged revision. Under `<=` neither would ever be seen until some
|
|
215
|
+
* unrelated device mutation happened to move the number.
|
|
216
|
+
*
|
|
217
|
+
* Notifies nothing. Every caller decides where in its own ordering sequence
|
|
218
|
+
* the publish belongs.
|
|
219
|
+
*/
|
|
220
|
+
private applyDirectory;
|
|
221
|
+
/** `GET /session/device/directory` → {@link applyDirectory}. No notify. */
|
|
222
|
+
private fetchDirectory;
|
|
223
|
+
/**
|
|
224
|
+
* Re-read the directory when the flat state has moved past it, returning the
|
|
225
|
+
* in-flight work so the caller can hold its notify until both halves describe
|
|
226
|
+
* the same revision.
|
|
227
|
+
*
|
|
228
|
+
* `null` — meaning "nothing to settle, stay synchronous" — when this client
|
|
229
|
+
* holds no directory (nobody reads one), when the directory is already at or
|
|
230
|
+
* ahead of the state, or when there is no bearer to make the call with.
|
|
231
|
+
* Never rejects: a failed refresh leaves the previous directory in place and
|
|
232
|
+
* the next transition tries again; it must not swallow the flat state's
|
|
233
|
+
* notify.
|
|
234
|
+
*/
|
|
235
|
+
private settleDirectory;
|
|
236
|
+
/**
|
|
237
|
+
* The highest device revision this client currently knows for `deviceId`,
|
|
238
|
+
* across BOTH halves, or `null` when it knows nothing about that device.
|
|
239
|
+
*
|
|
240
|
+
* Used to read the server's `changed` flag, which
|
|
241
|
+
* `POST /session/device/activate` deliberately does not carry: the revision
|
|
242
|
+
* already says whether the device moved, and a second field saying the same
|
|
243
|
+
* thing is a second field that can disagree with the first.
|
|
244
|
+
*/
|
|
245
|
+
private knownRevisionFor;
|
|
246
|
+
/**
|
|
247
|
+
* Plant the bearer `POST /session/device/activate` returned for the newly
|
|
248
|
+
* active context, under the same guards {@link applyState} applies to a
|
|
249
|
+
* sync-supplied `activeToken`: never for a null active context, never a
|
|
250
|
+
* foreign account's token while pinned, and never a redundant re-plant of the
|
|
251
|
+
* token already held.
|
|
252
|
+
*
|
|
253
|
+
* `activeToken: null` is not an error — it is an identity-pinned client, or a
|
|
254
|
+
* caller whose application is not entitled to a bearer for the new context.
|
|
255
|
+
*/
|
|
256
|
+
private plantActiveContextToken;
|
|
257
|
+
/**
|
|
258
|
+
* Bring the FLAT projection back in step after a context activation.
|
|
259
|
+
*
|
|
260
|
+
* The activation response answers with the directory and a bearer and
|
|
261
|
+
* deliberately not with `DeviceSessionState` (ADR 0002) — but every app that
|
|
262
|
+
* has not moved to the directory still renders from `getState()`, and leaving
|
|
263
|
+
* it a revision behind would show the PREVIOUS subject under the new
|
|
264
|
+
* subject's bearer. So it is settled BEFORE the notify, not after.
|
|
265
|
+
*
|
|
266
|
+
* This is also what converges the bearer when the activation returned no
|
|
267
|
+
* token: `GET /session/device/state` mints one for the active account, and
|
|
268
|
+
* `applyState`'s own mint-before-notify gate holds its notify until it lands.
|
|
269
|
+
*
|
|
270
|
+
* Non-fatal on failure — the directory is applied and (usually) the bearer is
|
|
271
|
+
* planted; the socket push or the next bootstrap catches the flat half up. A
|
|
272
|
+
* network blip must not turn a completed activation into a thrown error.
|
|
273
|
+
*/
|
|
274
|
+
private reconcileFlatState;
|
|
146
275
|
bootstrap(): Promise<void>;
|
|
276
|
+
/**
|
|
277
|
+
* Read `GET /session/device/directory` and publish it.
|
|
278
|
+
*
|
|
279
|
+
* Calling this is what opts a client into the directory: from here on every
|
|
280
|
+
* applied device state re-reads it (see {@link settleDirectory}), so the two
|
|
281
|
+
* halves stay at one revision without the caller polling.
|
|
282
|
+
*/
|
|
283
|
+
refreshDirectory(): Promise<void>;
|
|
284
|
+
/**
|
|
285
|
+
* `POST /session/device/activate` — make one `principal acting as account`
|
|
286
|
+
* context active (ADR 0002).
|
|
287
|
+
*
|
|
288
|
+
* The body is `{ contextId }` and nothing else: an `accountId` cannot name
|
|
289
|
+
* what to activate on a device where two people can both reach the same
|
|
290
|
+
* organization, and the server refuses a body carrying one rather than
|
|
291
|
+
* guessing inside an authorization path.
|
|
292
|
+
*
|
|
293
|
+
* The sequence is the ADR's ordering invariant, in order — commit the bearer
|
|
294
|
+
* for the new context, publish the new snapshot, notify — with the flat
|
|
295
|
+
* projection reconciled in the middle so no consumer can observe the two
|
|
296
|
+
* halves disagreeing.
|
|
297
|
+
*
|
|
298
|
+
* An IDEMPOTENT activation (the target was already active) moves no revision,
|
|
299
|
+
* so it reconciles nothing and wakes no sibling tab, mirroring the server's
|
|
300
|
+
* "bumps nothing and broadcasts nothing". `switchAccount` remains the
|
|
301
|
+
* compatibility path for callers still keyed on account ids.
|
|
302
|
+
*/
|
|
303
|
+
activateContext(contextId: string): Promise<void>;
|
|
147
304
|
switchAccount(accountId: string): Promise<void>;
|
|
305
|
+
/**
|
|
306
|
+
* The FLAT removal meanings, unchanged: `{ accountId }` removes that account
|
|
307
|
+
* however it is reached — plus the operator cascade — and `{ all: true }`
|
|
308
|
+
* removes the whole device including its credentials.
|
|
309
|
+
*
|
|
310
|
+
* `{ accountId }` is deliberately still account-grained. On a device holding
|
|
311
|
+
* two people it removes BOTH of their routes to that account, which is the
|
|
312
|
+
* right meaning for "sign this account out of this device" and the wrong one
|
|
313
|
+
* for "this person is done here" — see {@link signOutContext} and
|
|
314
|
+
* {@link signOutPrincipal} for the two that can tell those apart.
|
|
315
|
+
*/
|
|
148
316
|
signOut(target: {
|
|
149
317
|
accountId: string;
|
|
150
318
|
} | {
|
|
151
319
|
all: true;
|
|
152
320
|
}): Promise<void>;
|
|
321
|
+
/**
|
|
322
|
+
* Remove ONE `principal → account` pair, and only that pair.
|
|
323
|
+
*
|
|
324
|
+
* Never the account across the device: the same organization reached through
|
|
325
|
+
* a second person is a different session, a different audit actor and a
|
|
326
|
+
* different revocation path, and it stays. That distinction is unreachable
|
|
327
|
+
* through {@link signOut}, whose `accountId` cannot name which route to drop.
|
|
328
|
+
*
|
|
329
|
+
* Removal is not permanent while the membership lives — the server offers the
|
|
330
|
+
* pair again on the next directory read, as `onDevice: false` under a NEW id.
|
|
331
|
+
*/
|
|
332
|
+
signOutContext(contextId: string): Promise<void>;
|
|
333
|
+
/**
|
|
334
|
+
* Remove ONE PERSON and every context they reach — and nobody else's,
|
|
335
|
+
* including when another principal independently operates the same account.
|
|
336
|
+
*/
|
|
337
|
+
signOutPrincipal(principalId: string): Promise<void>;
|
|
338
|
+
/**
|
|
339
|
+
* The shared apply path for both context-aware removals.
|
|
340
|
+
*
|
|
341
|
+
* The response is `{directory, state, activeToken}` — its own contract, never
|
|
342
|
+
* `deviceSessionSyncSchema`, which would strip the directory silently. Both
|
|
343
|
+
* halves move in one server transition (a removal elects a replacement active
|
|
344
|
+
* context), so both are applied before anything is published: the directory
|
|
345
|
+
* first, so the flat apply's own `settleDirectory` sees a current directory
|
|
346
|
+
* and does not issue a redundant `GET /session/device/directory` for the
|
|
347
|
+
* revision already in hand.
|
|
348
|
+
*
|
|
349
|
+
* Token-before-notify is `commitSync`'s, reused verbatim rather than
|
|
350
|
+
* re-derived — including the equal-revision plant when a socket push already
|
|
351
|
+
* applied this revision.
|
|
352
|
+
*/
|
|
353
|
+
private removeFromDevice;
|
|
153
354
|
addCurrentAccount(): Promise<void>;
|
|
154
355
|
/**
|
|
155
356
|
* Register the just-signed-in account into the device set AND make it the
|
|
@@ -9,15 +9,16 @@
|
|
|
9
9
|
* replaces.
|
|
10
10
|
*
|
|
11
11
|
* The controller owns:
|
|
12
|
-
* - the
|
|
13
|
-
*
|
|
14
|
-
*
|
|
12
|
+
* - the device DIRECTORY (ADR 0002) — the server-authoritative read model of
|
|
13
|
+
* who is on this device and what each of them may act as, read through
|
|
14
|
+
* `SessionClient.refreshDirectory()`. It is not assembled here: the client
|
|
15
|
+
* holds one caller's account graph and cannot enumerate another principal's,
|
|
16
|
+
* so switchability is the server's answer and the controller only reads it;
|
|
15
17
|
* - the dialog `view` state machine (`accounts` | `signin` | `qr` | `add` |
|
|
16
18
|
* `signup`);
|
|
17
|
-
* - `
|
|
18
|
-
* account
|
|
19
|
-
*
|
|
20
|
-
* new switch path);
|
|
19
|
+
* - `activateContext` (the ADR 0002 switch, keyed on the `principal acting as
|
|
20
|
+
* account` pair) and the two removals an account id cannot name —
|
|
21
|
+
* `signOutContext` and `signOutPrincipal`;
|
|
21
22
|
* - the "Sign in with Oxy" device flow (same-device shared-keychain via
|
|
22
23
|
* `oxyServices.signInWithSharedIdentity`, else the cross-device QR handoff
|
|
23
24
|
* via `startCommonsSignIn` → poll → `claimSessionByToken`);
|
|
@@ -54,11 +55,12 @@
|
|
|
54
55
|
* `add` / `startSignup` (the view axis) and `cancelSignIn` (device-flow
|
|
55
56
|
* teardown), never an `open` / `close` / `visible`.
|
|
56
57
|
*/
|
|
58
|
+
import type { DeviceDirectory } from '@oxyhq/contracts';
|
|
57
59
|
import type { OxyServices } from '../OxyServices';
|
|
58
60
|
import type { SessionLoginResponse, MinimalUserData } from '../models/session';
|
|
59
61
|
import type { SessionClient } from './SessionClient';
|
|
60
62
|
import type { SocketIOFactory } from './socketLoader';
|
|
61
|
-
import { type
|
|
63
|
+
import { type DeviceContext } from './deviceDirectory';
|
|
62
64
|
import { type CommonsDeliveryPlatform, type CommonsDeliveryRoute } from '../utils/commonsDelivery';
|
|
63
65
|
/** The dialog's top-level view. */
|
|
64
66
|
export type AccountDialogView = 'accounts' | 'signin' | 'qr' | 'add' | 'signup';
|
|
@@ -192,16 +194,27 @@ export interface SignInFlowState {
|
|
|
192
194
|
export interface AccountDialogSnapshot {
|
|
193
195
|
/** The current view. */
|
|
194
196
|
view: AccountDialogView;
|
|
195
|
-
/**
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
197
|
+
/**
|
|
198
|
+
* The server-authoritative device directory — principals and the contexts
|
|
199
|
+
* each may act as (ADR 0002) — or `null` before the first read.
|
|
200
|
+
*
|
|
201
|
+
* The ONE read model a switcher renders. The flat list this replaced was
|
|
202
|
+
* keyed by account id, so on a device holding two people it could show one
|
|
203
|
+
* route to a shared organization and never both.
|
|
204
|
+
*/
|
|
205
|
+
directory: DeviceDirectory | null;
|
|
206
|
+
/** The active `principal acting as account` pair, actor and subject apart. */
|
|
207
|
+
activeContext: DeviceContext | null;
|
|
208
|
+
/** `true` while the first directory read is in flight with nothing to show. */
|
|
200
209
|
loading: boolean;
|
|
201
|
-
/** A human-readable
|
|
210
|
+
/** A human-readable directory error, or `null`. */
|
|
202
211
|
error: string | null;
|
|
203
|
-
/** The `
|
|
204
|
-
|
|
212
|
+
/** The `contextId` of an in-flight activation, or `null`. */
|
|
213
|
+
activatingContextId: string | null;
|
|
214
|
+
/** The `contextId` of an in-flight context removal, or `null`. */
|
|
215
|
+
removingContextId: string | null;
|
|
216
|
+
/** The `principalId` of an in-flight principal removal, or `null`. */
|
|
217
|
+
removingPrincipalId: string | null;
|
|
205
218
|
/** The "Sign in with Oxy" device-flow state. */
|
|
206
219
|
signIn: SignInFlowState;
|
|
207
220
|
/** Whether Commons is installed on this device. See {@link CommonsAvailability}. */
|
|
@@ -220,8 +233,6 @@ export interface AccountDialogControllerOptions {
|
|
|
220
233
|
* server would reject.
|
|
221
234
|
*/
|
|
222
235
|
clientId?: string | null;
|
|
223
|
-
/** Locale for display-name resolution. */
|
|
224
|
-
locale?: string;
|
|
225
236
|
/**
|
|
226
237
|
* Commit a freshly-authorized SIGN-IN session (device flow / shared identity)
|
|
227
238
|
* into the host's session set — device-first registration + durable persist +
|
|
@@ -231,24 +242,12 @@ export interface AccountDialogControllerOptions {
|
|
|
231
242
|
* `SessionClient.registerAndActivate` (registration + activation only — no
|
|
232
243
|
* provider-side durable persist/hydration).
|
|
233
244
|
*
|
|
234
|
-
*
|
|
235
|
-
*
|
|
236
|
-
*
|
|
245
|
+
* Sign-in is the only thing that commits a session here. An account SWITCH
|
|
246
|
+
* used to mint one too, on first entry into a graph account; activation mints
|
|
247
|
+
* the delegated session SERVER-side and hands back a bearer, so there is no
|
|
248
|
+
* second commit funnel to keep in step with this one.
|
|
237
249
|
*/
|
|
238
250
|
commitSession?: (session: SessionLoginResponse) => Promise<void>;
|
|
239
|
-
/**
|
|
240
|
-
* Commit a minted graph SWITCH session into the host's session set — same
|
|
241
|
-
* device-first registration + durable persist + profile hydration as
|
|
242
|
-
* {@link commitSession}, but IN-PLACE: it must NOT re-run sign-in side effects
|
|
243
|
-
* that belong only to a fresh authorization (for example, a redundant full
|
|
244
|
-
* device-set reconcile on switch). Cross-tab/app propagation of the switch
|
|
245
|
-
* still happens instantly via the server's device-scoped `session_state` /
|
|
246
|
-
* `session_accounts_changed` socket broadcast — no navigation required.
|
|
247
|
-
*
|
|
248
|
-
* When omitted the controller falls back to {@link commitSession} (if wired)
|
|
249
|
-
* and then to `SessionClient.registerAndActivate`.
|
|
250
|
-
*/
|
|
251
|
-
commitSwitchedSession?: (session: SessionLoginResponse) => Promise<void>;
|
|
252
251
|
/** Notified after a completed sign-in (bearer planted + session committed). */
|
|
253
252
|
onSignedIn?: (user: MinimalUserData) => void;
|
|
254
253
|
/**
|
|
@@ -313,9 +312,7 @@ export declare class AccountDialogController {
|
|
|
313
312
|
private readonly oxyServices;
|
|
314
313
|
private readonly sessionClient;
|
|
315
314
|
private readonly clientId;
|
|
316
|
-
private readonly locale?;
|
|
317
315
|
private readonly commitSession?;
|
|
318
|
-
private readonly commitSwitchedSession?;
|
|
319
316
|
private readonly onSignedIn?;
|
|
320
317
|
private readonly pollIntervalMs;
|
|
321
318
|
private readonly openUrl?;
|
|
@@ -326,11 +323,11 @@ export declare class AccountDialogController {
|
|
|
326
323
|
private readonly platform;
|
|
327
324
|
private readonly listeners;
|
|
328
325
|
private view;
|
|
329
|
-
private graph;
|
|
330
|
-
private profilesById;
|
|
331
326
|
private loading;
|
|
332
327
|
private error;
|
|
333
|
-
private
|
|
328
|
+
private activatingContextId;
|
|
329
|
+
private removingContextId;
|
|
330
|
+
private removingPrincipalId;
|
|
334
331
|
private signIn;
|
|
335
332
|
private commonsAvailability;
|
|
336
333
|
/** The secret device-flow token of the active QR flow (never surfaced). */
|
|
@@ -406,35 +403,54 @@ export declare class AccountDialogController {
|
|
|
406
403
|
/** Switch to the "create account" view (passkey / Commons signup entry). */
|
|
407
404
|
startSignup(): void;
|
|
408
405
|
/**
|
|
409
|
-
*
|
|
410
|
-
*
|
|
411
|
-
*
|
|
406
|
+
* Re-read `GET /session/device/directory`. Safe to call repeatedly;
|
|
407
|
+
* concurrent calls are reconciled by a sequence guard so a slow earlier read
|
|
408
|
+
* never overwrites a newer result.
|
|
409
|
+
*
|
|
410
|
+
* This is ONE request. It used to be three — the directory, plus
|
|
411
|
+
* `listAccounts()` and `getUsersByIds()` to rebuild the same tree client-side
|
|
412
|
+
* — and the reconstruction was not merely redundant: it enumerated the
|
|
413
|
+
* CALLER's account graph, which on a device holding two people is one
|
|
414
|
+
* person's answer presented as the device's.
|
|
412
415
|
*/
|
|
413
416
|
refresh(): Promise<void>;
|
|
414
417
|
/**
|
|
415
|
-
*
|
|
416
|
-
*
|
|
417
|
-
*
|
|
418
|
+
* Activate one `principal acting as account` context — the ADR 0002 switch,
|
|
419
|
+
* and the one that can express what an account id cannot: WHICH person's
|
|
420
|
+
* route to a shared organization to become.
|
|
421
|
+
*
|
|
422
|
+
* There is no on-device/graph fork. The directory has a row for a context the
|
|
423
|
+
* principal may act as but has never entered, and `POST /session/device/
|
|
424
|
+
* activate` reuses or mints the delegated session server-side, so one call
|
|
425
|
+
* covers both cases.
|
|
426
|
+
*
|
|
427
|
+
* A context id is not stable across a removal, so a stale one is an ordinary
|
|
428
|
+
* outcome rather than a bug: the server answers 404 or 403, heals the row, and
|
|
429
|
+
* the refresh below re-reads a directory that no longer offers it.
|
|
418
430
|
*/
|
|
419
|
-
|
|
420
|
-
private loadProfiles;
|
|
431
|
+
activateContext(contextId: string): Promise<boolean>;
|
|
421
432
|
/**
|
|
422
|
-
*
|
|
433
|
+
* Remove ONE `principal → account` pair, and only that pair.
|
|
434
|
+
*
|
|
435
|
+
* Not the account across the device: the same organization reached through a
|
|
436
|
+
* second person is a different session with a different audit actor, and it
|
|
437
|
+
* stays. Routing this through `signOut({accountId})` would revoke that second
|
|
438
|
+
* person's access as a side effect of one person tidying their own list.
|
|
423
439
|
*
|
|
424
|
-
*
|
|
425
|
-
*
|
|
426
|
-
*
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
*
|
|
431
|
-
*
|
|
440
|
+
* The removed pair is not gone for good while the membership lives — the
|
|
441
|
+
* server offers it again on the next read, under a NEW id and at an unchanged
|
|
442
|
+
* revision — so nothing may hold a context id across this call.
|
|
443
|
+
*/
|
|
444
|
+
signOutContext(contextId: string): Promise<boolean>;
|
|
445
|
+
/**
|
|
446
|
+
* Remove ONE PERSON and every context they reach — and nobody else's,
|
|
447
|
+
* including when another principal independently operates the same account.
|
|
432
448
|
*
|
|
433
|
-
*
|
|
434
|
-
*
|
|
435
|
-
*
|
|
449
|
+
* A separate call from {@link signOutContext} because it is a separate
|
|
450
|
+
* question, not a loop over the first one: the server removes the principal
|
|
451
|
+
* and elects a replacement active context in one transition.
|
|
436
452
|
*/
|
|
437
|
-
|
|
453
|
+
signOutPrincipal(principalId: string): Promise<boolean>;
|
|
438
454
|
/**
|
|
439
455
|
* Start "Sign in with Oxy". Native devices with a shared identity mint a
|
|
440
456
|
* session silently (`signInWithSharedIdentity`); everything else (web, or a
|
|
@@ -595,10 +611,6 @@ export declare class AccountDialogController {
|
|
|
595
611
|
* Register a token-planted session into the device set. Prefers the
|
|
596
612
|
* consumer's commit funnel (durable persist + hydration); falls back to
|
|
597
613
|
* `SessionClient.registerAndActivate` (registration + activation only).
|
|
598
|
-
*
|
|
599
|
-
* A SWITCH (`opts.fromSwitch`) uses the IN-PLACE `commitSwitchedSession` funnel;
|
|
600
|
-
* a SIGN-IN uses `commitSession`. When the switch funnel is not wired it falls
|
|
601
|
-
* back to the sign-in funnel, then to `registerAndActivate`.
|
|
602
614
|
*/
|
|
603
615
|
private commitAuthorizedSession;
|
|
604
616
|
private failSignIn;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two questions an account chooser asks of an account-graph node: is this
|
|
3
|
+
* kind switchable at all, and may THIS caller become it.
|
|
4
|
+
*
|
|
5
|
+
* Pure and I/O-free. They live here rather than beside the surfaces that ask
|
|
6
|
+
* them because there is more than one such surface — the Console's workspace
|
|
7
|
+
* tree, the Accounts app's managed-account rows — and a second enumeration of
|
|
8
|
+
* switch targets is a second place for the rule to go missing, which is
|
|
9
|
+
* precisely how the Console went on offering `channel` rows after the rule
|
|
10
|
+
* learned to drop them.
|
|
11
|
+
*
|
|
12
|
+
* The DEVICE switcher no longer asks anything here: it renders the server's
|
|
13
|
+
* device directory (ADR 0002, `deviceDirectory.ts`), whose `available` field is
|
|
14
|
+
* the server's own authorization verdict. These predicates answer a different
|
|
15
|
+
* question — one about the caller's account GRAPH, which is a list of accounts
|
|
16
|
+
* to manage, not a list of identities the device can become.
|
|
17
|
+
*/
|
|
18
|
+
import type { AccountRelationship, AccountKind, AccountMember } from '../mixins/OxyServices.accounts';
|
|
19
|
+
/**
|
|
20
|
+
* Whether the caller can BECOME this account — the one question every account
|
|
21
|
+
* switcher asks, answered here so no surface has to re-derive it.
|
|
22
|
+
*
|
|
23
|
+
* Two independent grounds, either of which suffices:
|
|
24
|
+
*
|
|
25
|
+
* - **It is already the caller's own identity** (`relationship: 'self'`).
|
|
26
|
+
* `GET /accounts` resolves its caller through `resolveOperatorId`, so `self`
|
|
27
|
+
* is the HUMAN operator's personal account even while they are operating an
|
|
28
|
+
* org — never the operated account. Kind is irrelevant on this ground: the
|
|
29
|
+
* caller IS that account, so returning to it asks the server for nothing.
|
|
30
|
+
* - **The server will mint a session for it** — `isActAsEligibleKind(kind)` is
|
|
31
|
+
* the exact predicate `POST /accounts/:id/switch` enforces, so a row offered
|
|
32
|
+
* on this ground is never a dead button.
|
|
33
|
+
*
|
|
34
|
+
* `isActAsEligibleKind` ALONE is not this question, and reaching for it
|
|
35
|
+
* directly is the mistake this function exists to prevent: it is false for
|
|
36
|
+
* `personal` as well as `channel`, so a switcher gated on it alone renders an
|
|
37
|
+
* empty list rather than a filtered one. Equally, `kind !== 'channel'` is not
|
|
38
|
+
* this question either — it silently admits every kind invented after it was
|
|
39
|
+
* written, which is the same trap `isActAsEligibleKind` was introduced to close
|
|
40
|
+
* on the server.
|
|
41
|
+
*
|
|
42
|
+
* Takes a structural subset rather than a whole {@link AccountNode} so a caller
|
|
43
|
+
* holding an already-projected row can ask it too.
|
|
44
|
+
*/
|
|
45
|
+
export declare function isSwitchTargetAccount(node: {
|
|
46
|
+
kind?: AccountKind | null;
|
|
47
|
+
relationship?: AccountRelationship;
|
|
48
|
+
}): boolean;
|
|
49
|
+
/**
|
|
50
|
+
* Whether the caller may switch INTO this account — the server-side
|
|
51
|
+
* `account:act_as` gate plus the structural {@link isSwitchTargetAccount} rule.
|
|
52
|
+
*
|
|
53
|
+
* `relationship: 'self'` always passes (returning to the caller's own personal
|
|
54
|
+
* account). Every other ground requires a switch-eligible kind AND
|
|
55
|
+
* `account:act_as` in the resolved membership permissions. When permissions are
|
|
56
|
+
* absent but the relationship is `owner`, the owner baseline is assumed — the
|
|
57
|
+
* API always resolves effective permissions for owned accounts, but test
|
|
58
|
+
* fixtures and stale rows may omit the membership blob.
|
|
59
|
+
*/
|
|
60
|
+
export declare function canSwitchIntoAccount(node: {
|
|
61
|
+
kind?: AccountKind | null;
|
|
62
|
+
relationship?: AccountRelationship;
|
|
63
|
+
callerMembership?: AccountMember | null;
|
|
64
|
+
}): boolean;
|