@me2em/react 0.1.0-alpha.1 → 1.0.0-rc.1
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/README.md +40 -11
- package/dist/index.d.ts +2 -1
- package/dist/index.js +55 -52
- package/dist/index.js.map +1 -1
- package/package.json +12 -6
- package/src/headless/context-crypto.ts +10 -29
- package/src/headless/identity-context-storage.ts +2 -2
- package/src/headless/import-flow.ts +1 -1
- package/src/react/PassphraseInput.tsx +1 -1
- package/src/react/SeedPhraseDisplay.tsx +29 -8
- package/src/react/SeedPhraseImport.tsx +1 -1
- package/src/react/SeedPhraseVerify.tsx +0 -1
- package/src/react/index.ts +3 -0
- package/src/react/useCreateIdentity.ts +2 -2
- package/src/react/useHandle.ts +1 -0
- package/src/react/useIdentityContext.ts +37 -1
- package/src/react/useImportSeed.ts +2 -2
- package/src/react/useSession.ts +7 -5
- package/src/react/useSubHandle.ts +1 -0
- package/src/headless/context-key-store.ts +0 -124
package/src/react/index.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
export { Me2emProvider } from './Me2emProvider.js';
|
|
2
2
|
export { useMe2emContext } from './context.js';
|
|
3
|
+
export type { Me2emContextValue } from './context.js';
|
|
3
4
|
export { useCreateIdentity } from './useCreateIdentity.js';
|
|
5
|
+
export type { UseCreateIdentityResult } from './useCreateIdentity.js';
|
|
4
6
|
export { useImportSeed } from './useImportSeed.js';
|
|
7
|
+
export type { UseImportSeedResult } from './useImportSeed.js';
|
|
5
8
|
export { useHandle } from './useHandle.js';
|
|
6
9
|
export { useSubHandle } from './useSubHandle.js';
|
|
7
10
|
export { SeedPhraseDisplay } from './SeedPhraseDisplay.js';
|
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
} from 'react';
|
|
7
7
|
import {
|
|
8
8
|
generateSeedPhrase,
|
|
9
|
-
|
|
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
|
-
|
|
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' });
|
package/src/react/useHandle.ts
CHANGED
|
@@ -10,6 +10,7 @@ export function useHandle(name: string): Handle | null {
|
|
|
10
10
|
useEffect(() => {
|
|
11
11
|
let cancelled = false;
|
|
12
12
|
if (!identity) {
|
|
13
|
+
// 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
14
|
setHandle(null);
|
|
14
15
|
return;
|
|
15
16
|
}
|
|
@@ -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
|
-
|
|
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
|
|
37
|
+
const seed = await get64ByteSeedFromMnemonic(words, passphrase ?? '');
|
|
38
38
|
dispatch({ type: 'IMPORT', seed });
|
|
39
39
|
await activateIdentity(seed);
|
|
40
40
|
} catch (err) {
|
package/src/react/useSession.ts
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
86
|
-
|
|
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
|
}
|
|
@@ -12,6 +12,7 @@ export function useSubHandle(
|
|
|
12
12
|
useEffect(() => {
|
|
13
13
|
let cancelled = false;
|
|
14
14
|
if (!identity) {
|
|
15
|
+
// 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
16
|
setSubHandle(null);
|
|
16
17
|
return;
|
|
17
18
|
}
|
|
@@ -1,124 +0,0 @@
|
|
|
1
|
-
// packages/react/src/headless/context-key-store.ts
|
|
2
|
-
import type { IdentityContextData } from './identity-context-storage.js';
|
|
3
|
-
|
|
4
|
-
const KEYS_PREFIX = '__keys:';
|
|
5
|
-
|
|
6
|
-
export interface ContextKeyStore {
|
|
7
|
-
/** Save a persisted CryptoKey for later use. */
|
|
8
|
-
setCacheKey(key: CryptoKey): Promise<void>;
|
|
9
|
-
/** Retrieve the persisted CryptoKey, or null if not found. */
|
|
10
|
-
getCacheKey(): Promise<CryptoKey | null>;
|
|
11
|
-
/** Delete the persisted CryptoKey (called on logout). */
|
|
12
|
-
deleteCacheKey(): Promise<void>;
|
|
13
|
-
/** Persist the CryptoKey to IndexedDB for warm return after browser restart. */
|
|
14
|
-
persistCacheKey(): Promise<void>;
|
|
15
|
-
/** Load a previously persisted CryptoKey from IndexedDB. */
|
|
16
|
-
loadPersistedKey(): Promise<CryptoKey | null>;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* IndexedDB-backed key store. CryptoKey objects are structured-cloned
|
|
21
|
-
* into an object store 'cryptoKeys' so they survive browser restarts.
|
|
22
|
-
*/
|
|
23
|
-
export class InMemoryKeyStore implements ContextKeyStore {
|
|
24
|
-
private db: IDBDatabase | null = null;
|
|
25
|
-
|
|
26
|
-
private getDbName(identityId: string): string {
|
|
27
|
-
return `${KEYS_PREFIX}${identityId}`;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
private async openOrCreate(): Promise<IDBDatabase> {
|
|
31
|
-
if (this.db) return this.db;
|
|
32
|
-
|
|
33
|
-
const name = this.getDbName('default');
|
|
34
|
-
return new Promise<IDBDatabase>((resolve, reject) => {
|
|
35
|
-
const request = indexedDB.open(name, 1);
|
|
36
|
-
|
|
37
|
-
request.onupgradeneeded = () => {
|
|
38
|
-
const db = request.result;
|
|
39
|
-
if (!db.objectStoreNames.contains('keys')) {
|
|
40
|
-
db.createObjectStore('keys', { keyPath: 'key' });
|
|
41
|
-
}
|
|
42
|
-
if (!db.objectStoreNames.contains('cryptoKeys')) {
|
|
43
|
-
db.createObjectStore('cryptoKeys', { keyPath: 'key' });
|
|
44
|
-
}
|
|
45
|
-
};
|
|
46
|
-
|
|
47
|
-
request.onsuccess = () => {
|
|
48
|
-
this.db = request.result;
|
|
49
|
-
resolve(request.result);
|
|
50
|
-
};
|
|
51
|
-
|
|
52
|
-
request.onerror = () => reject(new Error('Keys DB open failed'));
|
|
53
|
-
});
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
async setCacheKey(key: CryptoKey): Promise<void> {
|
|
57
|
-
this.cacheKey = key;
|
|
58
|
-
const db = await this.openOrCreate();
|
|
59
|
-
return new Promise<void>((resolve, reject) => {
|
|
60
|
-
const tx = db.transaction('cryptoKeys', 'readwrite');
|
|
61
|
-
const store = tx.objectStore('cryptoKeys');
|
|
62
|
-
store.put({ key: 'cacheKey', cryptoKey: key });
|
|
63
|
-
|
|
64
|
-
tx.oncomplete = () => resolve();
|
|
65
|
-
tx.onerror = () => reject(new Error('setCacheKey failed: ' + tx.error?.message));
|
|
66
|
-
});
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
private cacheKey: CryptoKey | null = null;
|
|
70
|
-
|
|
71
|
-
async getCacheKey(): Promise<CryptoKey | null> {
|
|
72
|
-
if (this.cacheKey) return this.cacheKey;
|
|
73
|
-
return this.loadPersistedKey();
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
async deleteCacheKey(): Promise<void> {
|
|
77
|
-
this.cacheKey = null;
|
|
78
|
-
const db = await this.openOrCreate();
|
|
79
|
-
return new Promise<void>((resolve, reject) => {
|
|
80
|
-
const tx = db.transaction('cryptoKeys', 'readwrite');
|
|
81
|
-
const store = tx.objectStore('cryptoKeys');
|
|
82
|
-
store.delete('cacheKey');
|
|
83
|
-
|
|
84
|
-
tx.oncomplete = () => resolve();
|
|
85
|
-
tx.onerror = () => reject(new Error('deleteCacheKey failed: ' + tx.error?.message));
|
|
86
|
-
});
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
async persistCacheKey(): Promise<void> {
|
|
90
|
-
const key = this.cacheKey;
|
|
91
|
-
if (!key) {
|
|
92
|
-
throw new Error('No cache key to persist');
|
|
93
|
-
}
|
|
94
|
-
const db = await this.openOrCreate();
|
|
95
|
-
return new Promise<void>((resolve, reject) => {
|
|
96
|
-
const tx = db.transaction('cryptoKeys', 'readwrite');
|
|
97
|
-
const store = tx.objectStore('cryptoKeys');
|
|
98
|
-
store.put({ key: 'cacheKey', cryptoKey: key });
|
|
99
|
-
|
|
100
|
-
tx.oncomplete = () => resolve();
|
|
101
|
-
tx.onerror = () => reject(new Error('persistCacheKey failed: ' + tx.error?.message));
|
|
102
|
-
});
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
async loadPersistedKey(): Promise<CryptoKey | null> {
|
|
106
|
-
const db = await this.openOrCreate();
|
|
107
|
-
return new Promise<CryptoKey | null>((resolve, reject) => {
|
|
108
|
-
const tx = db.transaction('cryptoKeys', 'readonly');
|
|
109
|
-
const store = tx.objectStore('cryptoKeys');
|
|
110
|
-
const request = store.get('cacheKey');
|
|
111
|
-
|
|
112
|
-
request.onsuccess = () => {
|
|
113
|
-
const record = request.result;
|
|
114
|
-
if (!record || !record.cryptoKey) {
|
|
115
|
-
resolve(null);
|
|
116
|
-
return;
|
|
117
|
-
}
|
|
118
|
-
resolve(record.cryptoKey);
|
|
119
|
-
};
|
|
120
|
-
|
|
121
|
-
request.onerror = () => resolve(null);
|
|
122
|
-
});
|
|
123
|
-
}
|
|
124
|
-
}
|