@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.
@@ -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 / minted
88
- * graph switch) into the host's session set — device-first registration +
89
- * durable persist + profile hydration. The consumer supplies its provider's
90
- * commit path (`useOxy().handleWebSession` / the auth-sdk equivalent). Called
91
- * AFTER the SDK has planted the access token. When omitted the controller
92
- * falls back to `SessionClient.registerAndActivate` (registration + activation
93
- * only — no provider-side durable persist/hydration).
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
- * When a `canOpenApp` probe is injected and reports Commons installed, open the
263
- * approve deep link via the injected `openUrl`. Best-effort and non-blocking: a
264
- * probe/open failure is logged and swallowed the QR/polling fallback remains.
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 onlya 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 maybeOpenCommons;
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 `commitSession` (durable persist + hydration); falls back to
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/core",
3
- "version": "12.0.0",
3
+ "version": "12.1.0",
4
4
  "description": "OxyHQ SDK Foundation — API client, authentication, cryptographic identity, and shared utilities",
5
5
  "main": "dist/cjs/index.js",
6
6
  "module": "dist/esm/index.js",
package/src/index.ts CHANGED
@@ -623,6 +623,7 @@ export type {
623
623
  AccountDialogControllerOptions,
624
624
  AccountDialogSnapshot,
625
625
  AccountDialogView,
626
+ CommonsAvailability,
626
627
  SignInFlowPhase,
627
628
  SignInFlowState,
628
629
  } from './session/accountDialogController';
@@ -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 deep-link (canOpenApp)', () => {
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 and keeps the QR fallback', async () => {
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 / minted
102
- * graph switch) into the host's session set — device-first registration +
103
- * durable persist + profile hydration. The consumer supplies its provider's
104
- * commit path (`useOxy().handleWebSession` / the auth-sdk equivalent). Called
105
- * AFTER the SDK has planted the access token. When omitted the controller
106
- * falls back to `SessionClient.registerAndActivate` (registration + activation
107
- * only — no provider-side durable persist/hydration).
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 only — `canOpenApp`
595
- // is undefined/false on web), deep-link straight into its approve screen with
596
- // the same `oxycommons://approve?...` payload the QR encodes. The QR + polling
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.maybeOpenCommons(handle.qrPayload);
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
- * When a `canOpenApp` probe is injected and reports Commons installed, open the
607
- * approve deep link via the injected `openUrl`. Best-effort and non-blocking: a
608
- * probe/open failure is logged and swallowed the QR/polling fallback remains.
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 onlya 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 maybeOpenCommons(qrPayload: string): Promise<void> {
611
- if (!this.canOpenApp || !this.openUrl) return;
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
- if (await this.canOpenApp(COMMONS_APP_SCHEME)) {
614
- this.openUrl(qrPayload);
615
- }
683
+ available = await this.canOpenApp(COMMONS_APP_SCHEME);
616
684
  } catch (error) {
617
685
  logger.debug(
618
- '[AccountDialogController] Commons deep-link probe failed (QR fallback active)',
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 `commitSession` (durable persist + hydration); falls back to
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
- if (this.commitSession) {
766
- await this.commitSession(session);
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