release(v4.0.0): Shade GA — V3.x consolidation + audit prep
Some checks failed
Test / test (push) Has been cancelled
Cross-platform vectors / TypeScript vectors (bun) (push) Has been cancelled
Cross-platform vectors / Kotlin vectors (gradle) (push) Has been cancelled
Docker build and publish / docker (push) Has been cancelled
Publish / publish (push) Has been cancelled

V3.1 → V3.12 consolidated and tagged for the first GA release. Wire
format unchanged from 0.4.x — 4.0 peers interoperate with 0.4.x peers
byte-for-byte. The version bump is semantic: audit-cycle complete,
opt-in surface fully exposed, threat model refreshed for every new
surface.

Highlights:
- All 24 @shade/* packages bumped to 4.0.0 in lockstep.
- CHANGELOG 4.0.0 section is the canonical manifest of what landed.
- THREAT-MODEL extended (§10 fingerprint gates, §11 WebRTC P2P, §12
  Web-Worker boundary) + residual-risks table refreshed.
- OpenAPI now covers all 27 routes: prekey, transfer, KT, inbox,
  bridge, observer, /metrics, /healthz, /ready.
- MIGRATION 0.3.x → 4.0 documented + smoke-tested against
  shade migrate-storage on a real SQLite DB.
- docs/audit/REVIEW-BUNDLE.md + SCOPE.md ready for external reviewer.
- scripts/soak.ts harness for the GA-stable 2-week soak window.
- All V*.md plans archived under docs/archive/ with Status: Done.
- Voice/Video carved out into V5.0; 4.0 audit focuses on the frozen
  non-realtime stack.

Tests: TS 1000/1000 + Kotlin 11/11 cross-platform vectors green.
Docker: gt.zyon.no/stian/shade-prekey:4.0.0 builds and reports
  version 4.0.0 on /health.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-03 18:35:35 +02:00
parent 8b055912b7
commit e6fdf31b49
298 changed files with 37909 additions and 256 deletions

View File

@@ -1,10 +1,11 @@
{
"name": "@shade/widgets",
"version": "0.3.0",
"version": "4.0.0",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
"dependencies": {
"@shade/recovery": "workspace:*",
"@shade/sdk": "workspace:*",
"@shade/streams": "workspace:*",
"@shade/transfer": "workspace:*"

View File

@@ -2,19 +2,51 @@ import React, { useState } from 'react';
import { useShadeState } from '../useShadeState.js';
import { useShadeContext } from '../ShadeProvider.js';
import { WidgetShell } from './shared.js';
import { formatOobText } from './FingerprintGate.js';
export interface FingerprintCompareProps {
/**
* Optional callback invoked when the user clicks "I have verified" after
* confirming a peer's fingerprint matches. The widget passes the
* normalized fingerprint that was compared. Wire this to
* `Shade.markPeerVerified(address)` in your app.
*/
onVerified?: (fingerprint: string) => void | Promise<void>;
/** Optional peer address to display alongside the OOB-copy text. */
peerAddress?: string;
}
/**
* FingerprintCompare — paste a fingerprint and check if it matches your own
* or any active session.
* (or, when wired with `onVerified`, manually accept a peer's fingerprint).
*
* V3.3: extended with "Copy OOB text" + "I have verified" actions so the
* widget can drive `Shade.markPeerVerified` directly.
*/
export function FingerprintCompare(): React.ReactElement {
export function FingerprintCompare(props: FingerprintCompareProps = {}): React.ReactElement {
const { state } = useShadeState();
const { theme } = useShadeContext();
const [input, setInput] = useState('');
const [copied, setCopied] = useState(false);
const normalized = input.replace(/\s+/g, ' ').trim();
const yourFp = state?.identity.fingerprint?.replace(/\s+/g, ' ').trim();
const matches = normalized && yourFp && normalized === yourFp;
const matches = normalized.length > 0 && yourFp !== undefined && normalized === yourFp;
const copyOob = async () => {
if (yourFp === undefined) return;
const text = formatOobText(props.peerAddress ?? 'me', yourFp);
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText !== undefined) {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
}
};
const verify = async () => {
if (normalized.length === 0) return;
await props.onVerified?.(normalized);
};
return (
<WidgetShell title="Verify fingerprint">
@@ -38,7 +70,7 @@ export function FingerprintCompare(): React.ReactElement {
boxSizing: 'border-box',
}}
/>
{normalized && (
{normalized.length > 0 && (
<div
style={{
padding: 10,
@@ -54,6 +86,50 @@ export function FingerprintCompare(): React.ReactElement {
{matches ? '✓ Matches your identity' : '✗ Does not match your identity'}
</div>
)}
<div style={{ display: 'flex', gap: 8 }}>
<button
type="button"
onClick={() => void copyOob()}
disabled={yourFp === undefined}
style={{
flex: 1,
background: 'transparent',
color: theme.text,
border: `1px solid ${theme.border}`,
borderRadius: 6,
padding: '6px 12px',
fontFamily: theme.font,
fontSize: 12,
fontWeight: 600,
cursor: yourFp === undefined ? 'not-allowed' : 'pointer',
opacity: yourFp === undefined ? 0.5 : 1,
}}
>
{copied ? 'Copied!' : 'Copy OOB text'}
</button>
{props.onVerified !== undefined && (
<button
type="button"
onClick={() => void verify()}
disabled={normalized.length === 0}
style={{
flex: 1,
background: theme.accent,
color: theme.bg,
border: `1px solid ${theme.accent}`,
borderRadius: 6,
padding: '6px 12px',
fontFamily: theme.font,
fontSize: 12,
fontWeight: 600,
cursor: normalized.length === 0 ? 'not-allowed' : 'pointer',
opacity: normalized.length === 0 ? 0.5 : 1,
}}
>
I have verified
</button>
)}
</div>
</div>
</WidgetShell>
);

View File

@@ -0,0 +1,213 @@
import React, { useCallback, useEffect, useState } from 'react';
import type { Shade } from '@shade/sdk';
import { useShadeRuntime, useShadeRuntimeTheme } from '../ShadeRuntimeProvider.js';
import type { ShadeTheme } from '../theme.js';
export interface FingerprintGateProps {
/** The peer address that the gated UI talks to. */
peerAddress: string;
/**
* Optional render-prop that replaces the default "verify first" UI.
* Receives the peer's safety number and a callback that marks the peer
* verified (after the user confirmed OOB).
*/
fallback?: (props: FingerprintGateFallbackProps) => React.ReactNode;
/** Called when the peer transitions from unverified → verified. */
onVerified?: () => void;
children: React.ReactNode;
}
export interface FingerprintGateFallbackProps {
peerAddress: string;
fingerprint: string;
/** Mark the peer verified at the current fingerprint. */
verify: () => Promise<void>;
/** Copy a copy-pasteable OOB text for the safety number. */
copyOob: () => Promise<void>;
}
/**
* `<FingerprintGate />` — blocks rendering of `children` until the
* `peerAddress` has been verified at its current fingerprint. Pairs with
* the V3.3 `Shade.beforeFirstLargeFile` / `Shade.beforeBackupImport` /
* `Shade.beforeNewDeviceTrust` hooks to expose verification in UI flows
* outside the SDK-driven gates.
*
* SSR-safe: the gate fetches state inside `useEffect`, so initial render
* shows nothing until the runtime resolves verification status.
*/
export function FingerprintGate(props: FingerprintGateProps): React.ReactElement | null {
const runtime = useShadeRuntime();
const theme = useShadeRuntimeTheme();
const status = useGateStatus(runtime, props.peerAddress);
const verify = useCallback(async () => {
await runtime.markPeerVerified(props.peerAddress);
status.refresh();
}, [runtime, props.peerAddress, status]);
const copyOob = useCallback(async () => {
if (status.fingerprint === null) return;
await copyToClipboard(formatOobText(props.peerAddress, status.fingerprint));
}, [props.peerAddress, status.fingerprint]);
useEffect(() => {
if (status.verified === true) props.onVerified?.();
}, [status.verified, props.onVerified, props]);
if (status.verified === null) return null; // SSR / first paint
if (status.verified === true) return <>{props.children}</>;
if (status.fingerprint === null) return null; // session not ready yet
if (props.fallback !== undefined) {
return (
<>{props.fallback({
peerAddress: props.peerAddress,
fingerprint: status.fingerprint,
verify,
copyOob,
})}</>
);
}
return (
<DefaultFallback
peerAddress={props.peerAddress}
fingerprint={status.fingerprint}
theme={theme}
verify={verify}
copyOob={copyOob}
/>
);
}
interface GateStatus {
verified: boolean | null;
fingerprint: string | null;
refresh: () => void;
}
function useGateStatus(runtime: Shade, peerAddress: string): GateStatus {
const [verified, setVerified] = useState<boolean | null>(null);
const [fingerprint, setFingerprint] = useState<string | null>(null);
const [tick, setTick] = useState(0);
useEffect(() => {
let cancelled = false;
void (async () => {
try {
const fp = await runtime.getFingerprintFor(peerAddress);
if (cancelled) return;
setFingerprint(fp);
const v = await runtime.isPeerVerified(peerAddress);
if (cancelled) return;
setVerified(v);
} catch {
// No session yet — leave both null so the component renders nothing
// until a session is established.
if (cancelled) return;
setFingerprint(null);
setVerified(null);
}
})();
return () => {
cancelled = true;
};
}, [runtime, peerAddress, tick]);
const refresh = useCallback(() => setTick((n) => n + 1), []);
return { verified, fingerprint, refresh };
}
function DefaultFallback(props: {
peerAddress: string;
fingerprint: string;
theme: ShadeTheme;
verify: () => Promise<void>;
copyOob: () => Promise<void>;
}): React.ReactElement {
const { theme } = props;
return (
<div
style={{
background: theme.bg,
border: `1px solid ${theme.border}`,
borderRadius: 8,
padding: 16,
color: theme.text,
fontFamily: theme.font,
}}
>
<div style={{ fontSize: 14, fontWeight: 600, marginBottom: 6 }}>
Verify {props.peerAddress} before continuing
</div>
<div style={{ fontSize: 12, color: theme.textMuted, marginBottom: 12 }}>
Compare the safety number below over a side channel (call, SMS, in person).
Approve only after both ends match.
</div>
<pre
style={{
background: theme.bgElevated,
border: `1px solid ${theme.border}`,
borderRadius: 6,
padding: 12,
fontFamily: theme.fontMono,
fontSize: 13,
color: theme.text,
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
margin: 0,
}}
>
{props.fingerprint}
</pre>
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
<button
type="button"
onClick={() => void props.copyOob()}
style={buttonStyle(theme, 'secondary')}
>
Copy OOB text
</button>
<button
type="button"
onClick={() => void props.verify()}
style={buttonStyle(theme, 'primary')}
>
I have verified
</button>
</div>
</div>
);
}
function buttonStyle(theme: ShadeTheme, variant: 'primary' | 'secondary'): React.CSSProperties {
const isPrimary = variant === 'primary';
return {
background: isPrimary ? theme.accent : 'transparent',
color: isPrimary ? theme.bg : theme.text,
border: `1px solid ${isPrimary ? theme.accent : theme.border}`,
borderRadius: 6,
padding: '6px 12px',
fontFamily: theme.font,
fontSize: 12,
fontWeight: 600,
cursor: 'pointer',
};
}
/**
* Build the OOB text that gets copied to the clipboard. Includes the peer
* address so the recipient knows which conversation it belongs to.
*/
export function formatOobText(peerAddress: string, fingerprint: string): string {
return `Shade safety number for ${peerAddress}:\n${fingerprint}`;
}
async function copyToClipboard(text: string): Promise<void> {
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText !== undefined) {
await navigator.clipboard.writeText(text);
return;
}
// Headless / SSR: silently no-op.
}

View File

@@ -0,0 +1,253 @@
/**
* `<RecoveryApprove />` — guardian-side widget for V3.10 social key
* recovery.
*
* Renders one approval card per pending recovery request. Each card
* shows:
* - Who is asking (the requester's Shade address).
* - The original (lost) device's setup-time fingerprint.
* - The new device's TEMPORARY fingerprint — the value the user
* MUST verify OOB before approving.
*
* The widget enforces V3.10 acceptance criterion #3: an approval is
* blocked behind a "fingerprint matches" checkbox AND an "I have
* verified OOB" checkbox before the green button is even clickable.
*
* The widget does NOT call `attachGuardian` itself — instead, the host
* application calls `attachGuardian({ approve: ... })` and forwards
* the approval into a queue this component consumes via the
* `pending` + `onResolve` props. That way the same approve channel
* can be shared between this UI and any out-of-app surface (push
* notification, OS-level prompt, hardware confirmation, etc.).
*/
import React, { useMemo, useState } from 'react';
import type { GuardianApproveContext } from '@shade/recovery';
import { useShadeRuntimeTheme } from '../../ShadeRuntimeProvider.js';
export interface PendingApproval {
/** Stable id used to correlate approve/decline with the deferred promise. */
id: string;
/** Recovery context handed to the approve handler. */
ctx: GuardianApproveContext;
}
export interface RecoveryApproveProps {
/** Pending requests waiting on the user. Caller updates this list. */
pending: ReadonlyArray<PendingApproval>;
/**
* Called when the user picks a side. The host wires this to resolve
* the deferred promise the `attachGuardian` approve callback is
* blocked on.
*/
onResolve: (id: string, decision: boolean) => void;
}
export function RecoveryApprove(props: RecoveryApproveProps): React.ReactElement {
const theme = useShadeRuntimeTheme();
if (props.pending.length === 0) {
return (
<div
style={{
padding: 12,
borderRadius: theme.radius,
border: `1px dashed ${theme.border}`,
color: theme.textMuted,
fontSize: 12,
fontFamily: theme.font,
textAlign: 'center',
}}
>
No pending recovery requests.
</div>
);
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{props.pending.map((p) => (
<ApprovalCard key={p.id} approval={p} onResolve={props.onResolve} />
))}
</div>
);
}
function ApprovalCard(props: {
approval: PendingApproval;
onResolve: (id: string, decision: boolean) => void;
}): React.ReactElement {
const theme = useShadeRuntimeTheme();
const { ctx } = props.approval;
const [matches, setMatches] = useState(false);
const [confirmedOob, setConfirmedOob] = useState(false);
const requestedAt = useMemo(() => new Date(ctx.requestReceivedAt).toISOString(), [ctx]);
const setupAt = useMemo(() => new Date(ctx.depositCreatedAt).toISOString(), [ctx]);
return (
<div
style={{
background: theme.bgElevated,
border: `1px solid ${theme.border}`,
borderRadius: theme.radius,
padding: 16,
color: theme.text,
fontFamily: theme.font,
}}
>
<div style={{ fontSize: 14, fontWeight: 600, marginBottom: 4 }}>
Recovery request for {ctx.originalAddress}
</div>
<div style={{ fontSize: 11, color: theme.textMuted, marginBottom: 10 }}>
Requester: <span style={{ fontFamily: theme.fontMono }}>{ctx.requesterAddress}</span>
<br />
Original deposit: {setupAt}, request received: {requestedAt}
</div>
<div style={{ marginBottom: 10 }}>
<div style={{ fontSize: 11, color: theme.textMuted, marginBottom: 4 }}>
Original device fingerprint (at setup):
</div>
<pre
style={{
margin: 0,
padding: 10,
borderRadius: 6,
background: theme.bg,
border: `1px solid ${theme.border}`,
fontFamily: theme.fontMono,
fontSize: 12,
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
}}
>
{ctx.setupFingerprint}
</pre>
</div>
<div style={{ marginBottom: 10 }}>
<div style={{ fontSize: 11, color: theme.textMuted, marginBottom: 4 }}>
New device fingerprint (verify OOB before approving):
</div>
<pre
style={{
margin: 0,
padding: 10,
borderRadius: 6,
background: theme.bg,
border: `1px solid ${theme.accent}`,
fontFamily: theme.fontMono,
fontSize: 12,
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
}}
>
{ctx.requesterFingerprint}
</pre>
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, marginBottom: 6 }}>
<input
type="checkbox"
checked={matches}
onChange={(e) => setMatches(e.target.checked)}
/>
I confirm the new device's fingerprint matches what the requester read aloud.
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, marginBottom: 12 }}>
<input
type="checkbox"
checked={confirmedOob}
onChange={(e) => setConfirmedOob(e.target.checked)}
/>
I verified the requester's identity over a side channel (call, in person), not Shade itself.
</label>
<div style={{ display: 'flex', gap: 8 }}>
<button
type="button"
onClick={() => props.onResolve(props.approval.id, false)}
style={{
flex: 1,
background: 'transparent',
color: theme.text,
border: `1px solid ${theme.border}`,
borderRadius: 6,
padding: '8px 14px',
fontSize: 12,
fontWeight: 600,
cursor: 'pointer',
}}
>
Decline
</button>
<button
type="button"
disabled={!matches || !confirmedOob}
onClick={() => props.onResolve(props.approval.id, true)}
style={{
flex: 1,
background: matches && confirmedOob ? theme.accent : 'transparent',
color: matches && confirmedOob ? theme.bg : theme.textMuted,
border: `1px solid ${matches && confirmedOob ? theme.accent : theme.border}`,
borderRadius: 6,
padding: '8px 14px',
fontSize: 12,
fontWeight: 600,
cursor: matches && confirmedOob ? 'pointer' : 'not-allowed',
}}
>
Release share
</button>
</div>
</div>
);
}
/**
* Tiny helper that turns the `attachGuardian({ approve })` callback
* shape into a deferred queue the `<RecoveryApprove />` widget can
* consume. Returns a tuple of:
*
* - `approve(ctx)`: the function to pass to `attachGuardian`.
* - `pending`: live list of pending approvals (consumer is expected
* to mirror this into React state via subscribe()).
* - `subscribe(listener)`: register for change notifications.
* - `resolve(id, decision)`: the value the widget calls.
*/
export function createApprovalQueue(): {
approve: (ctx: GuardianApproveContext) => Promise<boolean>;
pending: () => PendingApproval[];
subscribe: (listener: () => void) => () => void;
resolve: (id: string, decision: boolean) => void;
} {
const queue: Array<{
id: string;
ctx: GuardianApproveContext;
resolve: (decision: boolean) => void;
}> = [];
const listeners = new Set<() => void>();
const notify = (): void => {
for (const l of listeners) l();
};
return {
approve: (ctx) =>
new Promise<boolean>((resolveDecision) => {
const id = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
queue.push({ id, ctx, resolve: resolveDecision });
notify();
}),
pending: () => queue.map((q) => ({ id: q.id, ctx: q.ctx })),
subscribe: (listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
},
resolve: (id, decision) => {
const idx = queue.findIndex((q) => q.id === id);
if (idx === -1) return;
const [entry] = queue.splice(idx, 1);
entry!.resolve(decision);
notify();
},
};
}

