@me2em/react 0.1.0-alpha.2 → 1.0.0-rc.2

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.
@@ -2,6 +2,8 @@ import {
2
2
  useState,
3
3
  useCallback,
4
4
  useMemo,
5
+ useEffect,
6
+ useRef,
5
7
  type ReactNode,
6
8
  } from 'react';
7
9
  import { Identity } from '@me2em/core';
@@ -10,9 +12,32 @@ import { Me2emContext, type Me2emContextValue } from './context.js';
10
12
  export function Me2emProvider({ children }: { children: ReactNode }) {
11
13
  const [identity, setIdentity] = useState<Identity | null>(null);
12
14
 
15
+ // BL-47 dispose parity: the ref is mutated only in event handlers and
16
+ // cleanup effects, always alongside setIdentity — never inside a state
17
+ // updater (StrictMode double-invokes updaters; dispose is a side
18
+ // effect). A replaced/cleared handle is disposed at the swap, so
19
+ // stale references fail loudly with DisposedError (core contract)
20
+ // instead of silently operating on zeroed key material.
21
+ const identityRef = useRef<Identity | null>(null);
22
+ // Generation ticket for in-flight Identity.fromSeed results — the
23
+ // same load-race class fixed for useIdentityContext in rc.1: a
24
+ // result landing after a newer activation, clearIdentity() or
25
+ // unmount disposes its own handle instead of overwriting newer
26
+ // state.
27
+ const genRef = useRef(0);
28
+
13
29
  const activateIdentity = useCallback(async (seed: Uint8Array): Promise<boolean> => {
30
+ const gen = ++genRef.current;
14
31
  try {
15
32
  const id = await Identity.fromSeed(seed);
33
+ if (gen !== genRef.current) {
34
+ // Superseded while in flight: discard this handle loudly
35
+ // rather than leaking it into state.
36
+ id.dispose();
37
+ return false;
38
+ }
39
+ identityRef.current?.dispose(); // zero the replaced handle
40
+ identityRef.current = id;
16
41
  setIdentity(id);
17
42
  return true;
18
43
  } catch {
@@ -21,9 +46,24 @@ export function Me2emProvider({ children }: { children: ReactNode }) {
21
46
  }, []);
22
47
 
23
48
  const clearIdentity = useCallback(() => {
49
+ ++genRef.current; // invalidate in-flight activations
50
+ identityRef.current?.dispose();
51
+ identityRef.current = null;
24
52
  setIdentity(null);
25
53
  }, []);
26
54
 
55
+ // Unmount: cleanup-only dispose — no side effects in render or
56
+ // updaters (the identity was provider state, so it is unreachable
57
+ // after unmount either way; dispose just zeroizes it eagerly).
58
+ useEffect(() => {
59
+ return () => {
60
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- reading the LIVE ticket at teardown is the point: invalidate whatever activation is in flight at unmount; snapshotting it would defeat the guard
61
+ ++genRef.current;
62
+ identityRef.current?.dispose();
63
+ identityRef.current = null;
64
+ };
65
+ }, []);
66
+
27
67
  const value = useMemo<Me2emContextValue>(
28
68
  () => ({ identity, hasIdentity: identity !== null, activateIdentity, clearIdentity }),
29
69
  [identity, activateIdentity, clearIdentity]
@@ -1,5 +1,5 @@
1
1
  // packages/react/src/react/PassphraseInput.tsx
2
- import { useState, useCallback } from 'react';
2
+ import { useCallback } from 'react';
3
3
 
4
4
  export interface PassphraseInputProps {
5
5
  onPassphraseChange: (p: string) => void;
@@ -6,18 +6,37 @@ import {
6
6
  } from '../headless/display-flow.js';
7
7
 
8
8
  export interface SeedPhraseDisplayProps {
9
+ /** Seed material is reachable via React DevTools in this window; by design. */
9
10
  words: string[];
10
11
  onDismiss?: () => void;
11
12
  onCopyAllowed?: () => void;
12
13
  }
13
14
 
15
+ /**
16
+ * R8: the permanent/transient line for a failed clipboard write is
17
+ * "can a retry in this window ever succeed?". Without the async
18
+ * Clipboard API (non-secure context, unsupported browser) it cannot —
19
+ * the failure is permanent, the copy-all button is disabled and the
20
+ * user is pointed at manual word selection. With the API present any
21
+ * rejection (lost focus, dismissed permission prompt, OS hiccup)
22
+ * stays transient: the button remains enabled and a fresh attempt
23
+ * may go through.
24
+ */
25
+ function clipboardWriteAvailable(): boolean {
26
+ return (
27
+ typeof navigator !== 'undefined' &&
28
+ !!navigator.clipboard &&
29
+ typeof navigator.clipboard.writeText === 'function'
30
+ );
31
+ }
32
+
14
33
  export function SeedPhraseDisplay({
15
34
  words,
16
35
  onDismiss,
17
36
  onCopyAllowed,
18
37
  }: SeedPhraseDisplayProps) {
19
38
  const [state, dispatch] = useReducer(displayFlowReducer, initialDisplayState);
20
- const [copyFailed, setCopyFailed] = useState(false);
39
+ const [copyError, setCopyError] = useState<'transient' | 'permanent' | null>(null);
21
40
 
22
41
  const { status, copyCount } = state;
23
42
 
@@ -28,12 +47,12 @@ export function SeedPhraseDisplay({
28
47
  const handleCopy = async () => {
29
48
  if (status === 'revealed' && copyCount === 0) {
30
49
  try {
31
- await (navigator as any).clipboard.writeText(words.join(' '));
50
+ await navigator.clipboard.writeText(words.join(' '));
32
51
  dispatch({ type: 'COPY', at: Date.now() });
33
- setCopyFailed(false);
52
+ setCopyError(null);
34
53
  onCopyAllowed?.();
35
54
  } catch {
36
- setCopyFailed(true);
55
+ setCopyError(clipboardWriteAvailable() ? 'transient' : 'permanent');
37
56
  }
38
57
  }
39
58
  };
@@ -72,15 +91,17 @@ export function SeedPhraseDisplay({
72
91
  </li>
73
92
  ))}
74
93
  </ol>
75
- {copyFailed && (
76
- <div data-testid="copy-error">
77
- Copy failed — try again
94
+ {copyError && (
95
+ <div data-testid="copy-error" data-kind={copyError}>
96
+ {copyError === 'permanent'
97
+ ? 'Copy unavailable in this context — select the words manually'
98
+ : 'Copy failed — try again'}
78
99
  </div>
79
100
  )}
80
101
  <button
81
102
  data-testid="copy-all"
82
103
  onClick={handleCopy}
83
- disabled={copyCount === 1}
104
+ disabled={copyCount === 1 || copyError === 'permanent'}
84
105
  >
85
106
  {copyCount === 1 ? 'Copied' : 'Copy all'}
86
107
  </button>
@@ -1,5 +1,5 @@
1
1
  // packages/react/src/react/SeedPhraseImport.tsx
2
- import { useReducer, useCallback, useEffect, useRef } from 'react';
2
+ import { useReducer, useEffect, useRef } from 'react';
3
3
  import {
4
4
  importFlowReducer,
5
5
  initImportState,
@@ -3,7 +3,6 @@ import { useReducer, useCallback, useState, useEffect, useRef } from 'react';
3
3
  import {
4
4
  verifyFlowReducer,
5
5
  initVerifyState,
6
- type VerifyState,
7
6
  } from '../headless/verify-flow.js';
8
7
  import { wordlist } from '@me2em/core';
9
8
 
@@ -17,6 +16,15 @@ export interface SeedPhraseVerifyProps {
17
16
 
18
17
  const DEFAULT_GRID_SIZE_OFFSET = 8;
19
18
 
19
+ /**
20
+ * Confirmation grid for a generated seed phrase.
21
+ *
22
+ * The grid shuffle is deterministic by design (fixed base seed 42;
23
+ * each retry shifts it by `+retryCount + 1`) for testability and SSR
24
+ * stability — it is NOT a security control: decoy words come from the
25
+ * public wordlist, so the layout leaks nothing (BL-49). A
26
+ * `randomSeed` prop is deliberately not offered in the frozen 1.0 API.
27
+ */
20
28
  export function SeedPhraseVerify({
21
29
  realWords,
22
30
  gridSize,
@@ -2,9 +2,25 @@ import { createContext, useContext } from 'react';
2
2
  import type { Identity } from '@me2em/core';
3
3
 
4
4
  export interface Me2emContextValue {
5
+ /**
6
+ * Active identity, or null. The previously active identity is
7
+ * disposed (key material zeroed) the moment it is replaced by a new
8
+ * activation or by {@link clearIdentity} — stale references fail
9
+ * loudly with `DisposedError` per the core contract (BL-47).
10
+ */
5
11
  identity: Identity | null;
6
12
  hasIdentity: boolean;
13
+ /**
14
+ * Activates an identity from raw seed bytes, replacing (and
15
+ * disposing) any previously active identity. Resolves `false` if
16
+ * derivation failed or the activation was superseded by a newer
17
+ * activation / `clearIdentity()` while in flight.
18
+ *
19
+ * Does not consume the seed — the caller owns the buffer and must
20
+ * zero it when done (BL-48).
21
+ */
7
22
  activateIdentity: (seed: Uint8Array) => Promise<boolean>;
23
+ /** Disposes the active identity (if any) and clears it. */
8
24
  clearIdentity: () => void;
9
25
  }
10
26
 
@@ -6,7 +6,7 @@ import {
6
6
  } from 'react';
7
7
  import {
8
8
  generateSeedPhrase,
9
- get32ByteSeedFromMnemonic,
9
+ get64ByteSeedFromMnemonic,
10
10
  type SeedStrength,
11
11
  type Identity,
12
12
  } from '@me2em/core';
@@ -37,7 +37,7 @@ export function useCreateIdentity(): UseCreateIdentityResult {
37
37
  const generate = useCallback(
38
38
  (opts?: { strength?: SeedStrength }) => {
39
39
  const words = generateSeedPhrase(opts?.strength ?? 128);
40
- get32ByteSeedFromMnemonic(words).then((derivedSeed) => {
40
+ get64ByteSeedFromMnemonic(words).then((derivedSeed) => {
41
41
  dispatch({ type: 'GENERATE', words, seed: derivedSeed });
42
42
  }).catch(() => {
43
43
  dispatch({ type: 'FAIL', message: 'Failed to generate seed' });
@@ -3,6 +3,14 @@ import { useState, useEffect } from 'react';
3
3
  import { type Handle } from '@me2em/core';
4
4
  import { useMe2emContext } from './context.js';
5
5
 
6
+ /**
7
+ * Derives the named Handle for the active identity.
8
+ *
9
+ * Silent-failure contract (R7): on derivation failure the hook logs
10
+ * via console.error and keeps returning null — there is no thrown or
11
+ * returned error signal. The structured `{ handle, error }` shape is
12
+ * a post-freeze v1.1 candidate (BL-50).
13
+ */
6
14
  export function useHandle(name: string): Handle | null {
7
15
  const { identity } = useMe2emContext();
8
16
  const [handle, setHandle] = useState<Handle | null>(null);
@@ -10,6 +18,7 @@ export function useHandle(name: string): Handle | null {
10
18
  useEffect(() => {
11
19
  let cancelled = false;
12
20
  if (!identity) {
21
+ // eslint-disable-next-line react-hooks/set-state-in-effect -- clearing the stale async result when identity is gone; set-during-render refactor is a tracked post-rc.2 follow-up
13
22
  setHandle(null);
14
23
  return;
15
24
  }
@@ -1,5 +1,6 @@
1
1
  // packages/react/src/react/useIdentityContext.ts
2
2
  import { useState, useCallback, useEffect, useRef } from 'react';
3
+ import type { Identity } from '@me2em/core';
3
4
  import type { IdentityContextStorage, IdentityContextData } from '../headless/identity-context-types.js';
4
5
  import { useMe2emContext } from './context.js';
5
6
 
@@ -9,6 +10,10 @@ function publicKeyToId(publicKey: Uint8Array): string {
9
10
  .join('');
10
11
  }
11
12
 
13
+ function identityToId(identity: Identity | null): string | null {
14
+ return identity ? publicKeyToId(identity.getPublicKey()) : null;
15
+ }
16
+
12
17
  export interface UseIdentityContextResult {
13
18
  context: IdentityContextData | null;
14
19
  isLoaded: boolean;
@@ -28,11 +33,23 @@ export function useIdentityContext(
28
33
  const [error, setError] = useState<string | null>(null);
29
34
 
30
35
  const prevIdentityIdRef = useRef<string | null>(null);
36
+ // Generation ticket for async state transitions. Load (identity
37
+ // change / `refresh()`), `save()` and `clear()` each take a ticket;
38
+ // a resolution may only commit state while its ticket is the newest
39
+ // one. Without this, an in-flight load started before a save could
40
+ // resolve after it and clobber the just-saved state with pre-save
41
+ // data (out-of-order async update — observed as a rare CI flake in
42
+ // the rerender spec; pinned there by a gated-storage test).
43
+ const opRef = useRef(0);
44
+ const identityRef = useRef<Identity | null>(null);
45
+ // eslint-disable-next-line react-hooks/refs -- render-time mirror of the latest identity: save()/clear() are stable callbacks (storage-only deps) and must verify at commit time, after their await, that the identity they started for is still the active one
46
+ identityRef.current = identity;
31
47
 
32
48
  useEffect(() => {
33
49
  let cancelled = false;
34
50
 
35
51
  if (!identity) {
52
+ // eslint-disable-next-line react-hooks/set-state-in-effect -- clearing the stale async state when identity is gone (generation-ticket effect from the load-race fix); set-during-render refactor is a tracked post-rc.2 follow-up
36
53
  setContext(null);
37
54
  setIsLoaded(true);
38
55
  setError(null);
@@ -49,9 +66,10 @@ export function useIdentityContext(
49
66
  prevIdentityIdRef.current = identityId;
50
67
 
51
68
  // Open storage for this identity and load cached context (atomic)
69
+ const op = ++opRef.current;
52
70
  storage.openAndLoad(identityId)
53
71
  .then((data) => {
54
- if (cancelled) return;
72
+ if (cancelled || op !== opRef.current) return;
55
73
  setContext(data);
56
74
  setIsLoaded(true);
57
75
  setError(null);
@@ -68,19 +86,37 @@ export function useIdentityContext(
68
86
  }, [identity, storage]);
69
87
 
70
88
  const save = useCallback(async (data: IdentityContextData): Promise<void> => {
89
+ const idAtStart = identityToId(identityRef.current);
90
+ opRef.current += 1; // invalidate any in-flight load/refresh
71
91
  await storage.save(data);
92
+ // If the identity switched while the write was in flight, state
93
+ // ownership has moved to the new identity's load effect — committing
94
+ // here would surface identity A's context under identity B. The
95
+ // generation ticket above only orders this op against in-flight
96
+ // loads; it does not know about identity switches.
97
+ if (identityToId(identityRef.current) !== idAtStart) return;
72
98
  setContext(data);
99
+ setError(null);
73
100
  }, [storage]);
74
101
 
75
102
  const clear = useCallback(async (): Promise<void> => {
103
+ const idAtStart = identityToId(identityRef.current);
104
+ opRef.current += 1; // invalidate any in-flight load/refresh
76
105
  await storage.clear();
106
+ // Same ownership rule as save(): a clear() resolving after an
107
+ // identity switch must not blank the new identity's freshly
108
+ // loaded context.
109
+ if (identityToId(identityRef.current) !== idAtStart) return;
77
110
  setContext(null);
111
+ setError(null);
78
112
  }, [storage]);
79
113
 
80
114
  const refresh = useCallback(async (): Promise<void> => {
81
115
  if (!identity) return;
82
116
  const identityId = publicKeyToId(identity.getPublicKey());
117
+ const op = ++opRef.current;
83
118
  const data = await storage.openAndLoad(identityId);
119
+ if (op !== opRef.current) return;
84
120
  setContext(data);
85
121
  }, [identity, storage]);
86
122
 
@@ -2,7 +2,7 @@
2
2
  import { useReducer, useCallback, type Reducer } from 'react';
3
3
  import {
4
4
  validateSeedPhrase,
5
- get32ByteSeedFromMnemonic,
5
+ get64ByteSeedFromMnemonic,
6
6
  type Identity,
7
7
  } from '@me2em/core';
8
8
  import {
@@ -34,7 +34,7 @@ export function useImportSeed(): UseImportSeedResult {
34
34
  return;
35
35
  }
36
36
  try {
37
- const seed = await get32ByteSeedFromMnemonic(words, passphrase ?? '');
37
+ const seed = await get64ByteSeedFromMnemonic(words, passphrase ?? '');
38
38
  dispatch({ type: 'IMPORT', seed });
39
39
  await activateIdentity(seed);
40
40
  } catch (err) {
@@ -1,8 +1,7 @@
1
1
  // packages/react/src/react/useSession.ts
2
2
  import { useState, useEffect, useCallback, useRef } from 'react';
3
3
  import type { Handle } from '@me2em/core';
4
- import { Session, type SessionOptions, type SessionPayload } from '@me2em/core';
5
- import { useMe2emContext } from './context.js';
4
+ import { Session } from '@me2em/core';
6
5
 
7
6
  /**
8
7
  * Options for useSession, memoized via deep comparison of
@@ -74,7 +73,6 @@ export function useSession(
74
73
  options: UseSessionOptions,
75
74
  autoRenew: boolean = true
76
75
  ): UseSessionResult {
77
- const { identity } = useMe2emContext();
78
76
  const [session, setSession] = useState<Session | null>(null);
79
77
  const [isLoaded, setIsLoaded] = useState(false);
80
78
  const [error, setError] = useState<string | null>(null);
@@ -82,8 +80,11 @@ export function useSession(
82
80
  const optionsRef = useRef(options);
83
81
  const mountedRef = useRef(true);
84
82
 
85
- // Update options ref without triggering re-renders
86
- optionsRef.current = options;
83
+ // Mirror the latest options into the ref after every commit —
84
+ // ref writes belong in effects, not during render (react-hooks/refs).
85
+ useEffect(() => {
86
+ optionsRef.current = options;
87
+ });
87
88
 
88
89
  const clearTimer = useCallback(() => {
89
90
  if (timerRef.current) {
@@ -128,6 +129,7 @@ export function useSession(
128
129
  const halfLife = Math.floor((s.expiresAt * 1000 - Date.now()) / 2);
129
130
  if (halfLife > 0) {
130
131
  timerRef.current = setTimeout(() => {
132
+ // eslint-disable-next-line react-hooks/immutability -- self-rescheduling renewal timer: the timer is cleared and re-created whenever renew itself changes (deps + clearTimer), so the captured binding is always the current one
131
133
  renew();
132
134
  }, Math.max(halfLife, 1000));
133
135
  }
@@ -2,6 +2,14 @@ import { useState, useEffect } from 'react';
2
2
  import { type SubHandle } from '@me2em/core';
3
3
  import { useMe2emContext } from './context.js';
4
4
 
5
+ /**
6
+ * Derives the named SubHandle under the given Handle.
7
+ *
8
+ * Silent-failure contract (R7): on derivation failure the hook logs
9
+ * via console.error and keeps returning null — there is no thrown or
10
+ * returned error signal. The structured `{ handle, error }` shape is
11
+ * a post-freeze v1.1 candidate (BL-50).
12
+ */
5
13
  export function useSubHandle(
6
14
  handleName: string,
7
15
  subName: string
@@ -12,6 +20,7 @@ export function useSubHandle(
12
20
  useEffect(() => {
13
21
  let cancelled = false;
14
22
  if (!identity) {
23
+ // eslint-disable-next-line react-hooks/set-state-in-effect -- clearing the stale async result when identity is gone; set-during-render refactor is a tracked post-rc.2 follow-up
15
24
  setSubHandle(null);
16
25
  return;
17
26
  }