@oxyhq/core 17.0.2 → 17.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/i18n/locales/en-US.json +6 -0
  3. package/dist/cjs/i18n/locales/es-ES.json +6 -0
  4. package/dist/cjs/i18n/locales/locales/en-US.json +6 -0
  5. package/dist/cjs/i18n/locales/locales/es-ES.json +6 -0
  6. package/dist/cjs/mixins/OxyServices.accounts.js +3 -1
  7. package/dist/cjs/session/accountDialogController.js +3 -1
  8. package/dist/cjs/session/accountProjection.js +19 -1
  9. package/dist/esm/.tsbuildinfo +1 -1
  10. package/dist/esm/i18n/locales/en-US.json +6 -0
  11. package/dist/esm/i18n/locales/es-ES.json +6 -0
  12. package/dist/esm/i18n/locales/locales/en-US.json +6 -0
  13. package/dist/esm/i18n/locales/locales/es-ES.json +6 -0
  14. package/dist/esm/mixins/OxyServices.accounts.js +1 -1
  15. package/dist/esm/session/accountDialogController.js +3 -1
  16. package/dist/esm/session/accountProjection.js +19 -1
  17. package/dist/types/.tsbuildinfo +1 -1
  18. package/dist/types/crypto/keyManager.d.ts +2 -2
  19. package/dist/types/mixins/OxyServices.accounts.d.ts +8 -7
  20. package/dist/types/session/accountDialogController.d.ts +1 -1
  21. package/dist/types/session/accountProjection.d.ts +6 -0
  22. package/package.json +2 -2
  23. package/src/crypto/keyManager.ts +1 -2
  24. package/src/i18n/locales/en-US.json +6 -0
  25. package/src/i18n/locales/es-ES.json +6 -0
  26. package/src/mixins/OxyServices.accounts.ts +8 -8
  27. package/src/session/__tests__/accountDialogController.test.ts +13 -1
  28. package/src/session/__tests__/accountProjection.test.ts +31 -0
  29. package/src/session/accountDialogController.ts +4 -2
  30. package/src/session/accountProjection.ts +19 -1
  31. package/src/types/color.d.ts +0 -20
  32. package/src/types/elliptic.d.ts +0 -70
@@ -4,7 +4,7 @@
4
4
  * Handles secure generation, storage, and retrieval of cryptographic keys.
5
5
  * Private keys are stored securely using expo-secure-store and never leave the device.
6
6
  */
7
- import type { ECKeyPair } from 'elliptic';
7
+ import { ec as EC } from 'elliptic';
8
8
  import { type IdentityMarker } from './identityMarker';
9
9
  /**
10
10
  * Thrown when an identity-mutating operation (createIdentity / importKeyPair)
@@ -536,7 +536,7 @@ export declare class KeyManager {
536
536
  * Get the elliptic curve key object from the stored private key
537
537
  * Used internally for signing operations
538
538
  */
539
- static getKeyPairObject(): Promise<ECKeyPair | null>;
539
+ static getKeyPairObject(): Promise<EC.KeyPair | null>;
540
540
  /**
541
541
  * Derive public key from a private key (without storing)
542
542
  */
@@ -33,19 +33,20 @@
33
33
  * registers the switched session into the operator's device-set directly).
34
34
  */
35
35
  import type { User } from '../models/interfaces';
36
- import type { OrganizationCategory } from '@oxyhq/contracts';
36
+ import type { AccountKind, OrganizationCategory } from '@oxyhq/contracts';
37
37
  import type { SessionLoginResponse } from '../models/session';
38
38
  import type { OxyServicesBase } from '../OxyServices.base';
39
39
  /**
40
40
  * Account classification, orthogonal to the federation `type`
41
41
  * (`local|federated|agent|automated`). `personal` accounts have a direct login;
42
- * `organization` / `project` / `bot` accounts are operated via `AccountMember`
43
- * and have no direct login.
42
+ * `organization` / `project` / `bot` / `channel` accounts are operated via
43
+ * `AccountMember` and have no direct login. Of those, only the first three may
44
+ * be acted AS — see `isActAsEligibleKind`.
45
+ *
46
+ * Single source of truth is `@oxyhq/contracts`.
44
47
  */