View File

@@ -0,0 +1,274 @@
/**
* `<RecoveryRequest />` — new-device widget for V3.10 social key
* recovery.
*
* The user provides the recovery-card values they recorded at setup
* time (originalAddress, setupId, threshold, guardian list). The
* widget shows the new device's TEMPORARY identity fingerprint
* prominently — the user reads it OOB to each guardian to authorize
* the share release — then runs `requestRecovery` and reports
* progress as guardians respond.
*
* On success the widget surfaces the restored fingerprint so the
* user can sanity-check it against the value on the recovery card
* before continuing in the app.
*/
import React, { useEffect, useMemo, useState } from 'react';
import {
RecoveryDeclinedError,
RecoveryReconstructionError,
RecoveryTimeoutError,
requestRecovery,
type RecoveryDeliver,
type RecoveryProgress,
type RecoveryResult,
} from '@shade/recovery';
import { useShadeRuntime, useShadeRuntimeTheme } from '../../ShadeRuntimeProvider.js';
import { formatOobText } from '../FingerprintGate.js';
export interface RecoveryRequestProps {
/** Address of the original (lost) identity. From the recovery card. */
originalAddress: string;
/** Setup id from the recovery card. */
setupId: string;
/** Reconstruction threshold. From the recovery card. */
threshold: number;
/** Guardian addresses to query. Caller can preselect or let user choose. */
guardians: ReadonlyArray<string>;
/** Outbound transport callback. */
deliver: RecoveryDeliver;
/** Override timeout in ms (defaults to 5 min). */
timeoutMs?: number;
/** Fired on successful recovery. */
onComplete?: (result: RecoveryResult) => void;
/** Fired on terminal failure. */
onError?: (err: Error) => void;
}
type Phase =
| { kind: 'idle' }
| { kind: 'running'; progress: RecoveryProgress | null }
| { kind: 'done'; result: RecoveryResult }
| { kind: 'failed'; error: Error };
export function RecoveryRequest(props: RecoveryRequestProps): React.ReactElement {
const runtime = useShadeRuntime();
const theme = useShadeRuntimeTheme();
const [phase, setPhase] = useState<Phase>({ kind: 'idle' });
const [tempFingerprint, setTempFingerprint] = useState<string | null>(null);
const [copyHit, setCopyHit] = useState(false);
const guardianList = useMemo(() => [...props.guardians], [props.guardians]);
useEffect(() => {
let cancelled = false;
void (async () => {
try {
const fp = await runtime.fingerprint;
if (!cancelled) setTempFingerprint(fp);
} catch {
if (!cancelled) setTempFingerprint(null);
}
})();
return () => {
cancelled = true;
};
}, [runtime]);
const start = async (): Promise<void> => {
setPhase({ kind: 'running', progress: null });
try {
const result = await requestRecovery({
shade: runtime,
originalAddress: props.originalAddress,
setupId: props.setupId,
threshold: props.threshold,
guardians: guardianList,
deliver: props.deliver,
...(props.timeoutMs !== undefined ? { timeoutMs: props.timeoutMs } : {}),
onProgress: (progress) => setPhase({ kind: 'running', progress }),
});
setPhase({ kind: 'done', result });
props.onComplete?.(result);
} catch (err) {
setPhase({ kind: 'failed', error: err as Error });
props.onError?.(err as Error);
}
};
const copyOob = async (): Promise<void> => {
if (tempFingerprint === null) return;
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText !== undefined) {
const text = formatOobText(runtime.myAddress, tempFingerprint);
await navigator.clipboard.writeText(text);
setCopyHit(true);
setTimeout(() => setCopyHit(false), 1500);
}
};
return (
<div
style={{
background: theme.bgElevated,
border: `1px solid ${theme.border}`,
borderRadius: theme.radius,
padding: 16,
color: theme.text,
fontFamily: theme.font,
}}
>
<div style={{ fontSize: 14, fontWeight: 600, marginBottom: 8 }}>
Recover identity for {props.originalAddress}
</div>
<div style={{ fontSize: 12, color: theme.textMuted, marginBottom: 10 }}>
Read the safety number below to each guardian over a side channel (call, in person)
BEFORE asking them to approve. They will only release their share if your fingerprint
matches what they verify.
</div>
<div
style={{
padding: 10,
borderRadius: 6,
border: `1px solid ${theme.border}`,
background: theme.bg,
fontFamily: theme.fontMono,
fontSize: 12,
marginBottom: 10,
whiteSpace: 'pre-wrap',
}}
>
{tempFingerprint ?? 'Loading temporary fingerprint…'}
</div>
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
<button
type="button"
onClick={() => void copyOob()}
disabled={tempFingerprint === null}
style={{
background: 'transparent',
color: theme.text,
border: `1px solid ${theme.border}`,
borderRadius: 6,
padding: '6px 10px',
fontSize: 12,
cursor: tempFingerprint === null ? 'not-allowed' : 'pointer',
}}
>
{copyHit ? 'Copied!' : 'Copy OOB text'}
</button>
<button
type="button"
disabled={phase.kind === 'running' || tempFingerprint === null}
onClick={() => void start()}
style={{
background: theme.accent,
color: theme.bg,
border: `1px solid ${theme.accent}`,
borderRadius: 6,
padding: '6px 12px',
fontSize: 12,
fontWeight: 600,
cursor: phase.kind === 'running' ? 'not-allowed' : 'pointer',
opacity: phase.kind === 'running' ? 0.5 : 1,
}}
>
{phase.kind === 'running' ? 'Requesting…' : 'Request recovery'}
</button>
</div>
{phase.kind === 'running' && phase.progress !== null && (
<ProgressLine progress={phase.progress} theme={theme} />
)}
{phase.kind === 'done' && (
<div
style={{
padding: 10,
borderRadius: 6,
border: `1px solid ${theme.success}`,
background: 'rgba(34, 197, 94, 0.08)',
color: theme.text,
fontSize: 12,
}}
>
<div style={{ fontWeight: 600, color: theme.success, marginBottom: 4 }}>
Identity restored
</div>
<div>
Restored fingerprint:
<div
style={{
fontFamily: theme.fontMono,
fontSize: 12,
marginTop: 4,
whiteSpace: 'pre-wrap',
}}
>
{phase.result.restoredFingerprint}
</div>
</div>
<div style={{ marginTop: 4, fontSize: 11, color: theme.textMuted }}>
Compare this against the value on your recovery card before continuing.
</div>
</div>
)}
{phase.kind === 'failed' && (
<div
style={{
padding: 10,
borderRadius: 6,
border: `1px solid ${theme.danger}`,
background: 'rgba(239, 68, 68, 0.08)',
color: theme.danger,
fontSize: 12,
}}
>
<div style={{ fontWeight: 600, marginBottom: 4 }}>
{failureHeadline(phase.error)}
</div>
<div style={{ color: theme.text, fontSize: 11 }}>{phase.error.message}</div>
</div>
)}
</div>
);
}
function ProgressLine(props: {
progress: RecoveryProgress;
theme: ReturnType<typeof useShadeRuntimeTheme>;
}): React.ReactElement {
const { progress, theme } = props;
return (
<div
style={{
padding: 10,
borderRadius: 6,
border: `1px solid ${theme.border}`,
background: theme.bg,
fontSize: 12,
color: theme.text,
}}
>
<div>
Granted: {progress.granted} / {progress.threshold} Declined: {progress.declined}
Pending: {progress.pending}
</div>
{progress.fromAddress !== undefined && (
<div style={{ color: theme.textMuted, fontSize: 11, marginTop: 4 }}>
{progress.fromAddress}: {progress.latest === 'grant' ? 'granted' : 'declined'}
</div>
)}
</div>
);
}
function failureHeadline(err: Error): string {
if (err instanceof RecoveryDeclinedError) return 'Too many guardians declined';
if (err instanceof RecoveryTimeoutError) return 'Timed out waiting for guardians';
if (err instanceof RecoveryReconstructionError)
return 'Reconstruction failed — at least one share is invalid';
return 'Recovery failed';
}

