@oxy.so/core 1.8.0 → 1.9.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/crypto/polyfill.js +21 -6
- package/dist/cjs/i18n/locales/en-US.json +3 -1
- package/dist/cjs/i18n/locales/es-ES.json +3 -1
- package/dist/cjs/i18n/locales/locales/en-US.json +3 -1
- package/dist/cjs/i18n/locales/locales/es-ES.json +3 -1
- package/dist/cjs/index.js +4 -1
- package/dist/cjs/session/accountDialogController.js +73 -4
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/crypto/polyfill.js +19 -4
- package/dist/esm/i18n/locales/en-US.json +3 -1
- package/dist/esm/i18n/locales/es-ES.json +3 -1
- package/dist/esm/i18n/locales/locales/en-US.json +3 -1
- package/dist/esm/i18n/locales/locales/es-ES.json +3 -1
- package/dist/esm/index.js +4 -1
- package/dist/esm/session/accountDialogController.js +72 -4
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/crypto/polyfill.d.ts +17 -2
- package/dist/types/models/interfaces.d.ts +6 -1
- package/dist/types/session/accountDialogController.d.ts +50 -3
- package/package.json +2 -2
- package/src/crypto/__tests__/polyfill.test.ts +1 -1
- package/src/crypto/__tests__/polyfillOrder.test.ts +114 -0
- package/src/crypto/polyfill.ts +19 -4
- package/src/i18n/locales/en-US.json +3 -1
- package/src/i18n/locales/es-ES.json +3 -1
- package/src/index.ts +4 -1
- package/src/models/interfaces.ts +6 -1
- package/src/session/__tests__/accountDialogController.test.ts +105 -0
- package/src/session/__tests__/accountDialogShape.test.ts +1 -0
- package/src/session/accountDialogController.ts +87 -5
|
@@ -13,8 +13,23 @@
|
|
|
13
13
|
* 2. Node — backed by the built-in `node:crypto` module (`webcrypto`, else
|
|
14
14
|
* `randomFillSync`). This is what a Node runtime WITHOUT a global WebCrypto
|
|
15
15
|
* (Node 18 script entrypoints, some embedded hosts) falls back to.
|
|
16
|
-
* 3. React Native — `expo-crypto.getRandomBytes
|
|
17
|
-
*
|
|
16
|
+
* 3. React Native — `expo-crypto.getRandomBytes`, through the dependency-free
|
|
17
|
+
* `@oxy.so/protocol/random` entry.
|
|
18
|
+
*
|
|
19
|
+
* # Evaluation order — this module must import NOTHING that can capture the global
|
|
20
|
+
*
|
|
21
|
+
* `@noble/hashes` 1.x reads `globalThis.crypto` exactly once, when its
|
|
22
|
+
* `crypto.js` is evaluated, and `randomBytes` uses that captured binding for
|
|
23
|
+
* the lifetime of the app. ES imports are evaluated before the importing
|
|
24
|
+
* module's body, so every module this file imports runs BEFORE the shim below
|
|
25
|
+
* is installed. This file used to import `@oxy.so/protocol`'s root entry,
|
|
26
|
+
* which reaches `@noble/curves` → `@noble/hashes` through the envelope signer:
|
|
27
|
+
* on Hermes (no `globalThis.crypto` at startup) noble captured `undefined`
|
|
28
|
+
* before the shim existed, and every identity creation on Android failed with
|
|
29
|
+
* `crypto.getRandomValues must be defined`. Its imports are therefore limited
|
|
30
|
+
* to `buffer` and `@oxy.so/protocol/random`, both free of crypto libraries,
|
|
31
|
+
* and `index.ts` imports this file first. `__tests__/polyfillOrder.test.ts`
|
|
32
|
+
* guards both halves.
|
|
18
33
|
*
|
|
19
34
|
* Historically step (2) delegated to `@oxy.so/protocol`'s RN-only
|
|
20
35
|
* `getRandomBytesRN`, which THROWS on Node — so any Node host lacking a global
|
|
@@ -693,7 +693,12 @@ export interface AssetUploadProgress {
|
|
|
693
693
|
export interface DeviceLinkedSession {
|
|
694
694
|
sessionId: string;
|
|
695
695
|
deviceId: string;
|
|
696
|
-
|
|
696
|
+
/**
|
|
697
|
+
* Not sent by `GET /session/device/sessions/:sessionId` today — every row
|
|
698
|
+
* shares one device, and the account a row belongs to is `user`. Optional so
|
|
699
|
+
* a caller cannot render it unchecked ("undefined (This device)").
|
|
700
|
+
*/
|
|
701
|
+
deviceName?: string;
|
|
697
702
|
isActive: boolean;
|
|
698
703
|
lastActive: string;
|
|
699
704
|
expiresAt: string;
|
|
@@ -220,8 +220,19 @@ export interface SignInFlowState {
|
|
|
220
220
|
}
|
|
221
221
|
/** Immutable snapshot consumed by `useSyncExternalStore`. */
|
|
222
222
|
export interface AccountDialogSnapshot {
|
|
223
|
-
/**
|
|
223
|
+
/**
|
|
224
|
+
* The current view. Never `'accounts'` without a session: the account menu
|
|
225
|
+
* describes a signed-in account, so with no bearer the controller shows the
|
|
226
|
+
* sign-in entry (`'signin'`) in its place.
|
|
227
|
+
*/
|
|
224
228
|
view: AccountDialogView;
|
|
229
|
+
/**
|
|
230
|
+
* Where {@link AccountDialogController.back} leads from {@link view}, or
|
|
231
|
+
* `null` when `view` is the dialog's first view (a back there closes the
|
|
232
|
+
* dialog instead). Hosts show their back affordance from this, not from a
|
|
233
|
+
* table of their own.
|
|
234
|
+
*/
|
|
235
|
+
backView: AccountDialogView | null;
|
|
225
236
|
/**
|
|
226
237
|
* The server-authoritative device directory — principals and the contexts
|
|
227
238
|
* each may act as (ADR 0002) — or `null` before the first read.
|
|
@@ -449,8 +460,32 @@ export declare class AccountDialogController {
|
|
|
449
460
|
* (no retry storm).
|
|
450
461
|
*/
|
|
451
462
|
private reconcileAuth;
|
|
452
|
-
/**
|
|
453
|
-
|
|
463
|
+
/**
|
|
464
|
+
* Set the dialog view directly.
|
|
465
|
+
*
|
|
466
|
+
* `'accounts'` is only honoured with a session. Signed out it lands on the
|
|
467
|
+
* sign-in entry instead — the menu it would open holds "Switch account",
|
|
468
|
+
* storage and "Sign out" for an account that does not exist.
|
|
469
|
+
*/
|
|
470
|
+
setView(requested: AccountDialogView): void;
|
|
471
|
+
/**
|
|
472
|
+
* Go back one step: `signup` and `qr` return to the sign-in entry, and the
|
|
473
|
+
* entry returns to the account menu when somebody is signed in.
|
|
474
|
+
*
|
|
475
|
+
* Returns `false` — changing nothing — when the current view is the dialog's
|
|
476
|
+
* first one, so the host closes the dialog instead. Leaving an active
|
|
477
|
+
* request withdraws it; otherwise its authorize code stays approvable until
|
|
478
|
+
* it expires.
|
|
479
|
+
*/
|
|
480
|
+
back(): boolean;
|
|
481
|
+
/** `'accounts'` needs a session; without one the sign-in entry stands in for it. */
|
|
482
|
+
private resolveView;
|
|
483
|
+
/**
|
|
484
|
+
* Whether an account is signed in here — the precondition of the account
|
|
485
|
+
* menu. The bearer, not the directory: the directory is bearer-read, and the
|
|
486
|
+
* menu's content (hero, storage, sign-out) is all about the bearer's account.
|
|
487
|
+
*/
|
|
488
|
+
private hasSession;
|
|
454
489
|
/** Switch to the "add account" view (the sign-in entry chooser). */
|
|
455
490
|
add(): void;
|
|
456
491
|
/** Switch to the "create account" view (passkey / Commons signup entry). */
|
|
@@ -795,6 +830,18 @@ export declare class AccountDialogController {
|
|
|
795
830
|
/** Recompute the snapshot and notify subscribers. */
|
|
796
831
|
private emit;
|
|
797
832
|
}
|
|
833
|
+
/**
|
|
834
|
+
* The view one step back from `view`, or `null` when `view` is the dialog's
|
|
835
|
+
* first view.
|
|
836
|
+
*
|
|
837
|
+
* The sign-in entry is `'add'` ("add another account") when somebody is signed
|
|
838
|
+
* in and `'signin'` when nobody is; `signup` and `qr` are reached FROM that
|
|
839
|
+
* entry, so they return to it. The entry itself returns to the account menu —
|
|
840
|
+
* which exists only with a session. This used to be a host-side
|
|
841
|
+
* "back = accounts" that assumed a signed-in origin, so a signed-out Back from
|
|
842
|
+
* "Create your account" opened a menu holding "Sign out" for nobody.
|
|
843
|
+
*/
|
|
844
|
+
export declare function backViewOf(view: AccountDialogView, hasSession: boolean): AccountDialogView | null;
|
|
798
845
|
/** Factory mirroring `createSessionClient`, for ergonomic wiring by consumers. */
|
|
799
846
|
export declare function createAccountDialogController(options: AccountDialogControllerOptions): AccountDialogController;
|
|
800
847
|
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oxy.so/core",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.9.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",
|
|
@@ -119,7 +119,7 @@
|
|
|
119
119
|
"@noble/ciphers": "^1.3.0",
|
|
120
120
|
"@noble/hashes": "^1.8.0",
|
|
121
121
|
"@oxy.so/contracts": "^1.4.0",
|
|
122
|
-
"@oxy.so/protocol": "^1.1.
|
|
122
|
+
"@oxy.so/protocol": "^1.1.1",
|
|
123
123
|
"@scure/bip39": "^1.6.0",
|
|
124
124
|
"buffer": "^6.0.3",
|
|
125
125
|
"express-rate-limit": "^8.6.0",
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
const mockGetRandomBytesRN = jest.fn<Uint8Array, [number]>();
|
|
14
14
|
const mockState = { isNodeJS: true };
|
|
15
15
|
|
|
16
|
-
jest.mock('@oxy.so/protocol', () => ({
|
|
16
|
+
jest.mock('@oxy.so/protocol/random', () => ({
|
|
17
17
|
isNodeJS: () => mockState.isNodeJS,
|
|
18
18
|
getRandomBytesRN: (byteCount: number) => mockGetRandomBytesRN(byteCount),
|
|
19
19
|
}));
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The crypto polyfill must be installed before ANY `@noble/*` module evaluates.
|
|
3
|
+
*
|
|
4
|
+
* `@noble/hashes` 1.x's `crypto.js` captures `globalThis.crypto` once, at module
|
|
5
|
+
* evaluation, and `randomBytes` uses that captured binding forever. Hermes has
|
|
6
|
+
* no `globalThis.crypto` at startup, so if noble is evaluated before
|
|
7
|
+
* `./polyfill` installs the shim, every random draw throws
|
|
8
|
+
* `crypto.getRandomValues must be defined` — which is exactly how identity
|
|
9
|
+
* creation broke on Android: the polyfill imported `@oxy.so/protocol`'s root
|
|
10
|
+
* entry, whose envelope signer reaches `@noble/curves` → `@noble/hashes`, and
|
|
11
|
+
* imports are evaluated before the polyfill body.
|
|
12
|
+
*
|
|
13
|
+
* Node's resolver picks noble's `cryptoNode.js` (the `node` export condition),
|
|
14
|
+
* which never captures the global, so a plain Node run cannot see the bug. The
|
|
15
|
+
* suites below substitute the REAL `crypto.js` that Metro resolves for React
|
|
16
|
+
* Native, remove `globalThis.crypto`, and load core in a fresh module registry.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { readFileSync } from 'node:fs';
|
|
20
|
+
import { dirname, join, resolve } from 'node:path';
|
|
21
|
+
|
|
22
|
+
type RandomBytes = (length?: number) => Uint8Array;
|
|
23
|
+
|
|
24
|
+
const ORIGINAL_CRYPTO_DESCRIPTOR = Object.getOwnPropertyDescriptor(globalThis, 'crypto');
|
|
25
|
+
|
|
26
|
+
/** noble's browser / React Native `crypto.js`: the variant that captures the global. */
|
|
27
|
+
const NOBLE_CAPTURING_CRYPTO = join(dirname(require.resolve('@noble/hashes/utils')), 'crypto.js');
|
|
28
|
+
|
|
29
|
+
/** Remove `globalThis.crypto` the way a Hermes host starts: no property at all. */
|
|
30
|
+
function removeHostCrypto(): void {
|
|
31
|
+
if (!Reflect.deleteProperty(globalThis, 'crypto') || 'crypto' in globalThis) {
|
|
32
|
+
Object.defineProperty(globalThis, 'crypto', {
|
|
33
|
+
value: undefined,
|
|
34
|
+
configurable: true,
|
|
35
|
+
writable: true,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function restoreHostCrypto(): void {
|
|
41
|
+
Reflect.deleteProperty(globalThis, 'crypto');
|
|
42
|
+
if (ORIGINAL_CRYPTO_DESCRIPTOR) {
|
|
43
|
+
Object.defineProperty(globalThis, 'crypto', ORIGINAL_CRYPTO_DESCRIPTOR);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Run `body` in a fresh module registry that resolves `@noble/hashes/crypto` as
|
|
49
|
+
* a React Native bundle does, with no host `globalThis.crypto`.
|
|
50
|
+
*/
|
|
51
|
+
function withReactNativeNobleAndNoHostCrypto(body: () => void): void {
|
|
52
|
+
removeHostCrypto();
|
|
53
|
+
try {
|
|
54
|
+
jest.isolateModules(() => {
|
|
55
|
+
jest.doMock('@noble/hashes/crypto', () => jest.requireActual(NOBLE_CAPTURING_CRYPTO));
|
|
56
|
+
body();
|
|
57
|
+
});
|
|
58
|
+
} finally {
|
|
59
|
+
restoreHostCrypto();
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
describe('crypto polyfill evaluation order', () => {
|
|
64
|
+
it('reproduces the capture: noble evaluated before the polyfill has no randomness', () => {
|
|
65
|
+
// Guards the harness itself — if this ever stops throwing, the substitution
|
|
66
|
+
// no longer models the capturing variant and the suites below prove nothing.
|
|
67
|
+
withReactNativeNobleAndNoHostCrypto(() => {
|
|
68
|
+
const { randomBytes } = require('@noble/hashes/utils') as { randomBytes: RandomBytes };
|
|
69
|
+
require('../polyfill');
|
|
70
|
+
expect(() => randomBytes(32)).toThrow('crypto.getRandomValues must be defined');
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('importing @oxy.so/core installs the shim before noble captures the global', () => {
|
|
75
|
+
withReactNativeNobleAndNoHostCrypto(() => {
|
|
76
|
+
require('../../index');
|
|
77
|
+
const { randomBytes } = require('@noble/hashes/utils') as { randomBytes: RandomBytes };
|
|
78
|
+
|
|
79
|
+
const bytes = randomBytes(32);
|
|
80
|
+
expect(bytes).toHaveLength(32);
|
|
81
|
+
expect(bytes.some((byte) => byte !== 0)).toBe(true);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('generates an identity (key pair + recovery mnemonic) through core', () => {
|
|
86
|
+
withReactNativeNobleAndNoHostCrypto(() => {
|
|
87
|
+
const core = require('../../index') as typeof import('../../index');
|
|
88
|
+
const { generateMnemonic } = require('@scure/bip39') as typeof import('@scure/bip39');
|
|
89
|
+
const { wordlist } = require('@scure/bip39/wordlists/english') as {
|
|
90
|
+
wordlist: string[];
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
// The two random draws `RecoveryPhraseService.generateIdentityWithRecovery`
|
|
94
|
+
// and `KeyManager.generateKeyPairSync` make — the ones Commons failed on.
|
|
95
|
+
expect(generateMnemonic(wordlist, 128).split(' ')).toHaveLength(12);
|
|
96
|
+
const keyPair = core.KeyManager.generateKeyPairSync();
|
|
97
|
+
expect(keyPair.privateKey).toMatch(/^[0-9a-f]{64}$/);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('the polyfill imports nothing that can reach a crypto library', () => {
|
|
102
|
+
const source = readFileSync(resolve(__dirname, '..', 'polyfill.ts'), 'utf8');
|
|
103
|
+
const specifiers = [...source.matchAll(/^import\s+[^'"]*?['"]([^'"]+)['"]/gm)].map(
|
|
104
|
+
(match) => match[1],
|
|
105
|
+
);
|
|
106
|
+
expect(specifiers.sort()).toEqual(['@oxy.so/protocol/random', 'buffer']);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('the core entry imports the polyfill before anything else', () => {
|
|
110
|
+
const source = readFileSync(resolve(__dirname, '..', '..', 'index.ts'), 'utf8');
|
|
111
|
+
const firstModuleReference = source.match(/^(?:import|export)\s[^;]*?['"]([^'"]+)['"]/m);
|
|
112
|
+
expect(firstModuleReference?.[1]).toBe('./crypto/polyfill');
|
|
113
|
+
});
|
|
114
|
+
});
|
package/src/crypto/polyfill.ts
CHANGED
|
@@ -13,8 +13,23 @@
|
|
|
13
13
|
* 2. Node — backed by the built-in `node:crypto` module (`webcrypto`, else
|
|
14
14
|
* `randomFillSync`). This is what a Node runtime WITHOUT a global WebCrypto
|
|
15
15
|
* (Node 18 script entrypoints, some embedded hosts) falls back to.
|
|
16
|
-
* 3. React Native — `expo-crypto.getRandomBytes
|
|
17
|
-
*
|
|
16
|
+
* 3. React Native — `expo-crypto.getRandomBytes`, through the dependency-free
|
|
17
|
+
* `@oxy.so/protocol/random` entry.
|
|
18
|
+
*
|
|
19
|
+
* # Evaluation order — this module must import NOTHING that can capture the global
|
|
20
|
+
*
|
|
21
|
+
* `@noble/hashes` 1.x reads `globalThis.crypto` exactly once, when its
|
|
22
|
+
* `crypto.js` is evaluated, and `randomBytes` uses that captured binding for
|
|
23
|
+
* the lifetime of the app. ES imports are evaluated before the importing
|
|
24
|
+
* module's body, so every module this file imports runs BEFORE the shim below
|
|
25
|
+
* is installed. This file used to import `@oxy.so/protocol`'s root entry,
|
|
26
|
+
* which reaches `@noble/curves` → `@noble/hashes` through the envelope signer:
|
|
27
|
+
* on Hermes (no `globalThis.crypto` at startup) noble captured `undefined`
|
|
28
|
+
* before the shim existed, and every identity creation on Android failed with
|
|
29
|
+
* `crypto.getRandomValues must be defined`. Its imports are therefore limited
|
|
30
|
+
* to `buffer` and `@oxy.so/protocol/random`, both free of crypto libraries,
|
|
31
|
+
* and `index.ts` imports this file first. `__tests__/polyfillOrder.test.ts`
|
|
32
|
+
* guards both halves.
|
|
18
33
|
*
|
|
19
34
|
* Historically step (2) delegated to `@oxy.so/protocol`'s RN-only
|
|
20
35
|
* `getRandomBytesRN`, which THROWS on Node — so any Node host lacking a global
|
|
@@ -23,7 +38,7 @@
|
|
|
23
38
|
*/
|
|
24
39
|
|
|
25
40
|
import { Buffer } from 'buffer';
|
|
26
|
-
import { getRandomBytesRN, isNodeJS } from '@oxy.so/protocol';
|
|
41
|
+
import { getRandomBytesRN, isNodeJS } from '@oxy.so/protocol/random';
|
|
27
42
|
|
|
28
43
|
const getGlobalObject = (): typeof globalThis => {
|
|
29
44
|
if (typeof globalThis !== 'undefined') return globalThis;
|
|
@@ -125,7 +140,7 @@ const cryptoPolyfill: CryptoLike = {
|
|
|
125
140
|
return array;
|
|
126
141
|
}
|
|
127
142
|
// React Native (and any non-Node host without WebCrypto): synchronous
|
|
128
|
-
// expo-crypto via @oxy.so/protocol's RN `platform/
|
|
143
|
+
// expo-crypto via @oxy.so/protocol's RN `platform/random` variant.
|
|
129
144
|
const bytes = getRandomBytesRN(array.byteLength);
|
|
130
145
|
const uint8View = new Uint8Array(array.buffer, array.byteOffset, array.byteLength);
|
|
131
146
|
uint8View.set(bytes);
|
|
@@ -1850,6 +1850,7 @@
|
|
|
1850
1850
|
"switch": {
|
|
1851
1851
|
"title": "Switch account",
|
|
1852
1852
|
"count": "{{count}} accounts",
|
|
1853
|
+
"countOne": "{{count}} account",
|
|
1853
1854
|
"empty": "Accounts you own or share"
|
|
1854
1855
|
}
|
|
1855
1856
|
},
|
|
@@ -1970,7 +1971,8 @@
|
|
|
1970
1971
|
"loading": "Loading sessions…",
|
|
1971
1972
|
"empty": "No active sessions",
|
|
1972
1973
|
"signOutAllOnThisDevice": "Sign out of all other devices",
|
|
1973
|
-
"signOutAllSubtitle": "End {{count}} other device session(s)"
|
|
1974
|
+
"signOutAllSubtitle": "End {{count}} other device session(s)",
|
|
1975
|
+
"otherSession": "Another session"
|
|
1974
1976
|
},
|
|
1975
1977
|
"confirms": {
|
|
1976
1978
|
"removeDeviceTitle": "Remove device",
|
|
@@ -1850,6 +1850,7 @@
|
|
|
1850
1850
|
"switch": {
|
|
1851
1851
|
"title": "Cambiar de cuenta",
|
|
1852
1852
|
"count": "{{count}} cuentas",
|
|
1853
|
+
"countOne": "{{count}} cuenta",
|
|
1853
1854
|
"empty": "Cuentas que posees o compartes"
|
|
1854
1855
|
}
|
|
1855
1856
|
},
|
|
@@ -1970,7 +1971,8 @@
|
|
|
1970
1971
|
"loading": "Cargando sesiones…",
|
|
1971
1972
|
"empty": "No hay sesiones activas",
|
|
1972
1973
|
"signOutAllOnThisDevice": "Cerrar sesión en los demás dispositivos",
|
|
1973
|
-
"signOutAllSubtitle": "Finalizar {{count}} sesión(es) de otros dispositivos"
|
|
1974
|
+
"signOutAllSubtitle": "Finalizar {{count}} sesión(es) de otros dispositivos",
|
|
1975
|
+
"otherSession": "Otra sesión"
|
|
1974
1976
|
},
|
|
1975
1977
|
"confirms": {
|
|
1976
1978
|
"removeDeviceTitle": "Eliminar dispositivo",
|
package/src/index.ts
CHANGED
|
@@ -17,7 +17,10 @@
|
|
|
17
17
|
* If a symbol does not appear here, it is NOT part of the public API.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
// Ensure crypto polyfills are loaded before anything else
|
|
20
|
+
// Ensure crypto polyfills are loaded before anything else. This MUST stay the
|
|
21
|
+
// first import: `@noble/hashes` captures `globalThis.crypto` when it is
|
|
22
|
+
// evaluated, so the shim has to exist before any module below reaches it (see
|
|
23
|
+
// the evaluation-order note in `./crypto/polyfill`).
|
|
21
24
|
import './crypto/polyfill';
|
|
22
25
|
|
|
23
26
|
// ---------------------------------------------------------------------------
|
package/src/models/interfaces.ts
CHANGED
|
@@ -801,7 +801,12 @@ export interface AssetUploadProgress {
|
|
|
801
801
|
export interface DeviceLinkedSession {
|
|
802
802
|
sessionId: string;
|
|
803
803
|
deviceId: string;
|
|
804
|
-
|
|
804
|
+
/**
|
|
805
|
+
* Not sent by `GET /session/device/sessions/:sessionId` today — every row
|
|
806
|
+
* shares one device, and the account a row belongs to is `user`. Optional so
|
|
807
|
+
* a caller cannot render it unchecked ("undefined (This device)").
|
|
808
|
+
*/
|
|
809
|
+
deviceName?: string;
|
|
805
810
|
isActive: boolean;
|
|
806
811
|
lastActive: string;
|
|
807
812
|
expiresAt: string;
|
|
@@ -241,6 +241,111 @@ function makeHarness(
|
|
|
241
241
|
const directoryReads = (urls: string[]): number =>
|
|
242
242
|
urls.filter((url) => url === '/session/device/directory').length;
|
|
243
243
|
|
|
244
|
+
describe('AccountDialogController — back, and the menu needs a session', () => {
|
|
245
|
+
/** A harness with no bearer — nobody is signed in on this device. */
|
|
246
|
+
function signedOut(): Harness {
|
|
247
|
+
const harness = makeHarness();
|
|
248
|
+
harness.oxy.emitTokenChange(null);
|
|
249
|
+
return harness;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
it('signed out, Back from "Create your account" returns to the sign-in entry, not the account menu', () => {
|
|
253
|
+
// The Android QA repro (OxyHQ/oxy#1375 item 1): Sign In → "New to Oxy?
|
|
254
|
+
// Create one" → ‹. The host's back used to be a hard-coded
|
|
255
|
+
// `setView('accounts')`, which opened "Switch account … Sign out" for nobody.
|
|
256
|
+
const { controller } = signedOut();
|
|
257
|
+
controller.setView('signin');
|
|
258
|
+
controller.startSignup();
|
|
259
|
+
expect(controller.getSnapshot().backView).toBe('signin');
|
|
260
|
+
|
|
261
|
+
expect(controller.back()).toBe(true);
|
|
262
|
+
|
|
263
|
+
expect(controller.getSnapshot().view).toBe('signin');
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('signed out, the sign-in entry is the first view: back reports nothing to go back to', () => {
|
|
267
|
+
const { controller } = signedOut();
|
|
268
|
+
controller.setView('signin');
|
|
269
|
+
const before = controller.getSnapshot();
|
|
270
|
+
expect(before.backView).toBeNull();
|
|
271
|
+
|
|
272
|
+
expect(controller.back()).toBe(false);
|
|
273
|
+
|
|
274
|
+
expect(controller.getSnapshot()).toBe(before);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('signed out, the account menu is never the view — it resolves to the sign-in entry', async () => {
|
|
278
|
+
const { controller } = signedOut();
|
|
279
|
+
// A controller is constructed on `accounts`; started with no bearer, it
|
|
280
|
+
// shows the entry.
|
|
281
|
+
controller.start();
|
|
282
|
+
await flush();
|
|
283
|
+
expect(controller.getSnapshot().view).toBe('signin');
|
|
284
|
+
|
|
285
|
+
controller.setView('qr');
|
|
286
|
+
controller.setView('accounts');
|
|
287
|
+
expect(controller.getSnapshot().view).toBe('signin');
|
|
288
|
+
|
|
289
|
+
controller.add();
|
|
290
|
+
expect(controller.getSnapshot().backView).toBeNull();
|
|
291
|
+
expect(controller.back()).toBe(false);
|
|
292
|
+
expect(controller.getSnapshot().view).toBe('add');
|
|
293
|
+
controller.destroy();
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it('signed out, Back from the QR request withdraws it and returns to the entry', async () => {
|
|
297
|
+
const { controller, oxy } = signedOut();
|
|
298
|
+
oxy.startCommonsSignIn.mockResolvedValue({
|
|
299
|
+
sessionToken: 'secret-tok',
|
|
300
|
+
authorizeCode: 'AUTH-CODE',
|
|
301
|
+
qrPayload: 'oxycommons://approve?v=1&code=AUTH-CODE',
|
|
302
|
+
expiresAt: Date.now() + 300_000,
|
|
303
|
+
status: 'pending',
|
|
304
|
+
});
|
|
305
|
+
controller.setView('signin');
|
|
306
|
+
await controller.showQr();
|
|
307
|
+
expect(controller.getSnapshot().view).toBe('qr');
|
|
308
|
+
expect(controller.getSnapshot().signIn.phase).toBe('waiting');
|
|
309
|
+
|
|
310
|
+
expect(controller.back()).toBe(true);
|
|
311
|
+
|
|
312
|
+
const snap = controller.getSnapshot();
|
|
313
|
+
expect(snap.view).toBe('signin');
|
|
314
|
+
expect(snap.signIn.phase).toBe('idle');
|
|
315
|
+
expect(oxy.denyCommonsSignIn).toHaveBeenCalledWith('AUTH-CODE');
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it('signed in, back walks signup → add → accounts, and the menu is the first view', () => {
|
|
319
|
+
const { controller } = makeHarness();
|
|
320
|
+
controller.add();
|
|
321
|
+
controller.startSignup();
|
|
322
|
+
expect(controller.getSnapshot().backView).toBe('add');
|
|
323
|
+
|
|
324
|
+
expect(controller.back()).toBe(true);
|
|
325
|
+
expect(controller.getSnapshot().view).toBe('add');
|
|
326
|
+
expect(controller.getSnapshot().backView).toBe('accounts');
|
|
327
|
+
|
|
328
|
+
expect(controller.back()).toBe(true);
|
|
329
|
+
expect(controller.getSnapshot().view).toBe('accounts');
|
|
330
|
+
expect(controller.getSnapshot().backView).toBeNull();
|
|
331
|
+
|
|
332
|
+
expect(controller.back()).toBe(false);
|
|
333
|
+
expect(controller.getSnapshot().view).toBe('accounts');
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it('signing out while the account menu is open leaves the menu for the sign-in entry', async () => {
|
|
337
|
+
const { controller, oxy } = makeHarness();
|
|
338
|
+
controller.start();
|
|
339
|
+
await flush();
|
|
340
|
+
expect(controller.getSnapshot().view).toBe('accounts');
|
|
341
|
+
|
|
342
|
+
oxy.emitTokenChange(null);
|
|
343
|
+
|
|
344
|
+
expect(controller.getSnapshot().view).toBe('signin');
|
|
345
|
+
controller.destroy();
|
|
346
|
+
});
|
|
347
|
+
});
|
|
348
|
+
|
|
244
349
|
describe('AccountDialogController — initial + views', () => {
|
|
245
350
|
it('starts on the accounts view with an empty list and idle sign-in', () => {
|
|
246
351
|
const { controller } = makeHarness();
|
|
@@ -297,8 +297,19 @@ function deriveSignInProgress(facts: SignInFlowFacts): SignInProgress {
|
|
|
297
297
|
|
|
298
298
|
/** Immutable snapshot consumed by `useSyncExternalStore`. */
|
|
299
299
|
export interface AccountDialogSnapshot {
|
|
300
|
-
/**
|
|
300
|
+
/**
|
|
301
|
+
* The current view. Never `'accounts'` without a session: the account menu
|
|
302
|
+
* describes a signed-in account, so with no bearer the controller shows the
|
|
303
|
+
* sign-in entry (`'signin'`) in its place.
|
|
304
|
+
*/
|
|
301
305
|
view: AccountDialogView;
|
|
306
|
+
/**
|
|
307
|
+
* Where {@link AccountDialogController.back} leads from {@link view}, or
|
|
308
|
+
* `null` when `view` is the dialog's first view (a back there closes the
|
|
309
|
+
* dialog instead). Hosts show their back affordance from this, not from a
|
|
310
|
+
* table of their own.
|
|
311
|
+
*/
|
|
312
|
+
backView: AccountDialogView | null;
|
|
302
313
|
/**
|
|
303
314
|
* The server-authoritative device directory — principals and the contexts
|
|
304
315
|
* each may act as (ADR 0002) — or `null` before the first read.
|
|
@@ -695,9 +706,11 @@ export class AccountDialogController {
|
|
|
695
706
|
}
|
|
696
707
|
// Signed out. The directory is bearer-read and `SessionClient` drops the one
|
|
697
708
|
// it holds when the device empties, so there is nothing to clear here — only
|
|
698
|
-
// the in-flight bookkeeping, which no longer describes anything.
|
|
709
|
+
// the in-flight bookkeeping, which no longer describes anything. The account
|
|
710
|
+
// menu described the account that just left, so it gives way to the entry.
|
|
699
711
|
this.error = null;
|
|
700
712
|
this.loading = false;
|
|
713
|
+
this.view = this.resolveView(this.view);
|
|
701
714
|
this.emit();
|
|
702
715
|
}
|
|
703
716
|
|
|
@@ -705,8 +718,15 @@ export class AccountDialogController {
|
|
|
705
718
|
// View actions
|
|
706
719
|
// =========================================================================
|
|
707
720
|
|
|
708
|
-
/**
|
|
709
|
-
|
|
721
|
+
/**
|
|
722
|
+
* Set the dialog view directly.
|
|
723
|
+
*
|
|
724
|
+
* `'accounts'` is only honoured with a session. Signed out it lands on the
|
|
725
|
+
* sign-in entry instead — the menu it would open holds "Switch account",
|
|
726
|
+
* storage and "Sign out" for an account that does not exist.
|
|
727
|
+
*/
|
|
728
|
+
setView(requested: AccountDialogView): void {
|
|
729
|
+
const view = this.resolveView(requested);
|
|
710
730
|
if (this.view === view) return;
|
|
711
731
|
this.view = view;
|
|
712
732
|
// A `'completed'` flow owns no timers, socket, popup, or token — it is only
|
|
@@ -719,6 +739,39 @@ export class AccountDialogController {
|
|
|
719
739
|
this.emit();
|
|
720
740
|
}
|
|
721
741
|
|
|
742
|
+
/**
|
|
743
|
+
* Go back one step: `signup` and `qr` return to the sign-in entry, and the
|
|
744
|
+
* entry returns to the account menu when somebody is signed in.
|
|
745
|
+
*
|
|
746
|
+
* Returns `false` — changing nothing — when the current view is the dialog's
|
|
747
|
+
* first one, so the host closes the dialog instead. Leaving an active
|
|
748
|
+
* request withdraws it; otherwise its authorize code stays approvable until
|
|
749
|
+
* it expires.
|
|
750
|
+
*/
|
|
751
|
+
back(): boolean {
|
|
752
|
+
const target = backViewOf(this.view, this.hasSession());
|
|
753
|
+
if (target === null) return false;
|
|
754
|
+
if (this.signIn.phase === 'starting' || this.signIn.phase === 'waiting') {
|
|
755
|
+
this.cancelSignIn();
|
|
756
|
+
}
|
|
757
|
+
this.setView(target);
|
|
758
|
+
return true;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/** `'accounts'` needs a session; without one the sign-in entry stands in for it. */
|
|
762
|
+
private resolveView(view: AccountDialogView): AccountDialogView {
|
|
763
|
+
return view === 'accounts' && !this.hasSession() ? 'signin' : view;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
/**
|
|
767
|
+
* Whether an account is signed in here — the precondition of the account
|
|
768
|
+
* menu. The bearer, not the directory: the directory is bearer-read, and the
|
|
769
|
+
* menu's content (hero, storage, sign-out) is all about the bearer's account.
|
|
770
|
+
*/
|
|
771
|
+
private hasSession(): boolean {
|
|
772
|
+
return this.isAuthenticated();
|
|
773
|
+
}
|
|
774
|
+
|
|
722
775
|
/** Switch to the "add account" view (the sign-in entry chooser). */
|
|
723
776
|
add(): void {
|
|
724
777
|
this.setView('add');
|
|
@@ -1720,8 +1773,13 @@ export class AccountDialogController {
|
|
|
1720
1773
|
|
|
1721
1774
|
private computeSnapshot(): AccountDialogSnapshot {
|
|
1722
1775
|
const directory = this.sessionClient.getDirectory();
|
|
1776
|
+
const hasSession = this.hasSession();
|
|
1777
|
+
// Resolved here as well as in `setView`: the controller is constructed on
|
|
1778
|
+
// `'accounts'` before anyone knows whether a bearer exists.
|
|
1779
|
+
const view = this.resolveView(this.view);
|
|
1723
1780
|
return {
|
|
1724
|
-
view
|
|
1781
|
+
view,
|
|
1782
|
+
backView: backViewOf(view, hasSession),
|
|
1725
1783
|
directory,
|
|
1726
1784
|
activeContext: resolveActiveContext(directory),
|
|
1727
1785
|
loading: this.loading,
|
|
@@ -1747,6 +1805,30 @@ export class AccountDialogController {
|
|
|
1747
1805
|
}
|
|
1748
1806
|
}
|
|
1749
1807
|
|
|
1808
|
+
/**
|
|
1809
|
+
* The view one step back from `view`, or `null` when `view` is the dialog's
|
|
1810
|
+
* first view.
|
|
1811
|
+
*
|
|
1812
|
+
* The sign-in entry is `'add'` ("add another account") when somebody is signed
|
|
1813
|
+
* in and `'signin'` when nobody is; `signup` and `qr` are reached FROM that
|
|
1814
|
+
* entry, so they return to it. The entry itself returns to the account menu —
|
|
1815
|
+
* which exists only with a session. This used to be a host-side
|
|
1816
|
+
* "back = accounts" that assumed a signed-in origin, so a signed-out Back from
|
|
1817
|
+
* "Create your account" opened a menu holding "Sign out" for nobody.
|
|
1818
|
+
*/
|
|
1819
|
+
export function backViewOf(view: AccountDialogView, hasSession: boolean): AccountDialogView | null {
|
|
1820
|
+
switch (view) {
|
|
1821
|
+
case 'signup':
|
|
1822
|
+
case 'qr':
|
|
1823
|
+
return hasSession ? 'add' : 'signin';
|
|
1824
|
+
case 'add':
|
|
1825
|
+
return hasSession ? 'accounts' : null;
|
|
1826
|
+
case 'signin':
|
|
1827
|
+
case 'accounts':
|
|
1828
|
+
return null;
|
|
1829
|
+
}
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1750
1832
|
/** Factory mirroring `createSessionClient`, for ergonomic wiring by consumers. */
|
|
1751
1833
|
export function createAccountDialogController(
|
|
1752
1834
|
options: AccountDialogControllerOptions,
|