45
- export type AccountKind = 'personal' | 'organization' | 'project' | 'bot';
46
- /** Real-estate / team taxonomy for `kind: 'organization'` accounts. */
47
- export type { OrganizationCategory } from '@oxyhq/contracts';
48
- export { ORGANIZATION_CATEGORIES } from '@oxyhq/contracts';
48
+ export type { AccountKind, OrganizationCategory } from '@oxyhq/contracts';
49
+ export { ACCOUNT_KINDS, ORGANIZATION_CATEGORIES, isActAsEligibleKind } from '@oxyhq/contracts';
49
50
  /**
50
51
  * The calling user's relationship to an account node, as resolved by the API:
51
52
  * - `self` — the caller's own personal (root) account.
@@ -434,7 +434,7 @@ export declare class AccountDialogController {
434
434
  * subscription, which re-projects the active row. Concurrent switches are
435
435
  * ignored while one is in flight.
436
436
  */
437
- switchTo(accountId: string): Promise<void>;
437
+ switchTo(accountId: string): Promise<boolean>;
438
438
  /**
439
439
  * Start "Sign in with Oxy". Native devices with a shared identity mint a
440
440
  * session silently (`signInWithSharedIdentity`); everything else (web, or a
@@ -130,6 +130,9 @@ export interface ProjectSwitchableAccountsInput {
130
130
  * graph-only rows (in graph order). An account present as BOTH a device session
131
131
  * and a graph node is deduped into ONE device row enriched with the graph
132
132
  * metadata (relationship / kind / parent / membership).
133
+ *
134
+ * Graph nodes of a kind nobody may act as (`channel`) are omitted — see the
135
+ * filter below.
133
136
  */
134
137
  export declare function projectSwitchableAccounts(input: ProjectSwitchableAccountsInput): SwitchableAccount[];
135
138
  /**
@@ -138,5 +141,8 @@ export declare function projectSwitchableAccounts(input: ProjectSwitchableAccoun
138
141
  * `oxyServices.getUsersByIds(...)`; graph nodes already embed their `account`
139
142
  * document, but including their ids lets the caller pass one id set and lets the
140
143
  * projection prefer freshly-fetched profiles uniformly.
144
+ *
145
+ * Applies the SAME act-as filter as {@link projectSwitchableAccounts} to graph
146
+ * nodes, so this never fetches a profile for a row the projection will drop.
141
147
  */