View File

@@ -0,0 +1,280 @@
/**
* `<RecoverySetup />` — primary-device widget for V3.10 social key
* recovery setup.
*
* Lets the user pick guardians, choose a threshold (k-of-n), and run
* `setupRecovery` from `@shade/recovery`. The component is fully
* controlled — guardian addresses come from a prop the consumer
* populates from their address book; the widget never tries to read
* the host app's contact list directly.
*
* After a successful run, the widget displays the `setupId` and the
* setup-time fingerprint with a "Copy recovery card" action so the
* user can record the data they'll need to retrieve their identity
* later (setupId, threshold, guardian addresses, original
* fingerprint).
*/
import React, { useMemo, useState } from 'react';
import {
setupRecovery,
type RecoveryDeliver,
type SetupRecoveryResult,
} from '@shade/recovery';
import { useShadeRuntime, useShadeRuntimeTheme } from '../../ShadeRuntimeProvider.js';
export interface RecoverySetupProps {
/**
* Candidate guardian directory. The widget shows them as toggleable
* chips. Consumer is expected to source these from the host app's
* existing peer / contacts list.
*/
candidates: ReadonlyArray<{ address: string; label?: string }>;
/**
* Outbound transport callback — same shape as `setupRecovery({
* deliver })`. Wires through the host app's message-delivery layer.
*/
deliver: RecoveryDeliver;
/**
* Optional list of peer addresses whose Double-Ratchet sessions
* should be embedded in the backup blob. Defaults to `[]` (identity +
* prekeys only, no sessions).
*/
knownAddresses?: ReadonlyArray<string>;
/**
* Default threshold proposed in the slider. Defaults to
* `Math.ceil(candidates.length / 2)`.
*/
defaultThreshold?: number;
/** Fired after a successful setup completes. */
onComplete?: (result: SetupRecoveryResult) => void;
/** Fired if setup throws. */
onError?: (err: Error) => void;
}
/**
* Primary-device widget. Use inside `<ShadeRuntimeProvider runtime>`.
*/
export function RecoverySetup(props: RecoverySetupProps): React.ReactElement {
const runtime = useShadeRuntime();
const theme = useShadeRuntimeTheme();
const candidates = useMemo(() => [...props.candidates], [props.candidates]);
const [selected, setSelected] = useState<Set<string>>(() => new Set());
const [threshold, setThreshold] = useState<number>(
() => props.defaultThreshold ?? Math.ceil(Math.max(1, candidates.length / 2)),
);
const [busy, setBusy] = useState(false);
const [result, setResult] = useState<SetupRecoveryResult | null>(null);
const [error, setError] = useState<string | null>(null);
const guardianList = candidates.filter((c) => selected.has(c.address)).map((c) => c.address);
const validThreshold = threshold >= 1 && threshold <= guardianList.length;
const canRun = !busy && guardianList.length >= 1 && validThreshold;
const toggle = (addr: string): void => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(addr)) next.delete(addr);
else next.add(addr);
return next;
});
};
const run = async (): Promise<void> => {
if (!canRun) return;
setBusy(true);
setError(null);
setResult(null);
try {
const r = await setupRecovery({
shade: runtime,
guardians: guardianList,
threshold,
deliver: props.deliver,
knownAddresses: [...(props.knownAddresses ?? [])],
});
setResult(r);
props.onComplete?.(r);
} catch (err) {
const message = (err as Error).message;
setError(message);
props.onError?.(err as Error);
} finally {
setBusy(false);
}
};
const copyCard = async (): Promise<void> => {
if (result === null) return;
const text = formatRecoveryCard(runtime.myAddress, result, guardianList);
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText !== undefined) {
await navigator.clipboard.writeText(text);
}
};
return (
<div
style={{
background: theme.bgElevated,
border: `1px solid ${theme.border}`,
borderRadius: theme.radius,
padding: 16,
color: theme.text,
fontFamily: theme.font,
}}
>
<div style={{ fontSize: 14, fontWeight: 600, marginBottom: 8 }}>
Set up social key recovery
</div>
<div style={{ fontSize: 12, color: theme.textMuted, marginBottom: 12 }}>
Pick guardians (people who can help you recover this identity if you lose your device)
and a threshold. Any threshold-many guardians together can restore fewer cannot.
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 12 }}>
{candidates.map((c) => {
const on = selected.has(c.address);
return (
<button
key={c.address}
type="button"
onClick={() => toggle(c.address)}
style={{
padding: '6px 10px',
borderRadius: 6,
fontSize: 12,
fontFamily: theme.fontMono,
border: `1px solid ${on ? theme.accent : theme.border}`,
background: on ? theme.accent : 'transparent',
color: on ? theme.bg : theme.text,
cursor: 'pointer',
}}
>
{c.label ?? c.address}
</button>
);
})}
</div>
<div style={{ marginBottom: 12 }}>
<label style={{ fontSize: 12, color: theme.textMuted, display: 'block', marginBottom: 4 }}>
Threshold (k-of-{Math.max(1, guardianList.length)}): {threshold}
</label>
<input
type="range"
min={1}
max={Math.max(1, guardianList.length)}
step={1}
value={Math.min(threshold, Math.max(1, guardianList.length))}
onChange={(e) => setThreshold(parseInt(e.target.value, 10))}
style={{ width: '100%' }}
/>
</div>
<button
type="button"
disabled={!canRun}
onClick={() => void run()}
style={{
background: theme.accent,
color: theme.bg,
border: `1px solid ${theme.accent}`,
borderRadius: 6,
padding: '8px 14px',
fontFamily: theme.font,
fontSize: 12,
fontWeight: 600,
cursor: canRun ? 'pointer' : 'not-allowed',
opacity: canRun ? 1 : 0.5,
}}
>
{busy ? 'Distributing shares…' : `Distribute ${threshold}-of-${guardianList.length}`}
</button>
{error !== null && (
<div
style={{
marginTop: 12,
padding: 10,
borderRadius: 6,
border: `1px solid ${theme.danger}`,
background: 'rgba(239, 68, 68, 0.08)',
color: theme.danger,
fontSize: 12,
}}
>
{error}
</div>
)}
{result !== null && (
<div
style={{
marginTop: 12,
padding: 10,
borderRadius: 6,
border: `1px solid ${theme.success}`,
background: 'rgba(34, 197, 94, 0.08)',
color: theme.text,
fontSize: 12,
display: 'flex',
flexDirection: 'column',
gap: 6,
}}
>
<div style={{ fontWeight: 600, color: theme.success }}>
Distributed to {result.deliveries.filter((d) => d.error === null).length} of {result.guardianCount}
</div>
<div>
setupId: <span style={{ fontFamily: theme.fontMono }}>{result.setupId}</span>
</div>
<div style={{ fontSize: 11 }}>
Save this id (or click "Copy recovery card") together with the guardian list. You'll
need both on the new device.
</div>
<button
type="button"
onClick={() => void copyCard()}
style={{
alignSelf: 'flex-start',
background: 'transparent',
color: theme.text,
border: `1px solid ${theme.border}`,
borderRadius: 6,
padding: '6px 10px',
fontSize: 11,
cursor: 'pointer',
}}
>
Copy recovery card
</button>
</div>
)}
</div>
);
}
/**
* Build the human-readable "recovery card" the user should record on
* paper / a second device. Plain text on purpose — no QR, no JSON —
* so the user can transcribe it manually if needed.
*/
export function formatRecoveryCard(
ownAddress: string,
result: SetupRecoveryResult,
guardians: ReadonlyArray<string>,
): string {
const lines: string[] = [];
lines.push('Shade recovery card');
lines.push('=====================');
lines.push(`Owner: ${ownAddress}`);
lines.push(`Threshold: ${result.threshold} of ${result.guardianCount}`);
lines.push(`SetupId: ${result.setupId}`);
lines.push(`Setup fingerprint: ${result.setupFingerprint}`);
lines.push('Guardians:');
for (const g of guardians) lines.push(` - ${g}`);
lines.push('');
lines.push('Keep this card somewhere safe but accessible. You will need ALL of these');
lines.push('values on a new device to begin recovery.');
return lines.join('\n');
}

