@oxyhq/core 12.0.0 → 12.1.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/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/session/accountDialogController.js +83 -21
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/session/accountDialogController.js +83 -21
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/index.d.ts +1 -1
- package/dist/types/session/accountDialogController.d.ts +81 -16
- package/package.json +1 -1
- package/src/index.ts +1 -0
- package/src/session/__tests__/accountDialogController.test.ts +79 -2
- package/src/session/__tests__/accountProjection.test.ts +30 -0
- package/src/session/accountDialogController.ts +128 -27
package/dist/types/index.d.ts
CHANGED
|
@@ -99,7 +99,7 @@ export { deviceStateToClientSessions, activeSessionIdOf, activeUserOf, accountId
|
|
|
99
99
|
export { projectSwitchableAccounts, switchableAccountIds, } from './session/accountProjection';
|
|
100
100
|
export type { SwitchableAccount, SwitchableAccountUser, ProjectSwitchableAccountsInput, } from './session/accountProjection';
|
|
101
101
|
export { AccountDialogController, createAccountDialogController, } from './session/accountDialogController';
|
|
102
|
-
export type { AccountDialogControllerOptions, AccountDialogSnapshot, AccountDialogView, SignInFlowPhase, SignInFlowState, } from './session/accountDialogController';
|
|
102
|
+
export type { AccountDialogControllerOptions, AccountDialogSnapshot, AccountDialogView, CommonsAvailability, SignInFlowPhase, SignInFlowState, } from './session/accountDialogController';
|
|
103
103
|
export { createWebAuthStateStore, createNativeAuthStateStore, createMemoryAuthStateStore, AUTH_STATE_STORAGE_KEY, } from './session/authStateStore';
|
|
104
104
|
export type { PersistedAuthState, AuthStateStore, NativeKeyValueStorage, } from './session/authStateStore';
|
|
105
105
|
export { refreshPersistedSession, refreshDeviceSecretArm, createAuthRefreshHandler, installAuthRefreshHandler, startTokenRefreshScheduler, TOKEN_REFRESH_LEAD_MS, } from './session/refresh';
|
|
@@ -12,17 +12,23 @@
|
|
|
12
12
|
* - the unified account list (via {@link projectSwitchableAccounts}), fetched
|
|
13
13
|
* from `SessionClient` state ∪ `oxyServices.listAccounts()` and hydrated
|
|
14
14
|
* with `oxyServices.getUsersByIds()`;
|
|
15
|
-
* - the dialog `view` state machine (`accounts` | `signin` | `qr` | `add`
|
|
15
|
+
* - the dialog `view` state machine (`accounts` | `signin` | `qr` | `add` |
|
|
16
|
+
* `signup`);
|
|
16
17
|
* - `switchTo` (the uniform switch: `SessionClient.switchAccount` for an
|
|
17
18
|
* account already on the device, `oxyServices.switchToAccount` to mint on
|
|
18
19
|
* first entry into a graph account — reusing the existing SDK primitives, no
|
|
19
20
|
* new switch path);
|
|
20
21
|
* - the "Sign in with Oxy" device flow (same-device shared-keychain via
|
|
21
22
|
* `oxyServices.signInWithSharedIdentity`, else the cross-device QR handoff
|
|
22
|
-
* via `startCommonsSignIn` → poll → `claimSessionByToken`)
|
|
23
|
+
* via `startCommonsSignIn` → poll → `claimSessionByToken`);
|
|
24
|
+
* - `commonsAvailability` — whether Commons is installed on this device
|
|
25
|
+
* (native only, via the injected `canOpenApp` probe), so the QR view can
|
|
26
|
+
* offer a "Get Commons" fallback instead of a same-device dead end.
|
|
23
27
|
*
|
|
24
28
|
* Sign-in is passkey (WebAuthn) or the Commons QR / shared-keychain handoff —
|
|
25
|
-
* password, social login, and 2FA were removed ecosystem-wide.
|
|
29
|
+
* password, social login, and 2FA were removed ecosystem-wide. Account
|
|
30
|
+
* creation (`signup` view) is the same two identity backends: a passkey
|
|
31
|
+
* ceremony on web, or a Commons-created identity.
|
|
26
32
|
*/
|
|
27
33
|
import type { OxyServices } from '../OxyServices';
|
|
28
34
|
import type { SessionLoginResponse, MinimalUserData } from '../models/session';
|
|
@@ -30,7 +36,19 @@ import type { SessionClient } from './SessionClient';
|
|
|
30
36
|
import type { SocketIOFactory } from './socketLoader';
|
|
31
37
|
import { type SwitchableAccount } from './accountProjection';
|
|
32
38
|
/** The dialog's top-level view. */
|
|
33
|
-
export type AccountDialogView = 'accounts' | 'signin' | 'qr' | 'add';
|
|
39
|
+
export type AccountDialogView = 'accounts' | 'signin' | 'qr' | 'add' | 'signup';
|
|
40
|
+
/**
|
|
41
|
+
* Whether Commons is installed on this device, as resolved by the injected
|
|
42
|
+
* `canOpenApp` probe:
|
|
43
|
+
* - `'unknown'` — not yet probed, OR no probe was injected (web — there is
|
|
44
|
+
* no API to ask a browser whether a custom URL scheme is registered, so
|
|
45
|
+
* this stays `'unknown'` forever there and the QR view renders
|
|
46
|
+
* unconditionally, no gating).
|
|
47
|
+
* - `'checking'` — the probe is in flight.
|
|
48
|
+
* - `'available'` / `'unavailable'` — the probe's resolved terminal answer
|
|
49
|
+
* (native only). A probe error is treated as `'unavailable'` (fail-closed).
|
|
50
|
+
*/
|
|
51
|
+
export type CommonsAvailability = 'unknown' | 'checking' | 'available' | 'unavailable';
|
|
34
52
|
/** Lifecycle phase of the "Sign in with Oxy" device flow. */
|
|
35
53
|
export type SignInFlowPhase = 'idle' | 'starting' | 'waiting' | 'authorized' | 'error';
|
|
36
54
|
/** State of the "Sign in with Oxy" (shared-key / QR) device flow. */
|
|
@@ -67,6 +85,8 @@ export interface AccountDialogSnapshot {
|
|
|
67
85
|
switchingAccountId: string | null;
|
|
68
86
|
/** The "Sign in with Oxy" device-flow state. */
|
|
69
87
|
signIn: SignInFlowState;
|
|
88
|
+
/** Whether Commons is installed on this device. See {@link CommonsAvailability}. */
|
|
89
|
+
commonsAvailability: CommonsAvailability;
|
|
70
90
|
}
|
|
71
91
|
/** Construction options for {@link AccountDialogController}. */
|
|
72
92
|
export interface AccountDialogControllerOptions {
|
|
@@ -84,15 +104,36 @@ export interface AccountDialogControllerOptions {
|
|
|
84
104
|
/** Locale for display-name resolution. */
|
|
85
105
|
locale?: string;
|
|
86
106
|
/**
|
|
87
|
-
* Commit a freshly-authorized session (device flow / shared identity
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
107
|
+
* Commit a freshly-authorized SIGN-IN session (device flow / shared identity)
|
|
108
|
+
* into the host's session set — device-first registration + durable persist +
|
|
109
|
+
* profile hydration. The consumer supplies its provider's commit path
|
|
110
|
+
* (`useOxy().handleWebSession` / the auth-sdk equivalent). Called AFTER the SDK
|
|
111
|
+
* has planted the access token. When omitted the controller falls back to
|
|
112
|
+
* `SessionClient.registerAndActivate` (registration + activation only — no
|
|
113
|
+
* provider-side durable persist/hydration).
|
|
114
|
+
*
|
|
115
|
+
* This is the SIGN-IN commit: on an official web origin it may run the
|
|
116
|
+
* cross-origin hub-sync (a full-page redirect to `auth.oxy.so/sync`) that
|
|
117
|
+
* bootstraps silent OAuth restore on OTHER origins. A first sign-in on a web
|
|
118
|
+
* origin legitimately needs that. An account SWITCH does NOT — see
|
|
119
|
+
* {@link commitSwitchedSession}.
|
|
94
120
|
*/
|
|
95
121
|
commitSession?: (session: SessionLoginResponse) => Promise<void>;
|
|
122
|
+
/**
|
|
123
|
+
* Commit a minted graph SWITCH session into the host's session set — same
|
|
124
|
+
* device-first registration + durable persist + profile hydration as
|
|
125
|
+
* {@link commitSession}, but IN-PLACE: it must NOT trigger the cross-origin
|
|
126
|
+
* hub-sync redirect. Switching into an account you already operate reuses the
|
|
127
|
+
* device credential that was already hub-synced at the original sign-in, so
|
|
128
|
+
* re-syncing is redundant and a full-page redirect on switch is the exact
|
|
129
|
+
* regression this separation prevents. Cross-tab/app propagation of the switch
|
|
130
|
+
* still happens instantly via the server's device-scoped `session_state` /
|
|
131
|
+
* `session_accounts_changed` socket broadcast — no navigation required.
|
|
132
|
+
*
|
|
133
|
+
* When omitted the controller falls back to {@link commitSession} (if wired)
|
|
134
|
+
* and then to `SessionClient.registerAndActivate`.
|
|
135
|
+
*/
|
|
136
|
+
commitSwitchedSession?: (session: SessionLoginResponse) => Promise<void>;
|
|
96
137
|
/** Notified after a completed sign-in (bearer planted + session committed). */
|
|
97
138
|
onSignedIn?: (user: MinimalUserData) => void;
|
|
98
139
|
/**
|
|
@@ -133,6 +174,7 @@ export declare class AccountDialogController {
|
|
|
133
174
|
private readonly clientId;
|
|
134
175
|
private readonly locale?;
|
|
135
176
|
private readonly commitSession?;
|
|
177
|
+
private readonly commitSwitchedSession?;
|
|
136
178
|
private readonly onSignedIn?;
|
|
137
179
|
private readonly pollIntervalMs;
|
|
138
180
|
private readonly openUrl?;
|
|
@@ -146,6 +188,7 @@ export declare class AccountDialogController {
|
|
|
146
188
|
private error;
|
|
147
189
|
private switchingAccountId;
|
|
148
190
|
private signIn;
|
|
191
|
+
private commonsAvailability;
|
|
149
192
|
/** The secret device-flow token of the active QR flow (never surfaced). */
|
|
150
193
|
private signInToken;
|
|
151
194
|
private pollTimer;
|
|
@@ -214,6 +257,8 @@ export declare class AccountDialogController {
|
|
|
214
257
|
close(): void;
|
|
215
258
|
/** Switch to the "add account" view (the sign-in entry chooser). */
|
|
216
259
|
add(): void;
|
|
260
|
+
/** Switch to the "create account" view (passkey / Commons signup entry). */
|
|
261
|
+
startSignup(): void;
|
|
217
262
|
/**
|
|
218
263
|
* Reload the account graph and per-account profiles, then re-project. Safe to
|
|
219
264
|
* call repeatedly; concurrent calls are reconciled by a sequence guard so a
|
|
@@ -259,11 +304,25 @@ export declare class AccountDialogController {
|
|
|
259
304
|
*/
|
|
260
305
|
showQr(): Promise<void>;
|
|
261
306
|
/**
|
|
262
|
-
*
|
|
263
|
-
*
|
|
264
|
-
*
|
|
307
|
+
* Resolve whether Commons is installed on this device via the injected
|
|
308
|
+
* `canOpenApp` probe, updating {@link commonsAvailability} as durable,
|
|
309
|
+
* observable snapshot state. Native only — a no-op when `canOpenApp` was
|
|
310
|
+
* not injected (web), where `commonsAvailability` stays `'unknown'` forever
|
|
311
|
+
* and the QR view renders unconditionally (no gating).
|
|
312
|
+
*
|
|
313
|
+
* Replaces the old `maybeOpenCommons` fire-and-forget probe, whose outcome
|
|
314
|
+
* was only ever reflected by whether Commons silently opened — a probe
|
|
315
|
+
* failure or "not installed" answer was swallowed into a debug log with no
|
|
316
|
+
* way for the UI to react. `commonsAvailability` fixes that.
|
|
265
317
|
*/
|
|
266
|
-
private
|
|
318
|
+
private resolveCommonsAvailability;
|
|
319
|
+
/**
|
|
320
|
+
* When Commons is confirmed installed, deep-link straight into its approve
|
|
321
|
+
* screen via the injected `openUrl` with the same `oxycommons://approve?...`
|
|
322
|
+
* payload the QR encodes. Best-effort and non-blocking — the QR/polling
|
|
323
|
+
* fallback stays live regardless of the outcome here.
|
|
324
|
+
*/
|
|
325
|
+
private deepLinkIntoCommonsIfAvailable;
|
|
267
326
|
/** Tear down the active sign-in device flow (timers + socket + token) and reset to idle. */
|
|
268
327
|
cancelSignIn(): void;
|
|
269
328
|
private scheduleNextPoll;
|
|
@@ -283,8 +342,14 @@ export declare class AccountDialogController {
|
|
|
283
342
|
private completeSignIn;
|
|
284
343
|
/**
|
|
285
344
|
* Register a token-planted session into the device set. Prefers the
|
|
286
|
-
* consumer's
|
|
345
|
+
* consumer's commit funnel (durable persist + hydration); falls back to
|
|
287
346
|
* `SessionClient.registerAndActivate` (registration + activation only).
|
|
347
|
+
*
|
|
348
|
+
* A SWITCH (`opts.fromSwitch`) uses the IN-PLACE `commitSwitchedSession` funnel
|
|
349
|
+
* so it never runs the cross-origin hub-sync redirect; a SIGN-IN uses
|
|
350
|
+
* `commitSession` (which may hub-sync on an official web origin). When the
|
|
351
|
+
* switch funnel is not wired it falls back to the sign-in funnel, then to
|
|
352
|
+
* `registerAndActivate`.
|
|
288
353
|
*/
|
|
289
354
|
private commitAuthorizedSession;
|
|
290
355
|
private failSignIn;
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -152,6 +152,7 @@ describe('AccountDialogController — initial + views', () => {
|
|
|
152
152
|
expect(snap.loading).toBe(false);
|
|
153
153
|
expect(snap.switchingAccountId).toBeNull();
|
|
154
154
|
expect(snap.signIn.phase).toBe('idle');
|
|
155
|
+
expect(snap.commonsAvailability).toBe('unknown');
|
|
155
156
|
});
|
|
156
157
|
|
|
157
158
|
it('setView / add / close move between views and notify subscribers', () => {
|
|
@@ -169,6 +170,19 @@ describe('AccountDialogController — initial + views', () => {
|
|
|
169
170
|
expect(seen).toEqual(['add', 'signin', 'accounts']);
|
|
170
171
|
});
|
|
171
172
|
|
|
173
|
+
it('startSignup moves to the signup view and notifies subscribers', () => {
|
|
174
|
+
const { controller } = makeHarness();
|
|
175
|
+
const seen: string[] = [];
|
|
176
|
+
controller.subscribe((s) => seen.push(s.view));
|
|
177
|
+
|
|
178
|
+
controller.startSignup();
|
|
179
|
+
expect(controller.getSnapshot().view).toBe('signup');
|
|
180
|
+
controller.close();
|
|
181
|
+
expect(controller.getSnapshot().view).toBe('accounts');
|
|
182
|
+
|
|
183
|
+
expect(seen).toEqual(['signup', 'accounts']);
|
|
184
|
+
});
|
|
185
|
+
|
|
172
186
|
it('getSnapshot returns a stable reference until a change occurs', () => {
|
|
173
187
|
const { controller } = makeHarness();
|
|
174
188
|
const a = controller.getSnapshot();
|
|
@@ -391,6 +405,54 @@ describe('AccountDialogController — switchTo (uniform switch)', () => {
|
|
|
391
405
|
expect(commitSession.mock.calls[0][0]).toMatchObject({ sessionId: 'sess-org', accessToken: 'access-org' });
|
|
392
406
|
});
|
|
393
407
|
|
|
408
|
+
it('commits a graph switch via the IN-PLACE commitSwitchedSession — never the hub-syncing commitSession', async () => {
|
|
409
|
+
// PROBLEM 2: an account switch must not run the cross-origin hub-sync
|
|
410
|
+
// full-page redirect. When both funnels are wired, the mint-switch must use
|
|
411
|
+
// commitSwitchedSession (in-place) and NEVER commitSession (which may
|
|
412
|
+
// redirect on an official web origin).
|
|
413
|
+
const oxy = makeOxy();
|
|
414
|
+
const sc = new TestSessionClient(host());
|
|
415
|
+
sc.set(state([{ accountId: 'a1', sessionId: 's1' }], 'a1'));
|
|
416
|
+
jest.spyOn(sc, 'switchAccount').mockResolvedValue(undefined);
|
|
417
|
+
oxy.switchToAccount.mockResolvedValue({
|
|
418
|
+
sessionId: 'sess-org',
|
|
419
|
+
deviceId: 'device-1',
|
|
420
|
+
expiresAt: '2030-01-01T00:00:00Z',
|
|
421
|
+
accessToken: 'access-org',
|
|
422
|
+
user: user('org1'),
|
|
423
|
+
});
|
|
424
|
+
const commitSession = jest.fn().mockResolvedValue(undefined);
|
|
425
|
+
const commitSwitchedSession = jest.fn().mockResolvedValue(undefined);
|
|
426
|
+
const controller = new AccountDialogController({
|
|
427
|
+
oxyServices: oxy as unknown as OxyServices,
|
|
428
|
+
sessionClient: sc,
|
|
429
|
+
clientId: 'oxy_dk_test',
|
|
430
|
+
commitSession,
|
|
431
|
+
commitSwitchedSession,
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
await controller.switchTo('org1');
|
|
435
|
+
|
|
436
|
+
expect(commitSwitchedSession).toHaveBeenCalledTimes(1);
|
|
437
|
+
expect(commitSwitchedSession.mock.calls[0][0]).toMatchObject({ sessionId: 'sess-org', accessToken: 'access-org' });
|
|
438
|
+
expect(commitSession).not.toHaveBeenCalled();
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
it('surfaces a failed switch as snapshot.error instead of silently no-op\'ing', async () => {
|
|
442
|
+
// PROBLEM 1: switching into an account the server refuses (e.g. a 403 for a
|
|
443
|
+
// personal-kind target) must tell the user why — the error is recorded on
|
|
444
|
+
// the snapshot, the switch does not throw, and switchingAccountId resets.
|
|
445
|
+
const { controller, oxy, sc } = makeHarness();
|
|
446
|
+
sc.set(state([{ accountId: 'a1', sessionId: 's1' }], 'a1'));
|
|
447
|
+
oxy.switchToAccount.mockRejectedValue(new Error('Cannot switch into a personal account'));
|
|
448
|
+
|
|
449
|
+
await expect(controller.switchTo('org1')).resolves.toBeUndefined();
|
|
450
|
+
|
|
451
|
+
const snap = controller.getSnapshot();
|
|
452
|
+
expect(snap.error).toBe('Cannot switch into a personal account');
|
|
453
|
+
expect(snap.switchingAccountId).toBeNull();
|
|
454
|
+
});
|
|
455
|
+
|
|
394
456
|
it('falls back to SessionClient.registerAndActivate when no commitSession is supplied', async () => {
|
|
395
457
|
const oxy = makeOxy();
|
|
396
458
|
const sc = new TestSessionClient(host());
|
|
@@ -579,7 +641,7 @@ describe('AccountDialogController — sign in with Oxy', () => {
|
|
|
579
641
|
});
|
|
580
642
|
});
|
|
581
643
|
|
|
582
|
-
describe('AccountDialogController — Commons
|
|
644
|
+
describe('AccountDialogController — Commons availability (canOpenApp)', () => {
|
|
583
645
|
const START_HANDLE = {
|
|
584
646
|
sessionToken: 'secret-tok',
|
|
585
647
|
authorizeCode: 'AUTH-CODE',
|
|
@@ -621,6 +683,7 @@ describe('AccountDialogController — Commons deep-link (canOpenApp)', () => {
|
|
|
621
683
|
expect(snap.view).toBe('qr');
|
|
622
684
|
expect(snap.signIn.phase).toBe('waiting');
|
|
623
685
|
expect(snap.signIn.qrPayload).toBe('oxycommons://approve?v=1&code=AUTH-CODE');
|
|
686
|
+
expect(snap.commonsAvailability).toBe('available');
|
|
624
687
|
controller.cancelSignIn();
|
|
625
688
|
});
|
|
626
689
|
|
|
@@ -635,6 +698,7 @@ describe('AccountDialogController — Commons deep-link (canOpenApp)', () => {
|
|
|
635
698
|
expect(canOpenApp).toHaveBeenCalledWith('oxycommons://');
|
|
636
699
|
expect(openUrl).not.toHaveBeenCalled();
|
|
637
700
|
expect(controller.getSnapshot().signIn.phase).toBe('waiting');
|
|
701
|
+
expect(controller.getSnapshot().commonsAvailability).toBe('unavailable');
|
|
638
702
|
controller.cancelSignIn();
|
|
639
703
|
});
|
|
640
704
|
|
|
@@ -647,10 +711,11 @@ describe('AccountDialogController — Commons deep-link (canOpenApp)', () => {
|
|
|
647
711
|
|
|
648
712
|
expect(openUrl).not.toHaveBeenCalled();
|
|
649
713
|
expect(controller.getSnapshot().signIn.qrPayload).toBe('oxycommons://approve?v=1&code=AUTH-CODE');
|
|
714
|
+
expect(controller.getSnapshot().commonsAvailability).toBe('unknown');
|
|
650
715
|
controller.cancelSignIn();
|
|
651
716
|
});
|
|
652
717
|
|
|
653
|
-
it('swallows a canOpenApp probe rejection
|
|
718
|
+
it('swallows a canOpenApp probe rejection, keeps the QR fallback, and records unavailable', async () => {
|
|
654
719
|
const openUrl = jest.fn();
|
|
655
720
|
const canOpenApp = jest.fn().mockRejectedValue(new Error('probe boom'));
|
|
656
721
|
const { controller } = makeController({ openUrl, canOpenApp });
|
|
@@ -660,8 +725,20 @@ describe('AccountDialogController — Commons deep-link (canOpenApp)', () => {
|
|
|
660
725
|
|
|
661
726
|
expect(openUrl).not.toHaveBeenCalled();
|
|
662
727
|
expect(controller.getSnapshot().signIn.phase).toBe('waiting');
|
|
728
|
+
expect(controller.getSnapshot().commonsAvailability).toBe('unavailable');
|
|
663
729
|
controller.cancelSignIn();
|
|
664
730
|
});
|
|
731
|
+
|
|
732
|
+
it('start() eagerly resolves commonsAvailability without requiring a QR flow', async () => {
|
|
733
|
+
const canOpenApp = jest.fn().mockResolvedValue(true);
|
|
734
|
+
const { controller } = makeController({ canOpenApp });
|
|
735
|
+
|
|
736
|
+
controller.start();
|
|
737
|
+
await flush();
|
|
738
|
+
|
|
739
|
+
expect(canOpenApp).toHaveBeenCalledWith('oxycommons://');
|
|
740
|
+
expect(controller.getSnapshot().commonsAvailability).toBe('available');
|
|
741
|
+
});
|
|
665
742
|
});
|
|
666
743
|
|
|
667
744
|
describe('AccountDialogController — /auth-session socket (instant QR wake)', () => {
|
|
@@ -164,6 +164,36 @@ describe('projectSwitchableAccounts', () => {
|
|
|
164
164
|
});
|
|
165
165
|
expect(rows.every((r) => !r.isCurrent)).toBe(true);
|
|
166
166
|
});
|
|
167
|
+
|
|
168
|
+
it('keeps the OPERATOR full set when acting-as a sub-account (no collapse to the active account)', () => {
|
|
169
|
+
// nate operates albert: albert is the active on-device account, nate is also
|
|
170
|
+
// on-device, and the graph is OPERATOR-anchored (the server returns nate's
|
|
171
|
+
// full forest regardless of which account is active). The projection must
|
|
172
|
+
// faithfully union — switching in only changes which row is `isCurrent`,
|
|
173
|
+
// never which accounts are listed. This is the switcher-collapse regression.
|
|
174
|
+
const rows = projectSwitchableAccounts({
|
|
175
|
+
state: state([{ accountId: 'nate', sessionId: 's-nate' }, { accountId: 'albert', sessionId: 's-albert' }], 'albert'),
|
|
176
|
+
graph: [
|
|
177
|
+
graphNode('nate', { kind: 'personal', relationship: 'self' }),
|
|
178
|
+
graphNode('albert', { relationship: 'owner' }),
|
|
179
|
+
graphNode('oxy', { relationship: 'owner' }),
|
|
180
|
+
graphNode('faircoin', { relationship: 'owner' }),
|
|
181
|
+
],
|
|
182
|
+
profilesById: mapOf(user('nate'), user('albert'), user('oxy'), user('faircoin')),
|
|
183
|
+
resolveAvatarUrl: noAvatar,
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// The full operable set is present even though a leaf sub-account is active.
|
|
187
|
+
expect(rows.map((r) => r.accountId).sort()).toEqual(['albert', 'faircoin', 'nate', 'oxy']);
|
|
188
|
+
// Exactly the active sub-account is flagged current; operator + siblings stay.
|
|
189
|
+
expect(rows.find((r) => r.accountId === 'albert')?.isCurrent).toBe(true);
|
|
190
|
+
expect(rows.filter((r) => r.isCurrent)).toHaveLength(1);
|
|
191
|
+
// The operator's own personal account is not dropped by acting-as.
|
|
192
|
+
expect(rows.find((r) => r.accountId === 'nate')).toBeDefined();
|
|
193
|
+
// Graph-only siblings (operable but not yet on this device) still appear.
|
|
194
|
+
expect(rows.find((r) => r.accountId === 'oxy')?.onDevice).toBe(false);
|
|
195
|
+
expect(rows.find((r) => r.accountId === 'faircoin')?.onDevice).toBe(false);
|
|
196
|
+
});
|
|
167
197
|
});
|
|
168
198
|
|
|
169
199
|
describe('switchableAccountIds', () => {
|
|
@@ -12,17 +12,23 @@
|
|
|
12
12
|
* - the unified account list (via {@link projectSwitchableAccounts}), fetched
|
|
13
13
|
* from `SessionClient` state ∪ `oxyServices.listAccounts()` and hydrated
|
|
14
14
|
* with `oxyServices.getUsersByIds()`;
|
|
15
|
-
* - the dialog `view` state machine (`accounts` | `signin` | `qr` | `add`
|
|
15
|
+
* - the dialog `view` state machine (`accounts` | `signin` | `qr` | `add` |
|
|
16
|
+
* `signup`);
|
|
16
17
|
* - `switchTo` (the uniform switch: `SessionClient.switchAccount` for an
|
|
17
18
|
* account already on the device, `oxyServices.switchToAccount` to mint on
|
|
18
19
|
* first entry into a graph account — reusing the existing SDK primitives, no
|
|
19
20
|
* new switch path);
|
|
20
21
|
* - the "Sign in with Oxy" device flow (same-device shared-keychain via
|
|
21
22
|
* `oxyServices.signInWithSharedIdentity`, else the cross-device QR handoff
|
|
22
|
-
* via `startCommonsSignIn` → poll → `claimSessionByToken`)
|
|
23
|
+
* via `startCommonsSignIn` → poll → `claimSessionByToken`);
|
|
24
|
+
* - `commonsAvailability` — whether Commons is installed on this device
|
|
25
|
+
* (native only, via the injected `canOpenApp` probe), so the QR view can
|
|
26
|
+
* offer a "Get Commons" fallback instead of a same-device dead end.
|
|
23
27
|
*
|
|
24
28
|
* Sign-in is passkey (WebAuthn) or the Commons QR / shared-keychain handoff —
|
|
25
|
-
* password, social login, and 2FA were removed ecosystem-wide.
|
|
29
|
+
* password, social login, and 2FA were removed ecosystem-wide. Account
|
|
30
|
+
* creation (`signup` view) is the same two identity backends: a passkey
|
|
31
|
+
* ceremony on web, or a Commons-created identity.
|
|
26
32
|
*/
|
|
27
33
|
|
|
28
34
|
import type { OxyServices } from '../OxyServices';
|
|
@@ -40,7 +46,20 @@ import {
|
|
|
40
46
|
import type { AccountNode } from '../mixins/OxyServices.accounts';
|
|
41
47
|
|
|
42
48
|
/** The dialog's top-level view. */
|
|
43
|
-
export type AccountDialogView = 'accounts' | 'signin' | 'qr' | 'add';
|
|
49
|
+
export type AccountDialogView = 'accounts' | 'signin' | 'qr' | 'add' | 'signup';
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Whether Commons is installed on this device, as resolved by the injected
|
|
53
|
+
* `canOpenApp` probe:
|
|
54
|
+
* - `'unknown'` — not yet probed, OR no probe was injected (web — there is
|
|
55
|
+
* no API to ask a browser whether a custom URL scheme is registered, so
|
|
56
|
+
* this stays `'unknown'` forever there and the QR view renders
|
|
57
|
+
* unconditionally, no gating).
|
|
58
|
+
* - `'checking'` — the probe is in flight.
|
|
59
|
+
* - `'available'` / `'unavailable'` — the probe's resolved terminal answer
|
|
60
|
+
* (native only). A probe error is treated as `'unavailable'` (fail-closed).
|
|
61
|
+
*/
|
|
62
|
+
export type CommonsAvailability = 'unknown' | 'checking' | 'available' | 'unavailable';
|
|
44
63
|
|
|
45
64
|
/** Lifecycle phase of the "Sign in with Oxy" device flow. */
|
|
46
65
|
export type SignInFlowPhase = 'idle' | 'starting' | 'waiting' | 'authorized' | 'error';
|
|
@@ -80,6 +99,8 @@ export interface AccountDialogSnapshot {
|
|
|
80
99
|
switchingAccountId: string | null;
|
|
81
100
|
/** The "Sign in with Oxy" device-flow state. */
|
|
82
101
|
signIn: SignInFlowState;
|
|
102
|
+
/** Whether Commons is installed on this device. See {@link CommonsAvailability}. */
|
|
103
|
+
commonsAvailability: CommonsAvailability;
|
|
83
104
|
}
|
|
84
105
|
|
|
85
106
|
/** Construction options for {@link AccountDialogController}. */
|
|
@@ -98,15 +119,36 @@ export interface AccountDialogControllerOptions {
|
|
|
98
119
|
/** Locale for display-name resolution. */
|
|
99
120
|
locale?: string;
|
|
100
121
|
/**
|
|
101
|
-
* Commit a freshly-authorized session (device flow / shared identity
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
122
|
+
* Commit a freshly-authorized SIGN-IN session (device flow / shared identity)
|
|
123
|
+
* into the host's session set — device-first registration + durable persist +
|
|
124
|
+
* profile hydration. The consumer supplies its provider's commit path
|
|
125
|
+
* (`useOxy().handleWebSession` / the auth-sdk equivalent). Called AFTER the SDK
|
|
126
|
+
* has planted the access token. When omitted the controller falls back to
|
|
127
|
+
* `SessionClient.registerAndActivate` (registration + activation only — no
|
|
128
|
+
* provider-side durable persist/hydration).
|
|
129
|
+
*
|
|
130
|
+
* This is the SIGN-IN commit: on an official web origin it may run the
|
|
131
|
+
* cross-origin hub-sync (a full-page redirect to `auth.oxy.so/sync`) that
|
|
132
|
+
* bootstraps silent OAuth restore on OTHER origins. A first sign-in on a web
|
|
133
|
+
* origin legitimately needs that. An account SWITCH does NOT — see
|
|
134
|
+
* {@link commitSwitchedSession}.
|
|
108
135
|
*/
|
|
109
136
|
commitSession?: (session: SessionLoginResponse) => Promise<void>;
|
|
137
|
+
/**
|
|
138
|
+
* Commit a minted graph SWITCH session into the host's session set — same
|
|
139
|
+
* device-first registration + durable persist + profile hydration as
|
|
140
|
+
* {@link commitSession}, but IN-PLACE: it must NOT trigger the cross-origin
|
|
141
|
+
* hub-sync redirect. Switching into an account you already operate reuses the
|
|
142
|
+
* device credential that was already hub-synced at the original sign-in, so
|
|
143
|
+
* re-syncing is redundant and a full-page redirect on switch is the exact
|
|
144
|
+
* regression this separation prevents. Cross-tab/app propagation of the switch
|
|
145
|
+
* still happens instantly via the server's device-scoped `session_state` /
|
|
146
|
+
* `session_accounts_changed` socket broadcast — no navigation required.
|
|
147
|
+
*
|
|
148
|
+
* When omitted the controller falls back to {@link commitSession} (if wired)
|
|
149
|
+
* and then to `SessionClient.registerAndActivate`.
|
|
150
|
+
*/
|
|
151
|
+
commitSwitchedSession?: (session: SessionLoginResponse) => Promise<void>;
|
|
110
152
|
/** Notified after a completed sign-in (bearer planted + session committed). */
|
|
111
153
|
onSignedIn?: (user: MinimalUserData) => void;
|
|
112
154
|
/**
|
|
@@ -179,6 +221,7 @@ export class AccountDialogController {
|
|
|
179
221
|
private readonly clientId: string | null;
|
|
180
222
|
private readonly locale?: string;
|
|
181
223
|
private readonly commitSession?: (session: SessionLoginResponse) => Promise<void>;
|
|
224
|
+
private readonly commitSwitchedSession?: (session: SessionLoginResponse) => Promise<void>;
|
|
182
225
|
private readonly onSignedIn?: (user: MinimalUserData) => void;
|
|
183
226
|
private readonly pollIntervalMs: number;
|
|
184
227
|
private readonly openUrl?: (url: string) => void;
|
|
@@ -195,6 +238,7 @@ export class AccountDialogController {
|
|
|
195
238
|
private error: string | null = null;
|
|
196
239
|
private switchingAccountId: string | null = null;
|
|
197
240
|
private signIn: SignInFlowState = IDLE_SIGN_IN;
|
|
241
|
+
private commonsAvailability: CommonsAvailability = 'unknown';
|
|
198
242
|
|
|
199
243
|
// --- Sign-in device-flow bookkeeping ---
|
|
200
244
|
/** The secret device-flow token of the active QR flow (never surfaced). */
|
|
@@ -228,6 +272,7 @@ export class AccountDialogController {
|
|
|
228
272
|
this.clientId = options.clientId ?? null;
|
|
229
273
|
this.locale = options.locale;
|
|
230
274
|
this.commitSession = options.commitSession;
|
|
275
|
+
this.commitSwitchedSession = options.commitSwitchedSession;
|
|
231
276
|
this.onSignedIn = options.onSignedIn;
|
|
232
277
|
this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
233
278
|
this.openUrl = options.openUrl;
|
|
@@ -288,6 +333,11 @@ export class AccountDialogController {
|
|
|
288
333
|
// bearer is already planted (warm start); when signed out (cold boot before
|
|
289
334
|
// restore) it re-projects from device state and makes NO private call.
|
|
290
335
|
void this.refresh();
|
|
336
|
+
// Eager, cached Commons-availability probe (native only — a no-op when no
|
|
337
|
+
// `canOpenApp` was injected). It's a cheap local OS check, so by the time a
|
|
338
|
+
// user actually opens the sign-in entry it has almost always resolved —
|
|
339
|
+
// `showQr`'s own lazy probe below is only the safety net for the rare race.
|
|
340
|
+
void this.resolveCommonsAvailability();
|
|
291
341
|
}
|
|
292
342
|
|
|
293
343
|
/**
|
|
@@ -378,6 +428,11 @@ export class AccountDialogController {
|
|
|
378
428
|
this.setView('add');
|
|
379
429
|
}
|
|
380
430
|
|
|
431
|
+
/** Switch to the "create account" view (passkey / Commons signup entry). */
|
|
432
|
+
startSignup(): void {
|
|
433
|
+
this.setView('signup');
|
|
434
|
+
}
|
|
435
|
+
|
|
381
436
|
// =========================================================================
|
|
382
437
|
// Account list
|
|
383
438
|
// =========================================================================
|
|
@@ -523,6 +578,10 @@ export class AccountDialogController {
|
|
|
523
578
|
accessToken: result.accessToken,
|
|
524
579
|
},
|
|
525
580
|
result.user,
|
|
581
|
+
// A switch is IN-PLACE: commit without the hub-sync redirect (the
|
|
582
|
+
// device is already known/synced). Cross-tab/app propagation rides the
|
|
583
|
+
// server's `session_state` socket broadcast, not a navigation.
|
|
584
|
+
{ fromSwitch: true },
|
|
526
585
|
);
|
|
527
586
|
}
|
|
528
587
|
// Re-project + refetch immediately; the subscription also fires.
|
|
@@ -591,34 +650,65 @@ export class AccountDialogController {
|
|
|
591
650
|
// connect, so it now runs at the slow fallback cadence.
|
|
592
651
|
this.openAuthSessionSocket(handle.sessionToken);
|
|
593
652
|
this.scheduleNextPoll(handle.sessionToken);
|
|
594
|
-
// Same-device convenience: if Commons is installed (native
|
|
595
|
-
//
|
|
596
|
-
//
|
|
653
|
+
// Same-device convenience: if Commons is confirmed installed (native
|
|
654
|
+
// only — stays `'unknown'` on web, where this never opens anything),
|
|
655
|
+
// deep-link straight into its approve screen with the same
|
|
656
|
+
// `oxycommons://approve?...` payload the QR encodes. The QR + polling
|
|
597
657
|
// stay live as the fallback, so a user who dismisses the app-open still
|
|
598
658
|
// completes the sign-in by scanning.
|
|
599
|
-
void this.
|
|
659
|
+
void this.deepLinkIntoCommonsIfAvailable(handle.qrPayload);
|
|
600
660
|
} catch (error) {
|
|
601
661
|
this.setSignIn({ ...IDLE_SIGN_IN, phase: 'error', error: errorMessage(error) });
|
|
602
662
|
}
|
|
603
663
|
}
|
|
604
664
|
|
|
605
665
|
/**
|
|
606
|
-
*
|
|
607
|
-
*
|
|
608
|
-
*
|
|
666
|
+
* Resolve whether Commons is installed on this device via the injected
|
|
667
|
+
* `canOpenApp` probe, updating {@link commonsAvailability} as durable,
|
|
668
|
+
* observable snapshot state. Native only — a no-op when `canOpenApp` was
|
|
669
|
+
* not injected (web), where `commonsAvailability` stays `'unknown'` forever
|
|
670
|
+
* and the QR view renders unconditionally (no gating).
|
|
671
|
+
*
|
|
672
|
+
* Replaces the old `maybeOpenCommons` fire-and-forget probe, whose outcome
|
|
673
|
+
* was only ever reflected by whether Commons silently opened — a probe
|
|
674
|
+
* failure or "not installed" answer was swallowed into a debug log with no
|
|
675
|
+
* way for the UI to react. `commonsAvailability` fixes that.
|
|
609
676
|
*/
|
|
610
|
-
private async
|
|
611
|
-
if (!this.canOpenApp
|
|
677
|
+
private async resolveCommonsAvailability(): Promise<void> {
|
|
678
|
+
if (!this.canOpenApp) return;
|
|
679
|
+
this.commonsAvailability = 'checking';
|
|
680
|
+
this.emit();
|
|
681
|
+
let available = false;
|
|
612
682
|
try {
|
|
613
|
-
|
|
614
|
-
this.openUrl(qrPayload);
|
|
615
|
-
}
|
|
683
|
+
available = await this.canOpenApp(COMMONS_APP_SCHEME);
|
|
616
684
|
} catch (error) {
|
|
617
685
|
logger.debug(
|
|
618
|
-
'[AccountDialogController] Commons
|
|
686
|
+
'[AccountDialogController] Commons availability probe failed',
|
|
619
687
|
{ component: 'AccountDialogController' },
|
|
620
688
|
error,
|
|
621
689
|
);
|
|
690
|
+
available = false; // fail-closed — treat a probe error as "not installed"
|
|
691
|
+
}
|
|
692
|
+
this.commonsAvailability = available ? 'available' : 'unavailable';
|
|
693
|
+
this.emit();
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* When Commons is confirmed installed, deep-link straight into its approve
|
|
698
|
+
* screen via the injected `openUrl` with the same `oxycommons://approve?...`
|
|
699
|
+
* payload the QR encodes. Best-effort and non-blocking — the QR/polling
|
|
700
|
+
* fallback stays live regardless of the outcome here.
|
|
701
|
+
*/
|
|
702
|
+
private async deepLinkIntoCommonsIfAvailable(qrPayload: string): Promise<void> {
|
|
703
|
+
if (!this.openUrl) return;
|
|
704
|
+
if (this.commonsAvailability === 'unknown' || this.commonsAvailability === 'checking') {
|
|
705
|
+
// The eager `start()` probe hasn't resolved yet (or was never run, e.g.
|
|
706
|
+
// `showQr` called without a prior `start()`) — resolve it now rather
|
|
707
|
+
// than skipping the deep link.
|
|
708
|
+
await this.resolveCommonsAvailability();
|
|
709
|
+
}
|
|
710
|
+
if (this.commonsAvailability === 'available') {
|
|
711
|
+
this.openUrl(qrPayload);
|
|
622
712
|
}
|
|
623
713
|
}
|
|
624
714
|
|
|
@@ -755,15 +845,25 @@ export class AccountDialogController {
|
|
|
755
845
|
|
|
756
846
|
/**
|
|
757
847
|
* Register a token-planted session into the device set. Prefers the
|
|
758
|
-
* consumer's
|
|
848
|
+
* consumer's commit funnel (durable persist + hydration); falls back to
|
|
759
849
|
* `SessionClient.registerAndActivate` (registration + activation only).
|
|
850
|
+
*
|
|
851
|
+
* A SWITCH (`opts.fromSwitch`) uses the IN-PLACE `commitSwitchedSession` funnel
|
|
852
|
+
* so it never runs the cross-origin hub-sync redirect; a SIGN-IN uses
|
|
853
|
+
* `commitSession` (which may hub-sync on an official web origin). When the
|
|
854
|
+
* switch funnel is not wired it falls back to the sign-in funnel, then to
|
|
855
|
+
* `registerAndActivate`.
|
|
760
856
|
*/
|
|
761
857
|
private async commitAuthorizedSession(
|
|
762
858
|
session: SessionLoginResponse,
|
|
763
859
|
user: MinimalUserData,
|
|
860
|
+
opts?: { fromSwitch?: boolean },
|
|
764
861
|
): Promise<void> {
|
|
765
|
-
|
|
766
|
-
|
|
862
|
+
const commit = opts?.fromSwitch
|
|
863
|
+
? this.commitSwitchedSession ?? this.commitSession
|
|
864
|
+
: this.commitSession;
|
|
865
|
+
if (commit) {
|
|
866
|
+
await commit(session);
|
|
767
867
|
} else {
|
|
768
868
|
await this.sessionClient.registerAndActivate(user.id);
|
|
769
869
|
}
|
|
@@ -875,6 +975,7 @@ export class AccountDialogController {
|
|
|
875
975
|
error: this.error,
|
|
876
976
|
switchingAccountId: this.switchingAccountId,
|
|
877
977
|
signIn: this.signIn,
|
|
978
|
+
commonsAvailability: this.commonsAvailability,
|
|
878
979
|
};
|
|
879
980
|
}
|
|
880
981
|
|