142
148
  export declare function switchableAccountIds(state: DeviceSessionState | null, graph: AccountNode[]): string[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/core",
3
- "version": "17.0.2",
3
+ "version": "17.0.3",
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",
@@ -118,6 +118,7 @@
118
118
  "@oxyhq/contracts": "^0.21.0",
119
119
  "@oxyhq/protocol": "^0.1.6",
120
120
  "@scure/bip39": "^1.6.0",
121
+ "@types/elliptic": "^6.4.18",
121
122
  "buffer": "^6.0.3",
122
123
  "elliptic": "^6.6.1",
123
124
  "invariant": "^2.2.4",
@@ -154,7 +155,6 @@
154
155
  "devDependencies": {
155
156
  "@biomejs/biome": "^1.9.4",
156
157
  "@react-native-async-storage/async-storage": "^2.2.0",
157
- "@types/elliptic": "^6.4.18",
158
158
  "@types/express": "^4.17.21",
159
159
  "@types/invariant": "^2.2.34",
160
160
  "@types/node": "^20.19.43",
@@ -6,7 +6,6 @@
6
6
  */
7
7
 
8
8
  import { ec as EC } from 'elliptic';
9
- import type { ECKeyPair } from 'elliptic';
10
9
  import { isWeb, isIOS, isAndroid } from '../utils/platform';
11
10
  import { type ExpoCryptoLike, type ExpoSecureStoreLike, isReactNative, isNodeJS, loadAsyncStorage, loadExpoCrypto, loadNodeCrypto, loadSecureStore, loadSharedIdentityBridge } from '@oxyhq/protocol';
12
11
  import { isDev, logger } from '../logger';
@@ -2490,7 +2489,7 @@ export class KeyManager {
2490
2489
  * Get the elliptic curve key object from the stored private key
2491
2490
  * Used internally for signing operations
2492
2491
  */
2493
- static async getKeyPairObject(): Promise<ECKeyPair | null> {
2492
+ static async getKeyPairObject(): Promise<EC.KeyPair | null> {
2494
2493
  if (isWebPlatform()) {
2495
2494
  return null; // Identity storage is only available on native platforms
2496
2495
  }
@@ -54,6 +54,12 @@
54
54
  "status": {
55
55
  "accountSwitched": "Now using {{name}}",
56
56
  "signingIn": "Signing in…"
57
+ },
58
+ "errors": {
59
+ "notConfigured": "Sign-in isn't available",
60
+ "notConfiguredDescription": "{{app}} isn't set up for sign-in yet. Contact the app's developer.",
61
+ "failed": "Couldn't start sign-in",
62
+ "failedDescription": "Something went wrong. Please try again."
57
63
  }
58
64
  },
59
65
  "signup": {
@@ -54,6 +54,12 @@
54
54
  "status": {
55
55
  "accountSwitched": "Ahora usando {{name}}",
56
56
  "signingIn": "Iniciando sesión…"
57
+ },
58
+ "errors": {
59
+ "notConfigured": "El inicio de sesión no está disponible",
60
+ "notConfiguredDescription": "{{app}} todavía no está configurada para iniciar sesión. Contacta con quien desarrolla la app.",
61
+ "failed": "No se pudo iniciar sesión",
62
+ "failedDescription": "Algo ha ido mal. Inténtalo de nuevo."
57
63
  }
58
64
  },
59
65
  "signup": {
@@ -33,7 +33,7 @@
33
33
  * registers the switched session into the operator's device-set directly).
34
34
  */
35
35
  import type { User } from '../models/interfaces';
36
- import type { OrganizationCategory } from '@oxyhq/contracts';
36
+ import type { AccountKind, OrganizationCategory } from '@oxyhq/contracts';
37
37
  import type { SessionLoginResponse } from '../models/session';
38
38
  import type { OxyServicesBase } from '../OxyServices.base';
39
39
  import { normalizeUserIdentity } from '../utils/userIdentity';
@@ -46,14 +46,14 @@ import { CACHE_TIMES } from './mixinHelpers';
46
46
  /**
47
47
  * Account classification, orthogonal to the federation `type`
48
48
  * (`local|federated|agent|automated`). `personal` accounts have a direct login;
49
- * `organization` / `project` / `bot` accounts are operated via `AccountMember`
50
- * and have no direct login.
49
+ * `organization` / `project` / `bot` / `channel` accounts are operated via
50
+ * `AccountMember` and have no direct login. Of those, only the first three may
51
+ * be acted AS — see `isActAsEligibleKind`.
52
+ *
53
+ * Single source of truth is `@oxyhq/contracts`.
51
54
  */
52
- export type AccountKind = 'personal' | 'organization' | 'project' | 'bot';
53
-
54
- /** Real-estate / team taxonomy for `kind: 'organization'` accounts. */
55
- export type { OrganizationCategory } from '@oxyhq/contracts';
56
- export { ORGANIZATION_CATEGORIES } from '@oxyhq/contracts';
55
+ export type { AccountKind, OrganizationCategory } from '@oxyhq/contracts';
56
+ export { ACCOUNT_KINDS, ORGANIZATION_CATEGORIES, isActAsEligibleKind } from '@oxyhq/contracts';
57
57
 
58
58
  /**
59
59
  * The calling user's relationship to an account node, as resolved by the API:
@@ -472,7 +472,7 @@ describe('AccountDialogController — switchTo (uniform switch)', () => {
472
472
  sc.set(state([{ accountId: 'a1', sessionId: 's1' }], 'a1'));
473
473
  oxy.switchToAccount.mockRejectedValue(new Error('Cannot switch into a personal account'));
474
474
 
475
- await expect(controller.switchTo('org1')).resolves.toBeUndefined();
475
+ await expect(controller.switchTo('org1')).resolves.toBe(false);
476
476
 
477
477
  const snap = controller.getSnapshot();
478
478
  expect(snap.error).toBe('Cannot switch into a personal account');
@@ -517,6 +517,18 @@ describe('AccountDialogController — switchTo (uniform switch)', () => {
517
517
  release();
518
518
  await first;
519
519
  });
520
+
521
+ it('returns true when the switch succeeds even if the post-switch refresh fails', async () => {
522
+ const { controller, oxy, sc } = makeHarness();
523
+ sc.set(state([{ accountId: 'a1', sessionId: 's1' }, { accountId: 'a2', sessionId: 's2' }], 'a1'));
524
+ jest.spyOn(sc, 'switchAccount').mockResolvedValue(undefined);
525
+ oxy.listAccounts.mockRejectedValue(new Error('network down'));
526
+
527
+ await expect(controller.switchTo('a2')).resolves.toBe(true);
528
+
529
+ expect(controller.getSnapshot().error).toBe('network down');
530
+ expect(controller.getSnapshot().switchingAccountId).toBeNull();
531
+ });
520
532
  });
521
533
 
522
534
  describe('AccountDialogController — sign in with Oxy', () => {
@@ -115,6 +115,29 @@ describe('projectSwitchableAccounts', () => {
115
115
  expect(org.parentAccountId).toBe('a1');
116
116
  });
117
117
 
118
+ /**
119
+ * A channel is a content identity nobody acts as, so it must never be offered
120
+ * as a switch target. It reaches the projection through the GRAPH lane, not
121
+ * the device lane — a channel has no credentials and so can never be a device
122
+ * session, which is exactly why "no-login accounts can't appear here by
123
+ * construction" is false: `listAccounts()` contributes credential-less
124
+ * accounts on purpose (that is how an org first becomes switchable).
125
+ */
126
+ it('omits a graph-only channel account from the switcher', () => {
127
+ const rows = projectSwitchableAccounts({
128
+ state: state([{ accountId: 'a1', sessionId: 's1' }], 'a1'),
129
+ graph: [
130
+ graphNode('org1', { kind: 'organization' }),
131
+ graphNode('chan1', { kind: 'channel' }),
132
+ ],
133
+ profilesById: mapOf(user('a1')),
134
+ resolveAvatarUrl: noAvatar,
135
+ });
136
+
137
+ expect(rows.map((r) => r.accountId)).toEqual(['a1', 'org1']);
138
+ expect(rows.some((r) => r.kind === 'channel')).toBe(false);
139
+ });
140
+
118
141
  it('dedups an account present as BOTH device session and graph node into ONE enriched row', () => {
119
142
  const rows = projectSwitchableAccounts({
120
143
  state: state([{ accountId: 'a1', sessionId: 's1', authuser: 0 }], 'a1'),
@@ -208,4 +231,12 @@ describe('switchableAccountIds', () => {
208
231
  it('returns [] for null state and empty graph', () => {
209
232
  expect(switchableAccountIds(null, [])).toEqual([]);
210
233
  });
234
+
235
+ it('omits a graph-only channel, so no profile is fetched for a dropped row', () => {
236
+ const ids = switchableAccountIds(null, [
237
+ graphNode('org1', { kind: 'organization' }),
238
+ graphNode('chan1', { kind: 'channel' }),
239
+ ]);
240
+ expect(ids).toEqual(['org1']);
241
+ });
211
242
  });
@@ -783,8 +783,8 @@ export class AccountDialogController {
783
783
  * subscription, which re-projects the active row. Concurrent switches are
784
784
  * ignored while one is in flight.
785
785
  */
786
- async switchTo(accountId: string): Promise<void> {
787
- if (this.switchingAccountId) return;
786
+ async switchTo(accountId: string): Promise<boolean> {
787
+ if (this.switchingAccountId) return false;
788
788
  this.switchingAccountId = accountId;
789
789
  this.error = null;
790
790
  this.emit();
@@ -815,8 +815,10 @@ export class AccountDialogController {
815
815
  }
816
816
  // Re-project + refetch immediately; the subscription also fires.
817
817
  await this.refresh();
818
+ return true;
818
819
  } catch (error) {
819
820
  this.error = errorMessage(error);
821
+ return false;
820
822
  } finally {
821
823
  this.switchingAccountId = null;
822
824
  this.emit();
@@ -18,6 +18,7 @@
18
18
  */
19
19
 
20
20
  import type { DeviceSessionState } from '@oxyhq/contracts';
21
+ import { isActAsEligibleKind } from '@oxyhq/contracts';
21
22
  import type { User } from '../models/interfaces';
22
23
  import type {
23
24
  AccountNode,
@@ -142,6 +143,9 @@ export interface ProjectSwitchableAccountsInput {
142
143
  * graph-only rows (in graph order). An account present as BOTH a device session
143
144
  * and a graph node is deduped into ONE device row enriched with the graph
144
145
  * metadata (relationship / kind / parent / membership).
146
+ *
147
+ * Graph nodes of a kind nobody may act as (`channel`) are omitted — see the
148
+ * filter below.
145
149
  */
146
150
  export function projectSwitchableAccounts(input: ProjectSwitchableAccountsInput): SwitchableAccount[] {
147
151
  const { state, graph, profilesById, activeUser, locale, resolveAvatarUrl } = input;
@@ -227,6 +231,17 @@ export function projectSwitchableAccounts(input: ProjectSwitchableAccountsInput)
227
231
  continue;
228
232
  }
229
233
  // Graph-only account (owned org / shared, not yet a device session).
234
+ //
235
+ // This lane is why a no-login account is NOT kept out of the switcher "by
236
+ // construction": the graph contributes accounts that have no device session
237
+ // and no credentials at all, which is exactly how an org first becomes
238
+ // switchable. So a kind that must never be switched into has to be filtered
239
+ // HERE, and `isActAsEligibleKind` is the same predicate the server enforces
240
+ // on `POST /accounts/:id/switch` — offering a row the server would 403 is a
241
+ // dead button.
242
+ if (!isActAsEligibleKind(node.kind)) {
243
+ continue;
244
+ }
230
245
  remember(toRow(node.account, {
231
246
  relationship: node.relationship,
232
247
  kind: node.kind,
@@ -247,6 +262,9 @@ export function projectSwitchableAccounts(input: ProjectSwitchableAccountsInput)
247
262
  * `oxyServices.getUsersByIds(...)`; graph nodes already embed their `account`
248
263
  * document, but including their ids lets the caller pass one id set and lets the
249
264
  * projection prefer freshly-fetched profiles uniformly.
265
+ *
266
+ * Applies the SAME act-as filter as {@link projectSwitchableAccounts} to graph
267
+ * nodes, so this never fetches a profile for a row the projection will drop.
250
268
  */
251
269
  export function switchableAccountIds(
252
270
  state: DeviceSessionState | null,
@@ -259,7 +277,7 @@ export function switchableAccountIds(
259
277
  }
260
278
  }
261
279
  for (const node of graph) {
262
- if (node.accountId) {
280
+ if (node.accountId && isActAsEligibleKind(node.kind)) {
263
281
  ids.add(node.accountId);
264
282
  }
265
283
  }
@@ -1,20 +0,0 @@
1
- declare module 'color' {
2
- interface Color {
3
- (color: string | Color | undefined, model?: string): Color;
4
- rgb(): Color;
5
- rgba(): Color;
6
- alpha(): number;
7
- alpha(val: number): Color;
8
- fade(val: number): Color;
9
- lighten(val: number): Color;
10
- darken(val: number): Color;
11
- string(): string;
12
- hex(): string;
13
- toString(): string;
14
- }
15
-
16
- function color(color: string | Color | undefined, model?: string): Color;
17
- export default color;
18
- }
19
-
20
-
@@ -1,70 +0,0 @@
1
- declare module 'elliptic' {
2
- export interface BN {
3
- toArray(endian?: 'be' | 'le', length?: number): number[];
4
- toString(base?: number): string;
5
- }
6
-
7
- export interface KeyPair {
8
- getPrivate(enc?: 'hex' | 'array' | 'bn'): string | number[] | any;
9
- getPublic(): Point;
10
- getPublic(enc: 'hex'): string;
11
- getPublic(enc: 'array'): number[];
12
- getPublic(compressed: boolean, enc?: 'hex'): string;
13
- getPublic(compressed: boolean, enc?: 'array'): number[];
14
- sign(msg: string | number[]): Signature;
15
- verify(msg: string | number[], signature: Signature | string | { r: string | number[]; s: string | number[] }): boolean;
16
- /** ECDH: derive the shared point's x-coordinate against another party's public point. */
17
- derive(pub: Point): BN;
18
- }
19
-
20
- export interface Signature {
21
- r: any;
22
- s: any;
23
- recoveryParam: number | null;
24
- toDER(enc?: 'hex'): string;
25
- toCompact(enc?: 'hex', param?: number): string;
26
- }
27
-
28
- export interface Curve {
29
- n: any;
30
- red: any;
31
- g: Point;
32
- decodePoint(bytes: string | number[], enc?: string): Point;
33
- }
34
-
35
- export interface Point {
36
- x: any;
37
- y: any;
38
- inf: boolean;
39
- getX(): any;
40
- getY(): any;
41
- encode(enc?: string, compressed?: boolean): string | number[];
42
- add(p: Point): Point;
43
- mul(k: any): Point;
44
- eq(p: Point): boolean;
45
- }
46
-
47
- export class EC {
48
- constructor(curve: string | Curve);
49
- keyFromPrivate(priv: string | number[], enc?: string): KeyPair;
50
- keyFromPublic(pub: string | number[] | Point, enc?: string): KeyPair;
51
- genKeyPair(opts?: { entropy?: string | number[]; pers?: string | number[] }): KeyPair;
52
- keyPair(opts?: { priv?: string | number[]; pub?: string | number[] | Point; privEnc?: string; pubEnc?: string }): KeyPair;
53
- curve: Curve;
54
- n: any;
55
- nh: any;
56
- g: Point;
57
- }
58
-
59
- export type ECKeyPair = KeyPair;
60
-
61
- // ec can be called as a function or used as a constructor
62
- export interface ECConstructor {
63
- new (curve: string | Curve): EC;
64
- (curve: string | Curve): EC;
65
- }
66
-
67
- export const ec: ECConstructor;
68
- }
69
-
70
-