View File

@@ -0,0 +1,117 @@
import React, { useEffect, useRef, useState } from 'react';
import {
isAllowedThumbnailMime,
THUMBNAIL_MAX_BYTES,
type ThumbnailMime,
} from '@shade/sdk';
import { useShadeRuntimeTheme } from '../../ShadeRuntimeProvider.js';
export interface ThumbnailPreviewProps {
/** Bytes from `useThumbnail(streamId)`. */
bytes: Uint8Array | null;
/** Declared MIME — must be in the allowlist or the preview is blanked. */
mime: string | null;
/** Side length in CSS px (square). Default 48. */
size?: number;
className?: string;
style?: React.CSSProperties;
/** Override the placeholder shown when no thumbnail is available. */
placeholder?: React.ReactNode;
}
/**
* V3.9 — safe thumbnail renderer.
*
* Defends against malicious image bytes by:
* - refusing to render anything outside `THUMBNAIL_MIME_ALLOWLIST`,
* - capping displayed sizes at `THUMBNAIL_MAX_BYTES` (defensive — the
* cache already enforces this at write-time, but the prop boundary
* is the last chance to catch a caller passing arbitrary bytes),
* - rendering through a Blob URL inside an `<img>` so the browser's
* own image-decoding sandbox is the rendering trust boundary, and
* - revoking the Blob URL on unmount so a long-running app doesn't
* leak handles.
*/
export function ThumbnailPreview({
bytes,
mime,
size,
className,
style,
placeholder,
}: ThumbnailPreviewProps): React.ReactElement {
const theme = useShadeRuntimeTheme();
const [url, setUrl] = useState<string | null>(null);
const lastUrlRef = useRef<string | null>(null);
useEffect(() => {
if (lastUrlRef.current !== null) {
URL.revokeObjectURL(lastUrlRef.current);
lastUrlRef.current = null;
}
if (
bytes === null ||
mime === null ||
!isAllowedThumbnailMime(mime) ||
bytes.byteLength === 0 ||
bytes.byteLength > THUMBNAIL_MAX_BYTES
) {
setUrl(null);
return;
}
const blob = new Blob([bytes as BlobPart], { type: mime });
const next = URL.createObjectURL(blob);
lastUrlRef.current = next;
setUrl(next);
return () => {
if (lastUrlRef.current === next) {
URL.revokeObjectURL(next);
lastUrlRef.current = null;
}
};
}, [bytes, mime]);
const edge = size ?? 48;
const baseStyle: React.CSSProperties = {
width: edge,
height: edge,
flexShrink: 0,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
background: theme.bg,
border: `1px solid ${theme.border}`,
borderRadius: theme.radius,
overflow: 'hidden',
color: theme.textMuted,
fontSize: 11,
...style,
};
if (url === null) {
return (
<div className={className} style={baseStyle}>
{placeholder ?? <span aria-hidden></span>}
</div>
);
}
return (
<div className={className} style={baseStyle}>
<img
src={url}
alt=""
width={edge}
height={edge}
decoding="async"
loading="lazy"
referrerPolicy="no-referrer"
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
display: 'block',
}}
/>
</div>
);
}

