@oxyhq/core 20.1.0 → 21.0.1

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 (109) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/HttpService.js +47 -8
  3. package/dist/cjs/boot/sessionColdBoot.js +107 -8
  4. package/dist/cjs/i18n/locales/en-US.json +26 -4
  5. package/dist/cjs/i18n/locales/es-ES.json +26 -4
  6. package/dist/cjs/i18n/locales/locales/en-US.json +26 -4
  7. package/dist/cjs/i18n/locales/locales/es-ES.json +26 -4
  8. package/dist/cjs/index.js +57 -16
  9. package/dist/cjs/inference/OxyInferenceClient.js +330 -0
  10. package/dist/cjs/mixins/OxyServices.accounts.js +5 -72
  11. package/dist/cjs/mixins/OxyServices.auth.js +27 -3
  12. package/dist/cjs/mixins/OxyServices.inference.js +59 -0
  13. package/dist/cjs/mixins/OxyServices.utility.js +18 -6
  14. package/dist/cjs/mixins/index.js +6 -0
  15. package/dist/cjs/server/auth.js +76 -0
  16. package/dist/cjs/server/index.js +5 -1
  17. package/dist/cjs/session/SessionClient.js +361 -1
  18. package/dist/cjs/session/accountDialogController.js +121 -147
  19. package/dist/cjs/session/accountSwitchTargets.js +75 -0
  20. package/dist/cjs/session/deviceDirectory.js +143 -0
  21. package/dist/cjs/session/deviceSwitcherRows.js +76 -0
  22. package/dist/cjs/session/projectSessionState.js +8 -1
  23. package/dist/cjs/session/sharedDeviceCredential.js +247 -0
  24. package/dist/esm/.tsbuildinfo +1 -1
  25. package/dist/esm/HttpService.js +47 -8
  26. package/dist/esm/boot/sessionColdBoot.js +107 -8
  27. package/dist/esm/i18n/locales/en-US.json +26 -4
  28. package/dist/esm/i18n/locales/es-ES.json +26 -4
  29. package/dist/esm/i18n/locales/locales/en-US.json +26 -4
  30. package/dist/esm/i18n/locales/locales/es-ES.json +26 -4
  31. package/dist/esm/index.js +36 -10
  32. package/dist/esm/inference/OxyInferenceClient.js +325 -0
  33. package/dist/esm/mixins/OxyServices.accounts.js +5 -72
  34. package/dist/esm/mixins/OxyServices.auth.js +27 -3
  35. package/dist/esm/mixins/OxyServices.inference.js +56 -0
  36. package/dist/esm/mixins/OxyServices.utility.js +18 -6
  37. package/dist/esm/mixins/index.js +6 -0
  38. package/dist/esm/server/auth.js +72 -0
  39. package/dist/esm/server/index.js +1 -1
  40. package/dist/esm/session/SessionClient.js +362 -2
  41. package/dist/esm/session/accountDialogController.js +121 -147
  42. package/dist/esm/session/accountSwitchTargets.js +71 -0
  43. package/dist/esm/session/deviceDirectory.js +135 -0
  44. package/dist/esm/session/deviceSwitcherRows.js +72 -0
  45. package/dist/esm/session/projectSessionState.js +8 -2
  46. package/dist/esm/session/sharedDeviceCredential.js +239 -0
  47. package/dist/types/.tsbuildinfo +1 -1
  48. package/dist/types/HttpService.d.ts +39 -1
  49. package/dist/types/boot/sessionColdBoot.d.ts +24 -4
  50. package/dist/types/index.d.ts +11 -4
  51. package/dist/types/inference/OxyInferenceClient.d.ts +324 -0
  52. package/dist/types/mixins/OxyServices.accounts.d.ts +73 -95
  53. package/dist/types/mixins/OxyServices.auth.d.ts +75 -3
  54. package/dist/types/mixins/OxyServices.inference.d.ts +95 -0
  55. package/dist/types/mixins/OxyServices.utility.d.ts +44 -13
  56. package/dist/types/mixins/index.d.ts +2 -1
  57. package/dist/types/models/session.d.ts +11 -0
  58. package/dist/types/server/auth.d.ts +80 -0
  59. package/dist/types/server/index.d.ts +2 -2
  60. package/dist/types/session/SessionClient.d.ts +202 -1
  61. package/dist/types/session/accountDialogController.d.ts +76 -64
  62. package/dist/types/session/accountSwitchTargets.d.ts +64 -0
  63. package/dist/types/session/deviceDirectory.d.ts +182 -0
  64. package/dist/types/session/deviceSwitcherRows.d.ts +92 -0
  65. package/dist/types/session/projectSessionState.d.ts +29 -0
  66. package/dist/types/session/sharedDeviceCredential.d.ts +202 -0
  67. package/package.json +3 -3
  68. package/src/HttpService.ts +50 -10
  69. package/src/__tests__/httpServiceUnwrapEnvelope.test.ts +115 -0
  70. package/src/boot/__tests__/sessionColdBoot.sharedDevice.test.ts +325 -0
  71. package/src/boot/sessionColdBoot.ts +133 -9
  72. package/src/i18n/locales/en-US.json +26 -4
  73. package/src/i18n/locales/es-ES.json +26 -4
  74. package/src/index.ts +94 -25
  75. package/src/inference/OxyInferenceClient.ts +590 -0
  76. package/src/inference/__tests__/OxyInferenceClient.test.ts +383 -0
  77. package/src/mixins/OxyServices.accounts.ts +75 -176
  78. package/src/mixins/OxyServices.auth.ts +67 -5
  79. package/src/mixins/OxyServices.inference.ts +57 -0
  80. package/src/mixins/OxyServices.utility.ts +58 -14
  81. package/src/mixins/__tests__/accounts.test.ts +57 -102
  82. package/src/mixins/__tests__/inferenceFactory.test.ts +58 -0
  83. package/src/mixins/__tests__/preSessionSkipAuth.test.ts +54 -1
  84. package/src/mixins/__tests__/serviceAuth.test.ts +2 -0
  85. package/src/mixins/index.ts +8 -0
  86. package/src/models/session.ts +11 -0
  87. package/src/server/__tests__/serviceTokenAttribution.test.ts +396 -0
  88. package/src/server/auth.ts +118 -0
  89. package/src/server/index.ts +6 -0
  90. package/src/session/SessionClient.ts +386 -1
  91. package/src/session/__tests__/SessionClient.directory.test.ts +688 -0
  92. package/src/session/__tests__/accountDialogController.test.ts +411 -278
  93. package/src/session/__tests__/accountDialogShape.test.ts +118 -0
  94. package/src/session/__tests__/accountSwitchTargets.test.ts +132 -0
  95. package/src/session/__tests__/deviceDirectory.test.ts +422 -0
  96. package/src/session/__tests__/deviceSwitcherRows.test.ts +223 -0
  97. package/src/session/__tests__/projectSessionState.test.ts +17 -0
  98. package/src/session/__tests__/sharedDeviceCredential.test.ts +300 -0
  99. package/src/session/accountDialogController.ts +141 -179
  100. package/src/session/accountSwitchTargets.ts +87 -0
  101. package/src/session/deviceDirectory.ts +269 -0
  102. package/src/session/deviceSwitcherRows.ts +145 -0
  103. package/src/session/projectSessionState.ts +9 -3
  104. package/src/session/sharedDeviceCredential.ts +349 -0
  105. package/dist/cjs/session/accountProjection.js +0 -213
  106. package/dist/esm/session/accountProjection.js +0 -207
  107. package/dist/types/session/accountProjection.d.ts +0 -198
  108. package/src/session/__tests__/accountProjection.test.ts +0 -447
  109. package/src/session/accountProjection.ts +0 -354