View File

@@ -1,10 +1,16 @@
import React from 'react';
import type { TransferHandle, TransferProgress } from '@shade/sdk';
import type {
StreamFileMetadata,
TransferHandle,
TransferProgress,
} from '@shade/sdk';
import { useShadeRuntimeTheme } from '../../ShadeRuntimeProvider.js';
import { useThumbnail } from '../../useShadeThumbnails.js';
import { ProgressBar } from './ProgressBar.js';
import { SpeedReadout } from './SpeedReadout.js';
import { ETAReadout } from './ETAReadout.js';
import { LaneIndicator } from './LaneIndicator.js';
import { ThumbnailPreview } from './ThumbnailPreview.js';
export interface TransferRowProps {
handle: TransferHandle;
@@ -17,6 +23,22 @@ export interface TransferRowProps {
error?: unknown;
onCancel?: () => void;
onDismiss?: () => void;
/**
* V3.9 — render a 48×48 preview to the left of the row when the main
* stream's `fileMetadata` references a thumbnail-stream that has
* arrived in the cache. Off by default (rows stay compact in chat
* lists); turn it on per row, or wrap in `<ShadeThumbnailProvider />`
* + flip on at the row level for a file-browser-style UI.
*/
showThumbnail?: boolean;
/**
* V3.9 — `fileMetadata` from the main `stream-init`. Required when
* `showThumbnail` is true; the row reads `thumbnailStreamId` /
* `thumbnailHash` to consult the cache.
*/
fileMetadata?: StreamFileMetadata;
/** Thumbnail edge in CSS px (default 48). */
thumbnailSize?: number;
className?: string;
style?: React.CSSProperties;
}
@@ -31,14 +53,26 @@ export function TransferRow({
error,
onCancel,
onDismiss,
showThumbnail,
fileMetadata,
thumbnailSize,
className,
style,
}: TransferRowProps): React.ReactElement {
const theme = useShadeRuntimeTheme();
const percent = progress?.percent ?? (bytesTotal !== undefined && progress !== null ? progress.bytesSent / bytesTotal : undefined);
const percent =
progress?.percent ??
(bytesTotal !== undefined && progress !== null ? progress.bytesSent / bytesTotal : undefined);
const errorMessage =
error instanceof Error ? error.message : error !== undefined ? String(error) : null;
const thumb = useThumbnail(
showThumbnail === true ? fileMetadata?.thumbnailStreamId : undefined,
showThumbnail === true ? fileMetadata?.thumbnailHash : undefined,
);
const displayName = name ?? fileMetadata?.filename ?? handle.streamId;
return (
<div
className={className}
@@ -56,19 +90,32 @@ export function TransferRow({
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span
style={{
flex: 1,
fontSize: 13,
color: errorMessage !== null ? theme.danger : theme.text,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
title={name ?? handle.streamId}
>
{name ?? handle.streamId}
</span>
{showThumbnail === true ? (
<ThumbnailPreview
bytes={thumb?.bytes ?? null}
mime={thumb?.mime ?? null}
size={thumbnailSize ?? 48}
/>
) : null}
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minWidth: 0 }}>
<span
style={{
fontSize: 13,
color: errorMessage !== null ? theme.danger : theme.text,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
title={displayName}
>
{displayName}
</span>
{fileMetadata?.mimeType !== undefined && fileMetadata.mimeType.length > 0 ? (
<span style={{ fontSize: 11, color: theme.textMuted }}>
{fileMetadata.mimeType}
</span>
) : null}
</div>
{progress !== null ? (
<LaneIndicator lanes={progress.lanes} />
) : null}

View File

@@ -9,6 +9,12 @@ export { PrekeyStock } from './components/PrekeyStock.js';
export { RecentActivity } from './components/RecentActivity.js';
export { ServerStatus } from './components/ServerStatus.js';
export { FingerprintCompare } from './components/FingerprintCompare.js';
export type { FingerprintCompareProps } from './components/FingerprintCompare.js';
export { FingerprintGate, formatOobText } from './components/FingerprintGate.js';
export type {
FingerprintGateProps,
FingerprintGateFallbackProps,
} from './components/FingerprintGate.js';
export { WidgetCatalog } from './components/WidgetCatalog.js';
export type { ShadeProviderProps, ShadeContextValue } from './ShadeProvider.js';
@@ -43,6 +49,14 @@ export { DropZone } from './components/transfer/DropZone.js';
export type { DropZoneProps } from './components/transfer/DropZone.js';
export { TransferRow } from './components/transfer/TransferRow.js';
export type { TransferRowProps } from './components/transfer/TransferRow.js';
export { ThumbnailPreview } from './components/transfer/ThumbnailPreview.js';
export type { ThumbnailPreviewProps } from './components/transfer/ThumbnailPreview.js';
export {
ShadeThumbnailProvider,
useThumbnailCache,
useThumbnail,
} from './useShadeThumbnails.js';
export type { ThumbnailProviderProps } from './useShadeThumbnails.js';
export { ProgressBar } from './components/transfer/ProgressBar.js';
export type { ProgressBarProps } from './components/transfer/ProgressBar.js';
export { SpeedReadout, formatBytesPerSecond } from './components/transfer/SpeedReadout.js';
@@ -51,3 +65,20 @@ export { ETAReadout, formatEta } from './components/transfer/ETAReadout.js';
export type { ETAReadoutProps } from './components/transfer/ETAReadout.js';
export { LaneIndicator } from './components/transfer/LaneIndicator.js';
export type { LaneIndicatorProps } from './components/transfer/LaneIndicator.js';
// ─── Social Key Recovery widgets (V3.10) ────────────────
export {
RecoverySetup,
formatRecoveryCard,
} from './components/recovery/RecoverySetup.js';
export type { RecoverySetupProps } from './components/recovery/RecoverySetup.js';
export { RecoveryRequest } from './components/recovery/RecoveryRequest.js';
export type { RecoveryRequestProps } from './components/recovery/RecoveryRequest.js';
export {
RecoveryApprove,
createApprovalQueue,
} from './components/recovery/RecoveryApprove.js';
export type {
RecoveryApproveProps,
PendingApproval,
} from './components/recovery/RecoveryApprove.js';

View File

@@ -4,8 +4,12 @@ import type {
TransferHandle,
TransferOutput,
TransferProgress,
TransferResult,
ThumbnailMime,
} from '@shade/sdk';
import { isAllowedThumbnailMime } from '@shade/sdk';
import { useShadeRuntime } from './ShadeRuntimeProvider.js';
import { useThumbnailCache } from './useShadeThumbnails.js';
export interface DownloadEntry {
incoming: IncomingTransfer;
@@ -43,6 +47,7 @@ export function useShadeDownload(
options: UseShadeDownloadOptions = {},
): UseShadeDownloadResult {
const shade = useShadeRuntime();
const thumbnailCache = useThumbnailCache();
const [pending, setPending] = useState<DownloadEntry[]>([]);
const [active, setActive] = useState<DownloadEntry[]>([]);
@@ -102,6 +107,39 @@ export function useShadeDownload(
let cancelled = false;
void (async () => {
const cleanup = await shade.onIncomingTransfer(async (incoming) => {
// V3.9 — auto-accept thumbnail streams into the in-memory cache.
// Sender marks them with `userMetadata.shadeThumbnail === '1'`.
if (incoming.metadata.userMetadata?.shadeThumbnail === '1') {
const handle = await incoming.accept({ output: { kind: 'buffer' } });
void handle
.done()
.then((res: TransferResult) => {
const bytes = (res as TransferResult & { bytes?: Uint8Array }).bytes;
const declaredMime = incoming.metadata.contentType;
if (
bytes !== undefined &&
declaredMime !== undefined &&
isAllowedThumbnailMime(declaredMime)
) {
thumbnailCache.put(
incoming.streamId,
bytes,
declaredMime as ThumbnailMime,
);
}
})
.catch(() => {
/* preview failures are non-fatal */
});
return;
}
// V3.9 — when the main `stream-init` arrives, register the
// expected thumbnail hash so a previously-arrived (but not yet
// verified) thumbnail can be matched + made visible to widgets.
const fm = incoming.metadata.fileMetadata;
if (fm?.thumbnailStreamId !== undefined && fm.thumbnailHash !== undefined) {
thumbnailCache.setExpectedHash(fm.thumbnailStreamId, fm.thumbnailHash);
}
if (options.autoAccept !== undefined) {
const output = await options.autoAccept(incoming);
if (output !== null) {
@@ -126,7 +164,7 @@ export function useShadeDownload(
cancelled = true;
unsubscribe?.();
};
}, [shade, options.autoAccept, trackHandle]);
}, [shade, options.autoAccept, trackHandle, thumbnailCache]);
const accept = useCallback(
async (streamId: string, output: TransferOutput): Promise<TransferHandle> => {

View File

@@ -0,0 +1,88 @@
/**
* V3.9 — thumbnail-cache hook for `<TransferRow showThumbnail />`.
*
* Wires a single shared `ShadeThumbnailCache` into the React tree, lazily
* created on first use, and exposes a tracked `useThumbnail(streamId)`
* helper that re-renders consumers when their thumbnail arrives.
*
* The cache itself lives outside React state so it survives Strict-Mode
* double-mounts and unmounted-component thumbnail arrivals without a
* memory leak — entries evict on LRU + size cap, not on component
* lifecycle.
*/
import {
createContext,
createElement,
useContext,
useEffect,
useMemo,
useState,
type ReactElement,
type ReactNode,
} from 'react';
import { ShadeThumbnailCache, type ThumbnailHit } from '@shade/sdk';
const ThumbnailCacheContext = createContext<ShadeThumbnailCache | null>(null);
export interface ThumbnailProviderProps {
/** Optional shared cache. When omitted the provider creates one. */
cache?: ShadeThumbnailCache;
children?: ReactNode;
}
/**
* Provides a shared `ShadeThumbnailCache` to the subtree. Place once near
* the root of your app (typically just inside `<ShadeRuntimeProvider />`)
* so every transfer row consults the same cache.
*/
export function ShadeThumbnailProvider({
cache,
children,
}: ThumbnailProviderProps): ReactElement {
const value = useMemo(() => cache ?? new ShadeThumbnailCache(), [cache]);
return createElement(
ThumbnailCacheContext.Provider,
{ value },
children,
);
}
/**
* Returns the in-context thumbnail cache, lazily constructing one if no
* provider is mounted (so widgets don't have to demand the provider).
*/
export function useThumbnailCache(): ShadeThumbnailCache {
const ctx = useContext(ThumbnailCacheContext);
// Module-level fallback when no provider is mounted — single instance
// shared across all `useThumbnail()` calls in that scenario.
return ctx ?? fallbackCache();
}
let fallback: ShadeThumbnailCache | null = null;
function fallbackCache(): ShadeThumbnailCache {
if (fallback === null) fallback = new ShadeThumbnailCache();
return fallback;
}
/**
* Returns the cached thumbnail bytes + mime for `streamId`, or `null`
* when nothing is cached yet. Subscribes the calling component to cache
* changes so it re-renders on arrival.
*/
export function useThumbnail(
streamId: string | null | undefined,
expectedHash?: string,
): ThumbnailHit | null {
const cache = useThumbnailCache();
const [, setTick] = useState(0);
useEffect(() => {
if (streamId === null || streamId === undefined) return;
if (expectedHash !== undefined) cache.setExpectedHash(streamId, expectedHash);
return cache.onChange((changed) => {
if (changed === streamId) setTick((n) => n + 1);
});
}, [cache, streamId, expectedHash]);
if (streamId === null || streamId === undefined) return null;
return cache.get(streamId, expectedHash);
}

View File

@@ -0,0 +1,81 @@
import { describe, test, expect } from 'bun:test';
import { formatRecoveryCard } from '../src/components/recovery/RecoverySetup.js';
import { createApprovalQueue } from '../src/components/recovery/RecoveryApprove.js';
describe('formatRecoveryCard', () => {
test('renders all fields the user must keep', () => {
const text = formatRecoveryCard(
'alice@example',
{
setupId: 'sid-123',
threshold: 3,
guardianCount: 5,
deliveries: [],
allDelivered: true,
setupFingerprint: '11111 22222 33333 44444 55555 66666 77777 88888 99999 00000 11111 22222',
},
['bob@example', 'carol@example', 'dan@example', 'eve@example', 'faythe@example'],
);
expect(text).toContain('alice@example');
expect(text).toContain('Threshold: 3 of 5');
expect(text).toContain('SetupId: sid-123');
expect(text).toContain('Setup fingerprint:');
expect(text).toContain('bob@example');
expect(text).toContain('faythe@example');
});
});
describe('createApprovalQueue', () => {
test('queues approve calls and resolves on user decision', async () => {
const q = createApprovalQueue();
const approvePromise = q.approve({
requesterAddress: 'alice2',
originalAddress: 'alice',
setupId: 'sid-1',
requesterFingerprint: 'aaa',
setupFingerprint: 'bbb',
depositCreatedAt: 0,
requestReceivedAt: 1,
});
const pending = q.pending();
expect(pending.length).toBe(1);
q.resolve(pending[0]!.id, true);
const decision = await approvePromise;
expect(decision).toBe(true);
expect(q.pending().length).toBe(0);
});
test('decline path resolves false', async () => {
const q = createApprovalQueue();
const p = q.approve({
requesterAddress: 'eve',
originalAddress: 'alice',
setupId: 'sid-1',
requesterFingerprint: 'aaa',
setupFingerprint: 'bbb',
depositCreatedAt: 0,
requestReceivedAt: 1,
});
q.resolve(q.pending()[0]!.id, false);
expect(await p).toBe(false);
});
test('subscribe fires on new pending + resolve', async () => {
const q = createApprovalQueue();
const events: string[] = [];
const unsub = q.subscribe(() => events.push('change'));
q.approve({
requesterAddress: 'a',
originalAddress: 'b',
setupId: 's',
requesterFingerprint: 'x',
setupFingerprint: 'y',
depositCreatedAt: 0,
requestReceivedAt: 1,
}).catch(() => {});
expect(events.length).toBe(1);
q.resolve(q.pending()[0]!.id, false);
expect(events.length).toBe(2);
unsub();
});
});