@@ -16,6 +16,12 @@ export interface OxyServiceAppContext {
16
16
  appName: string;
17
17
  scopes: string[];
18
18
  credentialId: string;
19
+ /**
20
+ * The Oxy account that owns `appId` and is financially responsible for it.
21
+ * Read off the VERIFIED service-token claim set — never a user id, and never
22
+ * the delegated `X-Oxy-User-Id` (ADR 0007).
23
+ */
24
+ ownerAccountId: string;
19
25
  environment: OxyServiceEnvironment;
20
26
  }
21
27
  export interface OxyServiceActingAsContext {
@@ -44,6 +50,80 @@ export interface OxyAuthMiddlewareOptions {
44
50
  }
45
51
  export declare function getOxyUserId(req: Request): string | null;
46
52
  export declare function isOxyAuthenticated(req: Request): req is OxyAuthenticatedRequest;
53
+ /**
54
+ * The principal a request is CHARGED to, and the identifiers a receipt needs.
55
+ *
56
+ * Every field is read from the verified service-token claim set. It is an
57
+ * OBJECT, not a string, and that is the point: `getOxyUserId` returns a
58
+ * `string | null`, so a delegated end-user id cannot be passed anywhere an
59
+ * `OxyBillingPrincipal` is expected. The confusion ADR 0007 forbids —
60
+ * attributing spend to the person a service is acting for rather than to the
61
+ * service's own account — stops being a code-review question and becomes a
62
+ * compile error.
63
+ *
64
+ * `scopes` are the effective scopes minted into the token (credential ∩
65
+ * application). Nothing re-intersects them here.
66
+ */
67
+ export interface OxyBillingPrincipal {
68
+ /** `applications.owner_account_id` — the financially responsible account. */
69
+ readonly accountId: string;
70
+ readonly applicationId: string;
71
+ readonly credentialId: string;
72
+ readonly environment: OxyServiceEnvironment;
73
+ readonly scopes: readonly string[];
74
+ }
75
+ /**
76
+ * The full canonical attribution of ADR 0007 for a request: the billing
77
+ * principal PLUS the optional delegated end user.
78
+ *
79
+ * `delegatedUserId` is named for what it is. It answers "on whose behalf" and
80
+ * is absent for a machine credential acting for itself — its absence is normal,
81
+ * and nothing may synthesize one. If removing it would change what any account
82
+ * is charged, the code reading it is wrong.
83
+ */
84
+ export interface OxyRequestAttribution extends OxyBillingPrincipal {
85
+ readonly delegatedUserId: string | null;
86
+ }
87
+ /**
88
+ * The billing principal of a request, or `null` when the request carries no
89
+ * verified service principal (an ordinary user session is not a billable
90
+ * machine principal — its account is resolved from the account graph, not from
91
+ * a token claim).
92
+ *
93
+ * Reads `req.serviceApp` and NOTHING else: not `req.userId`, not `req.user`,
94
+ * not `req.serviceActingAs`. That exclusivity is the invariant this function
95
+ * exists to hold, and `serviceTokenAttribution.test.ts` mutation-tests it.
96
+ *
97
+ * **It answers for the SERVICE-TOKEN lane only.** The API's machine-credential
98
+ * lane (`oxy_sk_*`, issue #972 §2.3) resolves the same five facts into its own
99
+ * `req.machineCredential`, deliberately never `req.serviceApp` — populating the
100
+ * latter would hand a self-serve third-party credential the lane that only
101
+ * platform-trusted applications may enter. So a machine-credential request has
102
+ * no billing principal HERE and resolves `null`, which fails closed: the caller
103
+ * must handle it, and `getRequiredOxyBillingPrincipal` throws rather than
104
+ * charging anyone. One accessor answering for both lanes belongs to the public
105
+ * inference edge that has to admit both, and it needs the machine principal's
106
+ * shape to move into this package first.
107
+ */
108
+ export declare function getOxyBillingPrincipal(req: Request): OxyBillingPrincipal | null;
109
+ /**
110
+ * {@link getOxyBillingPrincipal}, throwing when the request has none. Use on
111
+ * routes that have already required a service token.
112
+ */
113
+ export declare function getRequiredOxyBillingPrincipal(req: Request): OxyBillingPrincipal;
114
+ /**
115
+ * The delegated end user of a service request, or `null`.
116
+ *
117
+ * Deliberately reads `req.serviceActingAs` — the grant-verified delegation —
118
+ * and not `req.userId`, which on a non-service request is the caller's own
119
+ * session identity and is not a delegation at all.
120
+ */
121
+ export declare function getOxyDelegatedUserId(req: Request): string | null;
122
+ /**
123
+ * The whole attribution tuple for a service request: who pays, which
124
+ * application and credential, and optionally on whose behalf.
125
+ */
126
+ export declare function getOxyRequestAttribution(req: Request): OxyRequestAttribution | null;
47
127
  export declare function getRequiredOxyUserId(req: Request): string;
48
128
  export declare function requireOxyAuth(req: Request, res: Response, next: NextFunction): void;
49
129
  export declare function createOptionalOxyAuth(oxy: OxyServices, options?: OxyAuthMiddlewareOptions): RequestHandler;
@@ -14,8 +14,8 @@
14
14
  * app.use(createOxyRateLimit(oxy, { store: redisStore }));
15
15
  * ```
16
16
  */
17
- export { createOptionalOxyAuth, createOxyAuthMiddleware, getOxyUserId, getRequiredOxyUserId, isOxyAuthenticated, requireOxyAuth, OXY_SERVICE_ENVIRONMENTS, } from './auth';
18
- export type { OxyAuthenticatedRequest, OxyAuthMiddlewareOptions, OxyAuthRequest, OxyRequestUser, OxyServiceActingAsContext, OxyServiceAppContext, OxyServiceEnvironment, } from './auth';
17
+ export { createOptionalOxyAuth, createOxyAuthMiddleware, getOxyBillingPrincipal, getOxyDelegatedUserId, getOxyRequestAttribution, getOxyUserId, getRequiredOxyBillingPrincipal, getRequiredOxyUserId, isOxyAuthenticated, requireOxyAuth, OXY_SERVICE_ENVIRONMENTS, } from './auth';
18
+ export type { OxyAuthenticatedRequest, OxyAuthMiddlewareOptions, OxyAuthRequest, OxyBillingPrincipal, OxyRequestAttribution, OxyRequestUser, OxyServiceActingAsContext, OxyServiceAppContext, OxyServiceEnvironment, } from './auth';
19
19
  export { createOxyRateLimit } from './rateLimit';
20
20
  export type { OxyRateLimitOptions } from './rateLimit';
21
21
  export { assertSafePublicUrl, isBlockedIp, safeFetch, SsrfRejection, UpstreamError, ALLOWED_PORTS, ALLOWED_PROTOCOLS, BLOCKED_HOSTNAMES, DEFAULT_USER_AGENT, MAX_REDIRECTS, MAX_URL_LENGTH, UPSTREAM_HEADERS_TIMEOUT_MS, } from './safeFetch';
@@ -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 unified account list (via {@link projectSwitchableAccounts}), fetched
13
- * from `SessionClient` state `oxyServices.listAccounts()` and hydrated
14
- * with `oxyServices.getUsersByIds()`;
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
- * - `switchTo` (the uniform switch: `SessionClient.switchAccount` for an
18
- * account already on the device, `oxyServices.switchToAccount` to mint on
19
- * first entry into a graph account — reusing the existing SDK primitives, no
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 SwitchableAccount } from './accountProjection';
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
- /** The unified, deduped account list (device sign-ins ∪ graph accounts). */
196
- accounts: SwitchableAccount[];
197
- /** The currently-active account id, or `null` when signed out. */
198
- activeAccountId: string | null;
199
- /** `true` while the initial account-list fetch is in flight with no data yet. */
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 account-list error, or `null`. */
210
+ /** A human-readable directory error, or `null`. */
202
211
  error: string | null;
203
- /** The `accountId` of an in-flight switch, or `null`. */
204
- switchingAccountId: string | null;
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
- * This is the SIGN-IN commit: registers the session into the host's device
235
- * set with durable persist + profile hydration. An account SWITCH uses
236
- * {@link commitSwitchedSession} instead see below.
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 switchingAccountId;
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
- * Reload the account graph and per-account profiles, then re-project. Safe to
410
- * call repeatedly; concurrent calls are reconciled by a sequence guard so a
411
- * slow earlier fetch never overwrites a newer result.
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
- * Fetch profiles for any account id (device set graph) not yet resolved.
416
- * Cheap no-op when everything is already hydrated used from the session
417
- * subscription so a newly-added device account gets a name/avatar.
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
- private ensureProfiles;
420
- private loadProfiles;
431
+ activateContext(contextId: string): Promise<boolean>;
421
432
  /**
422
- * Switch the active account to `accountId`.
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
- * Uniform switch model, mirroring the SDK's existing path NOT a new switch
425
- * mechanism:
426
- * - already on this device `SessionClient.switchAccount` (device-first
427
- * switch of `/session/device/switch`);
428
- * - a graph account not yet on the device (first entry)
429
- * `oxyServices.switchToAccount` mints + plants a real session and the
430
- * server registers it into the device set, then it is committed
431
- * (`commitSession` when supplied, else `SessionClient.registerAndActivate`).
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
- * The resulting device-state change flows back through the `SessionClient`
434
- * subscription, which re-projects the active row. Concurrent switches are
435
- * ignored while one is in flight.
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
- switchTo(accountId: string): Promise<boolean>;
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;