mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-05-31 03:19:42 +02:00
Harden Infonet DM address flow and seed sync
Allow local-operator DM invite import without requiring a full admin session. Prioritize bundled/bootstrap seed peers and shorten stale seed cooldowns for faster Infonet recovery. Replace raw DM invite dumps with copyable signed-address controls, contact request handling, and safer sealed-send behavior while the private delivery route connects.
This commit is contained in:
@@ -67,6 +67,12 @@ ADMIN_KEY=
|
||||
# SHADOWBROKER_SLOW_FETCH_CONCURRENCY=4
|
||||
# SHADOWBROKER_STARTUP_HEAVY_CONCURRENCY=2
|
||||
|
||||
# Infonet bootstrap/sync responsiveness. Defaults favor fast seed failure
|
||||
# detection so stale onion peers do not make the terminal look hung.
|
||||
# MESH_SYNC_TIMEOUT_S=5
|
||||
# MESH_SYNC_MAX_PEERS_PER_CYCLE=3
|
||||
# MESH_BOOTSTRAP_SEED_FAILURE_COOLDOWN_S=15
|
||||
|
||||
# Google Earth Engine for VIIRS night lights change detection (optional).
|
||||
# pip install earthengine-api
|
||||
# GEE_SERVICE_ACCOUNT_KEY=
|
||||
|
||||
+22
-4
@@ -1386,7 +1386,12 @@ def _peer_sync_response(peer_url: str, body: dict[str, Any]) -> dict[str, Any]:
|
||||
if _infonet_private_transport_required() and not _is_private_infonet_transport(transport):
|
||||
raise RuntimeError(_infonet_private_transport_error())
|
||||
|
||||
timeout = int(get_settings().MESH_RELAY_PUSH_TIMEOUT_S or 10)
|
||||
settings = get_settings()
|
||||
timeout = int(
|
||||
getattr(settings, "MESH_SYNC_TIMEOUT_S", 0)
|
||||
or getattr(settings, "MESH_RELAY_PUSH_TIMEOUT_S", 0)
|
||||
or 10
|
||||
)
|
||||
kwargs: dict[str, Any] = {
|
||||
"json": body,
|
||||
"timeout": timeout,
|
||||
@@ -1509,6 +1514,8 @@ def _run_public_sync_cycle() -> SyncWorkerState:
|
||||
|
||||
records = _filter_infonet_sync_records(store.records())
|
||||
peers = eligible_sync_peers(records, now=time.time())
|
||||
max_peers = max(1, int(getattr(get_settings(), "MESH_SYNC_MAX_PEERS_PER_CYCLE", 0) or 3))
|
||||
peers = peers[:max_peers]
|
||||
with _NODE_RUNTIME_LOCK:
|
||||
current_state = get_sync_state()
|
||||
if not peers:
|
||||
@@ -1571,14 +1578,25 @@ def _run_public_sync_cycle() -> SyncWorkerState:
|
||||
return updated
|
||||
|
||||
last_error = error
|
||||
settings = get_settings()
|
||||
is_seed_peer = str(getattr(record, "role", "") or "").strip().lower() == "seed"
|
||||
cooldown_s = int(getattr(settings, "MESH_RELAY_FAILURE_COOLDOWN_S", 120) or 120)
|
||||
if is_seed_peer:
|
||||
cooldown_s = int(
|
||||
getattr(settings, "MESH_BOOTSTRAP_SEED_FAILURE_COOLDOWN_S", cooldown_s)
|
||||
or cooldown_s
|
||||
)
|
||||
store.mark_failure(
|
||||
record.peer_url,
|
||||
"sync",
|
||||
error=error,
|
||||
cooldown_s=int(get_settings().MESH_RELAY_FAILURE_COOLDOWN_S or 120),
|
||||
cooldown_s=cooldown_s,
|
||||
now=time.time(),
|
||||
)
|
||||
store.save()
|
||||
failure_backoff_s = int(settings.MESH_SYNC_FAILURE_BACKOFF_S or 60)
|
||||
if is_seed_peer:
|
||||
failure_backoff_s = min(failure_backoff_s, max(1, cooldown_s))
|
||||
updated = finish_sync(
|
||||
started,
|
||||
ok=False,
|
||||
@@ -1588,7 +1606,7 @@ def _run_public_sync_cycle() -> SyncWorkerState:
|
||||
fork_detected=forked,
|
||||
now=time.time(),
|
||||
interval_s=int(get_settings().MESH_SYNC_INTERVAL_S or 300),
|
||||
failure_backoff_s=int(get_settings().MESH_SYNC_FAILURE_BACKOFF_S or 60),
|
||||
failure_backoff_s=failure_backoff_s,
|
||||
)
|
||||
with _NODE_RUNTIME_LOCK:
|
||||
set_sync_state(updated)
|
||||
@@ -9860,7 +9878,7 @@ async def api_wormhole_dm_invite_handle_revoke(request: Request, handle: str):
|
||||
return revoke_prekey_lookup_handle(handle)
|
||||
|
||||
|
||||
@app.post("/api/wormhole/dm/invite/import", dependencies=[Depends(require_admin)])
|
||||
@app.post("/api/wormhole/dm/invite/import", dependencies=[Depends(require_local_operator)])
|
||||
@limiter.limit("30/minute")
|
||||
async def api_wormhole_dm_invite_import(request: Request, body: WormholeDmInviteImportRequest):
|
||||
return import_wormhole_dm_invite(
|
||||
|
||||
@@ -735,7 +735,7 @@ async def api_wormhole_dm_invite_handle_revoke(request: Request, handle: str):
|
||||
return revoke_prekey_lookup_handle(handle)
|
||||
|
||||
|
||||
@router.post("/api/wormhole/dm/invite/import", dependencies=[Depends(require_admin)])
|
||||
@router.post("/api/wormhole/dm/invite/import", dependencies=[Depends(require_local_operator)])
|
||||
@limiter.limit("30/minute")
|
||||
async def api_wormhole_dm_invite_import(request: Request, body: WormholeDmInviteImportRequest):
|
||||
return import_wormhole_dm_invite(
|
||||
|
||||
@@ -46,9 +46,12 @@ class Settings(BaseSettings):
|
||||
MESH_NODE_MODE: str = "participant"
|
||||
MESH_SYNC_INTERVAL_S: int = 300
|
||||
MESH_SYNC_FAILURE_BACKOFF_S: int = 60
|
||||
MESH_SYNC_TIMEOUT_S: int = 5
|
||||
MESH_SYNC_MAX_PEERS_PER_CYCLE: int = 3
|
||||
MESH_RELAY_PUSH_TIMEOUT_S: int = 10
|
||||
MESH_RELAY_MAX_FAILURES: int = 3
|
||||
MESH_RELAY_FAILURE_COOLDOWN_S: int = 120
|
||||
MESH_BOOTSTRAP_SEED_FAILURE_COOLDOWN_S: int = 15
|
||||
MESH_PEER_PUSH_SECRET: str = ""
|
||||
MESH_RNS_APP_NAME: str = "shadowbroker"
|
||||
MESH_RNS_ASPECT: str = "infonet"
|
||||
|
||||
@@ -30,10 +30,19 @@ def eligible_sync_peers(records: list[PeerRecord], *, now: float | None = None)
|
||||
for record in records
|
||||
if record.bucket == "sync" and record.enabled and int(record.cooldown_until or 0) <= current_time
|
||||
]
|
||||
|
||||
def _seed_priority(record: PeerRecord) -> int:
|
||||
role = str(record.role or "").strip().lower()
|
||||
source = str(record.source or "").strip().lower()
|
||||
if role == "seed" and source in {"bundle", "bootstrap_promoted"}:
|
||||
return 0
|
||||
return 1
|
||||
|
||||
return sorted(
|
||||
candidates,
|
||||
key=lambda record: (
|
||||
-int(record.last_sync_ok_at or 0),
|
||||
_seed_priority(record),
|
||||
int(record.failure_count or 0),
|
||||
int(record.added_at or 0),
|
||||
record.peer_url,
|
||||
|
||||
@@ -258,6 +258,12 @@ class PeerStore:
|
||||
self._records[record.record_key()] = record
|
||||
return record
|
||||
|
||||
explicit_seed_refresh = (
|
||||
record.bucket == "sync"
|
||||
and record.role == "seed"
|
||||
and record.source in {"bundle", "bootstrap_promoted"}
|
||||
)
|
||||
|
||||
merged = PeerRecord(
|
||||
bucket=record.bucket,
|
||||
source=record.source,
|
||||
@@ -272,9 +278,9 @@ class PeerStore:
|
||||
last_seen_at=max(existing.last_seen_at, record.last_seen_at),
|
||||
last_sync_ok_at=max(existing.last_sync_ok_at, record.last_sync_ok_at),
|
||||
last_push_ok_at=max(existing.last_push_ok_at, record.last_push_ok_at),
|
||||
last_error=record.last_error or existing.last_error,
|
||||
failure_count=max(existing.failure_count, record.failure_count),
|
||||
cooldown_until=max(existing.cooldown_until, record.cooldown_until),
|
||||
last_error="" if explicit_seed_refresh else record.last_error or existing.last_error,
|
||||
failure_count=0 if explicit_seed_refresh else max(existing.failure_count, record.failure_count),
|
||||
cooldown_until=0 if explicit_seed_refresh else max(existing.cooldown_until, record.cooldown_until),
|
||||
metadata={**existing.metadata, **record.metadata},
|
||||
)
|
||||
self._records[record.record_key()] = merged
|
||||
|
||||
@@ -37,6 +37,30 @@ def test_eligible_sync_peers_filters_bucket_and_cooldown():
|
||||
assert [record.peer_url for record in candidates] == ["https://active.example"]
|
||||
|
||||
|
||||
def test_eligible_sync_peers_prioritizes_explicit_bootstrap_seed():
|
||||
old_runtime = make_sync_peer_record(
|
||||
peer_url="https://old-runtime.example",
|
||||
transport="clearnet",
|
||||
role="participant",
|
||||
source="runtime",
|
||||
now=100,
|
||||
)
|
||||
seed = make_sync_peer_record(
|
||||
peer_url="https://node.shadowbroker.info",
|
||||
transport="clearnet",
|
||||
role="seed",
|
||||
source="bundle",
|
||||
now=200,
|
||||
)
|
||||
|
||||
candidates = eligible_sync_peers([old_runtime, seed], now=300)
|
||||
|
||||
assert [record.peer_url for record in candidates] == [
|
||||
"https://node.shadowbroker.info",
|
||||
"https://old-runtime.example",
|
||||
]
|
||||
|
||||
|
||||
def test_finish_sync_success_updates_schedule():
|
||||
state = begin_sync(SyncWorkerState(), peer_url="https://seed.example", now=100)
|
||||
finished = finish_sync(
|
||||
|
||||
@@ -96,3 +96,38 @@ def test_peer_store_failure_and_success_lifecycle(tmp_path):
|
||||
assert recovered.cooldown_until == 0
|
||||
assert recovered.last_error == ""
|
||||
assert recovered.last_sync_ok_at == 250
|
||||
|
||||
|
||||
def test_upsert_explicit_seed_clears_stale_cooldown(tmp_path):
|
||||
store = PeerStore(tmp_path / "peer_store.json")
|
||||
store.upsert(
|
||||
make_sync_peer_record(
|
||||
peer_url="https://node.shadowbroker.info",
|
||||
transport="clearnet",
|
||||
role="seed",
|
||||
source="bundle",
|
||||
now=100,
|
||||
)
|
||||
)
|
||||
failed = store.mark_failure(
|
||||
"https://node.shadowbroker.info",
|
||||
"sync",
|
||||
error="timed out",
|
||||
cooldown_s=120,
|
||||
now=110,
|
||||
)
|
||||
assert failed.cooldown_until == 230
|
||||
|
||||
refreshed = store.upsert(
|
||||
make_sync_peer_record(
|
||||
peer_url="https://node.shadowbroker.info",
|
||||
transport="clearnet",
|
||||
role="seed",
|
||||
source="bundle",
|
||||
now=120,
|
||||
)
|
||||
)
|
||||
|
||||
assert refreshed.failure_count == 0
|
||||
assert refreshed.cooldown_until == 0
|
||||
assert refreshed.last_error == ""
|
||||
|
||||
@@ -61,8 +61,29 @@ const mocks = vi.hoisted(() => ({
|
||||
bootstrapDecryptAccessRequest: vi.fn(async () => 'offer'),
|
||||
bootstrapEncryptAccessRequest: vi.fn(async () => 'x3dh1:bootstrap'),
|
||||
canUseWormholeBootstrap: vi.fn(async () => false),
|
||||
bootstrapWormholeIdentity: vi.fn(async () => ({
|
||||
node_id: '!sb_local',
|
||||
public_key: 'local-pub',
|
||||
public_key_algo: 'Ed25519',
|
||||
sequence: 1,
|
||||
protocol_version: 'infonet/2',
|
||||
})),
|
||||
exportWormholeDmInvite: vi.fn(async () => ({
|
||||
ok: true,
|
||||
invite: {
|
||||
event_type: 'dm_invite',
|
||||
payload: {
|
||||
prekey_lookup_handle: 'handle-123',
|
||||
expires_at: 2_000_000_000,
|
||||
},
|
||||
},
|
||||
peer_id: '!sb_local',
|
||||
trust_fingerprint: 'trustfp123456',
|
||||
prekey_publish_pending: false,
|
||||
})),
|
||||
fetchWormholeStatus: vi.fn(async () => ({ ready: true, transport_tier: 'private_strong' })),
|
||||
fetchWormholeIdentity: vi.fn(async () => ({ node_id: '!sb_local', public_key: 'local-pub' })),
|
||||
listWormholeDmInviteHandles: vi.fn(async () => ({ ok: true, addresses: [] })),
|
||||
prepareWormholeInteractiveLane: vi.fn(async () => ({
|
||||
ready: true,
|
||||
settingsEnabled: true,
|
||||
@@ -75,10 +96,13 @@ const mocks = vi.hoisted(() => ({
|
||||
trust_fingerprint: 'invitefp',
|
||||
trust_level: 'invite_pinned',
|
||||
})),
|
||||
renameWormholeDmInviteHandle: vi.fn(async () => ({ ok: true })),
|
||||
revokeWormholeDmInviteHandle: vi.fn(async () => ({ ok: true, revoked: true })),
|
||||
isWormholeReady: vi.fn(async () => true),
|
||||
isWormholeSecureRequired: vi.fn(async () => false),
|
||||
issueWormholePairwiseAlias: vi.fn(async () => ({ ok: true, shared_alias: 'alias-123' })),
|
||||
openWormholeSenderSeal: vi.fn(async () => ({ sender_id: '!sb_peer', seal_verified: true })),
|
||||
writeClipboard: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
@@ -152,8 +176,10 @@ vi.mock('@/mesh/wormholeDmBootstrapClient', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('@/mesh/wormholeIdentityClient', () => ({
|
||||
bootstrapWormholeIdentity: mocks.bootstrapWormholeIdentity,
|
||||
fetchWormholeStatus: mocks.fetchWormholeStatus,
|
||||
fetchWormholeIdentity: mocks.fetchWormholeIdentity,
|
||||
exportWormholeDmInvite: mocks.exportWormholeDmInvite,
|
||||
prepareWormholeInteractiveLane: mocks.prepareWormholeInteractiveLane,
|
||||
getWormholeDmInviteImportErrorResult: (error: unknown) =>
|
||||
error && typeof error === 'object' && 'result' in (error as Record<string, unknown>)
|
||||
@@ -162,8 +188,11 @@ vi.mock('@/mesh/wormholeIdentityClient', () => ({
|
||||
importWormholeDmInvite: mocks.importWormholeDmInvite,
|
||||
isWormholeReady: mocks.isWormholeReady,
|
||||
isWormholeSecureRequired: mocks.isWormholeSecureRequired,
|
||||
listWormholeDmInviteHandles: mocks.listWormholeDmInviteHandles,
|
||||
issueWormholePairwiseAlias: mocks.issueWormholePairwiseAlias,
|
||||
openWormholeSenderSeal: mocks.openWormholeSenderSeal,
|
||||
renameWormholeDmInviteHandle: mocks.renameWormholeDmInviteHandle,
|
||||
revokeWormholeDmInviteHandle: mocks.revokeWormholeDmInviteHandle,
|
||||
}));
|
||||
|
||||
import MessagesView from '@/components/InfonetTerminal/MessagesView';
|
||||
@@ -191,10 +220,21 @@ describe('MessagesView first-contact trust UX', () => {
|
||||
localStorage.clear();
|
||||
contactsState = {};
|
||||
vi.clearAllMocks();
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
value: { writeText: mocks.writeClipboard },
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
mocks.getContacts.mockImplementation(() => contactsState);
|
||||
mocks.hydrateWormholeContacts.mockImplementation(async () => contactsState);
|
||||
mocks.fetchWormholeStatus.mockResolvedValue({ ready: true, transport_tier: 'private_strong' });
|
||||
mocks.bootstrapWormholeIdentity.mockResolvedValue({
|
||||
node_id: '!sb_local',
|
||||
public_key: 'local-pub',
|
||||
public_key_algo: 'Ed25519',
|
||||
sequence: 1,
|
||||
protocol_version: 'infonet/2',
|
||||
});
|
||||
mocks.prepareWormholeInteractiveLane.mockResolvedValue({
|
||||
ready: true,
|
||||
settingsEnabled: true,
|
||||
@@ -215,6 +255,20 @@ describe('MessagesView first-contact trust UX', () => {
|
||||
mocks.fetchDmPublicKey.mockResolvedValue({ dh_pub_key: 'peer-dh', dh_algo: 'X25519' });
|
||||
mocks.sendOffLedgerConsentMessage.mockResolvedValue({ ok: true, transport: 'relay' });
|
||||
mocks.canUseWormholeBootstrap.mockResolvedValue(false);
|
||||
mocks.exportWormholeDmInvite.mockResolvedValue({
|
||||
ok: true,
|
||||
invite: {
|
||||
event_type: 'dm_invite',
|
||||
payload: {
|
||||
prekey_lookup_handle: 'handle-123',
|
||||
expires_at: 2_000_000_000,
|
||||
},
|
||||
},
|
||||
peer_id: '!sb_local',
|
||||
trust_fingerprint: 'trustfp123456',
|
||||
prekey_publish_pending: false,
|
||||
});
|
||||
mocks.listWormholeDmInviteHandles.mockResolvedValue({ ok: true, addresses: [] });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -285,7 +339,7 @@ describe('MessagesView first-contact trust UX', () => {
|
||||
expect(screen.getByRole('button', { name: 'Send Secure Mail' })).toBeEnabled();
|
||||
});
|
||||
|
||||
it('warms the private lane in the background before sending secure mail', async () => {
|
||||
it('sends sealed mail without waiting for the private delivery route', async () => {
|
||||
contactsState = {
|
||||
'!sb_pinned': {
|
||||
alias: 'Pinned Peer',
|
||||
@@ -296,6 +350,17 @@ describe('MessagesView first-contact trust UX', () => {
|
||||
},
|
||||
};
|
||||
mocks.fetchWormholeStatus.mockResolvedValue({ ready: false, transport_tier: 'public_degraded' });
|
||||
mocks.prepareWormholeInteractiveLane.mockImplementation(
|
||||
() =>
|
||||
new Promise(() => {
|
||||
/* background route prep stays pending */
|
||||
}),
|
||||
);
|
||||
mocks.sendDmMessage.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
queued: true,
|
||||
private_transport_pending: true,
|
||||
});
|
||||
|
||||
renderMessagesView();
|
||||
await openComposeForRecipient('!sb_pinned', 'hello after warmup');
|
||||
@@ -306,7 +371,8 @@ describe('MessagesView first-contact trust UX', () => {
|
||||
|
||||
await waitFor(() => expect(mocks.prepareWormholeInteractiveLane).toHaveBeenCalled(), { timeout: 5000 });
|
||||
await waitFor(() => expect(mocks.sendDmMessage).toHaveBeenCalled(), { timeout: 5000 });
|
||||
await screen.findByText(/Mail delivered to Pinned Peer/i, {}, { timeout: 5000 });
|
||||
await screen.findByText(/Mail sealed locally for Pinned Peer/i, {}, { timeout: 5000 });
|
||||
expect(screen.queryByText(/still warming up/i)).not.toBeInTheDocument();
|
||||
}, 10000);
|
||||
|
||||
it('does not flatten witness policy not met into a generic witnessed root label', async () => {
|
||||
@@ -360,6 +426,70 @@ describe('MessagesView first-contact trust UX', () => {
|
||||
expect(screen.getByLabelText(/Local Alias/i)).toHaveValue('!sb_unpinned');
|
||||
});
|
||||
|
||||
it('surfaces pending contact requests in the contact list with approve and deny actions', async () => {
|
||||
localStorage.setItem(
|
||||
'sb_infonet_mailbox_v1:!sb_local',
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
items: [
|
||||
{
|
||||
id: 'request-1',
|
||||
msgId: 'request-1',
|
||||
folder: 'inbox',
|
||||
kind: 'request',
|
||||
direction: 'inbound',
|
||||
senderId: '!sb_requester',
|
||||
recipientId: '!sb_local',
|
||||
subject: 'Contact request from !sb_requester',
|
||||
body: '!sb_requester wants to open a secure mailbox.',
|
||||
timestamp: 1_778_624_800,
|
||||
read: false,
|
||||
transport: 'relay',
|
||||
deliveryClass: 'request',
|
||||
requestStatus: 'pending',
|
||||
requestDhPubKey: 'requester-dh',
|
||||
requestDhAlgo: 'X25519',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
mocks.addContact.mockImplementation((peerId: string, dhPubKey: string, _alias?: string, dhAlgo?: string) => {
|
||||
contactsState[peerId] = {
|
||||
alias: 'Requester',
|
||||
blocked: false,
|
||||
dhPubKey,
|
||||
dhAlgo,
|
||||
trust_level: 'unpinned',
|
||||
};
|
||||
});
|
||||
|
||||
renderMessagesView();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'CONTACTS' }));
|
||||
|
||||
expect(await screen.findByText('Contact Requests')).toBeInTheDocument();
|
||||
expect(await screen.findByText('1 pending')).toBeInTheDocument();
|
||||
expect(await screen.findAllByText('!sb_requester')).toHaveLength(2);
|
||||
expect(screen.getByRole('button', { name: 'Deny' })).toBeEnabled();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Approve' }));
|
||||
|
||||
await waitFor(() => expect(mocks.addContact).toHaveBeenCalledWith(
|
||||
'!sb_requester',
|
||||
'peer-dh',
|
||||
undefined,
|
||||
'X25519',
|
||||
));
|
||||
await waitFor(() =>
|
||||
expect(mocks.sendOffLedgerConsentMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
recipientId: '!sb_requester',
|
||||
recipientDhPub: 'peer-dh',
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(await screen.findByText(/Contact accepted: Requester\./i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('routes continuity reverify from Secure Messages into Dead Drop with SAS visible', async () => {
|
||||
contactsState = {
|
||||
'!sb_reverify': {
|
||||
@@ -465,7 +595,7 @@ describe('MessagesView first-contact trust UX', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'CONTACTS' }));
|
||||
expect(await screen.findByText("Paste Someone's Address")).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText(/Paste the address blob/i), {
|
||||
fireEvent.change(screen.getByPlaceholderText(/Paste the full text copied/i), {
|
||||
target: { value: JSON.stringify({ invite: { event_type: 'dm_invite', payload: {} } }) },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Import Address' }));
|
||||
@@ -475,6 +605,121 @@ describe('MessagesView first-contact trust UX', () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('generates and copies the full signed public address instead of the lookup handle', async () => {
|
||||
renderMessagesView();
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Generate Address' }));
|
||||
|
||||
await waitFor(() => expect(mocks.writeClipboard).toHaveBeenCalled());
|
||||
const copied = String(mocks.writeClipboard.mock.calls[0][0] || '');
|
||||
expect(copied).toContain('"type": "shadowbroker.infonet.dm.invite"');
|
||||
expect(copied).toContain('"prekey_lookup_handle": "handle-123"');
|
||||
expect(copied).not.toBe('handle-123');
|
||||
expect(await screen.findByText(/Generated and copied/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Signed invite ready/i)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/shadowbroker\.infonet\.dm\.invite/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not advertise legacy handle-only addresses as copyable public addresses', async () => {
|
||||
localStorage.setItem(
|
||||
'sb_infonet_dm_addresses_v1:!sb_local',
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
addresses: [
|
||||
{
|
||||
id: 'legacy-address',
|
||||
label: 'Legacy handle',
|
||||
handle: 'd8ce691f751817e137066f2a1858e21689b0118f8ec485c1',
|
||||
peerId: '',
|
||||
trustFingerprint: '',
|
||||
inviteBlob: '',
|
||||
createdAt: 1_700_000_000,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
renderMessagesView();
|
||||
|
||||
expect(await screen.findByText(/Generate an address, then send it to someone/i)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'CONTACTS' }));
|
||||
|
||||
expect(await screen.findByText('Legacy handle')).toBeInTheDocument();
|
||||
expect(screen.getByText('Address unavailable locally.')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Copy' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('explains raw lookup handles instead of showing a JSON parser error', async () => {
|
||||
renderMessagesView();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'CONTACTS' }));
|
||||
expect(await screen.findByText("Paste Someone's Address")).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText(/Paste the full text copied/i), {
|
||||
target: { value: 'f0eee9e9ccf849bcb2d86c0d7a1e0669c75be4e05533b0f6c67' },
|
||||
});
|
||||
|
||||
expect(await screen.findByText(/only a short address ID/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Import Address' })).toBeDisabled();
|
||||
expect(screen.queryByText(/Unexpected number in JSON/i)).not.toBeInTheDocument();
|
||||
expect(mocks.importWormholeDmInvite).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('hides pasted signed address JSON until advanced details are opened', async () => {
|
||||
const signedAddress = JSON.stringify({
|
||||
type: 'shadowbroker.infonet.dm.invite',
|
||||
version: 1,
|
||||
invite: { event_type: 'dm_invite', payload: {} },
|
||||
});
|
||||
|
||||
renderMessagesView();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'CONTACTS' }));
|
||||
expect(await screen.findByText("Paste Someone's Address")).toBeInTheDocument();
|
||||
|
||||
const addressField = screen.getByPlaceholderText(/Paste the full text copied/i);
|
||||
fireEvent.paste(addressField, {
|
||||
clipboardData: {
|
||||
getData: () => signedAddress,
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.getByDisplayValue(/Copied address received\. Ready to import\./i)).toBeInTheDocument();
|
||||
expect(screen.queryByDisplayValue(/shadowbroker\.infonet\.dm\.invite/i)).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Advanced Details' }));
|
||||
|
||||
expect(screen.getByLabelText('Raw copied public address')).toHaveValue(signedAddress);
|
||||
});
|
||||
|
||||
it('imports a copied address without waiting for secure mail warm-up', async () => {
|
||||
mocks.fetchWormholeStatus.mockResolvedValue({ ready: false, transport_tier: 'public_degraded' });
|
||||
mocks.prepareWormholeInteractiveLane.mockImplementation(
|
||||
() =>
|
||||
new Promise(() => {
|
||||
/* background warm-up stays pending */
|
||||
}),
|
||||
);
|
||||
mocks.importWormholeDmInvite.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
peer_id: '!sb_now',
|
||||
trust_fingerprint: 'invitefp-now',
|
||||
trust_level: 'invite_pinned',
|
||||
contact: {},
|
||||
});
|
||||
|
||||
renderMessagesView();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'CONTACTS' }));
|
||||
expect(await screen.findByText("Paste Someone's Address")).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText(/Paste the full text copied/i), {
|
||||
target: { value: JSON.stringify({ invite: { event_type: 'dm_invite', payload: {} } }) },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Import Address' }));
|
||||
|
||||
expect(await screen.findByText(/INVITE PINNED for !sb_now \(invitefp-now\)\./i)).toBeInTheDocument();
|
||||
expect(mocks.importWormholeDmInvite).toHaveBeenCalled();
|
||||
expect(screen.queryByText(/Secure mail is still warming up/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('announces compat invite imports as TOFU PINNED with backend detail', async () => {
|
||||
mocks.importWormholeDmInvite.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
@@ -489,7 +734,7 @@ describe('MessagesView first-contact trust UX', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'CONTACTS' }));
|
||||
expect(await screen.findByText("Paste Someone's Address")).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText(/Paste the address blob/i), {
|
||||
fireEvent.change(screen.getByPlaceholderText(/Paste the full text copied/i), {
|
||||
target: { value: JSON.stringify({ invite: { event_type: 'dm_invite', payload: {} } }) },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Import Address' }));
|
||||
@@ -538,7 +783,7 @@ describe('MessagesView first-contact trust UX', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'CONTACTS' }));
|
||||
expect(await screen.findByText("Paste Someone's Address")).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText(/Paste the address blob/i), {
|
||||
fireEvent.change(screen.getByPlaceholderText(/Paste the full text copied/i), {
|
||||
target: { value: JSON.stringify({ invite: { event_type: 'dm_invite', payload: {} } }) },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Import Address' }));
|
||||
@@ -552,7 +797,7 @@ describe('MessagesView first-contact trust UX', () => {
|
||||
});
|
||||
|
||||
it('uses non-blocking secure-mail startup language while the DM lane warms', async () => {
|
||||
mocks.fetchWormholeStatus.mockResolvedValueOnce({ ready: false, transport_tier: 'public_degraded' });
|
||||
mocks.fetchWormholeStatus.mockResolvedValue({ ready: false, transport_tier: 'public_degraded' });
|
||||
mocks.prepareWormholeInteractiveLane.mockImplementation(
|
||||
() =>
|
||||
new Promise(() => {
|
||||
@@ -563,9 +808,9 @@ describe('MessagesView first-contact trust UX', () => {
|
||||
renderMessagesView();
|
||||
|
||||
expect(
|
||||
await screen.findByText(/Private message delivery is connecting/i),
|
||||
await screen.findByText(/Private delivery route is connecting/i),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(/generate and copy your public address now/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Addresses, contacts, and sealed sends can proceed now/i)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/LOCKED/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/enter the Wormhole/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -1327,6 +1327,7 @@ describe('wormholeIdentityClient strict profile hints', () => {
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
requireAdminSession: false,
|
||||
body: JSON.stringify({
|
||||
invite: { event_type: 'dm_invite' },
|
||||
alias: 'field contact',
|
||||
@@ -1378,6 +1379,7 @@ describe('wormholeIdentityClient strict profile hints', () => {
|
||||
const prepared = await mod.prepareWormholeInteractiveLane({ bootstrapIdentity: true });
|
||||
|
||||
expect(connectWormhole).toHaveBeenCalledTimes(1);
|
||||
expect(connectWormhole).toHaveBeenCalledWith({ requireAdminSession: false });
|
||||
expect(joinWormhole).not.toHaveBeenCalled();
|
||||
expect(prepared).toEqual(
|
||||
expect.objectContaining({
|
||||
|
||||
@@ -89,6 +89,7 @@ import {
|
||||
canUseWormholeBootstrap,
|
||||
} from '@/mesh/wormholeDmBootstrapClient';
|
||||
import {
|
||||
bootstrapWormholeIdentity,
|
||||
fetchWormholeStatus,
|
||||
fetchWormholeIdentity,
|
||||
exportWormholeDmInvite,
|
||||
@@ -188,6 +189,7 @@ const FOLDERS: Array<{ key: MailFolder; label: string; icon: React.ReactNode }>
|
||||
];
|
||||
|
||||
const MAIL_POLL_BASE_MS = 12_000;
|
||||
const DM_LANE_BACKGROUND_PREP_TIMEOUT_MS = 5_000;
|
||||
const STORAGE_VERSION = 1;
|
||||
const SHADOWBROKER_WELCOME_ID = 'shadowbroker-welcome';
|
||||
const MAIL_SUBJECT_PREFIX = 'MAIL_SUBJECT:';
|
||||
@@ -335,7 +337,54 @@ function formatDmAddressDate(value?: number): string {
|
||||
}
|
||||
|
||||
function dmAddressShareText(address: LocalDmAddress): string {
|
||||
return String(address.handle || '').trim();
|
||||
return String(address.inviteBlob || '').trim();
|
||||
}
|
||||
|
||||
function dmAddressStatusLabel(address: LocalDmAddress): string {
|
||||
return dmAddressShareText(address) ? 'Signed invite ready' : 'Legacy handle only';
|
||||
}
|
||||
|
||||
function dmAddressDisplayId(address: LocalDmAddress): string {
|
||||
return shortHandle(address.handle || address.trustFingerprint || address.id);
|
||||
}
|
||||
|
||||
function isLikelyRawDmLookupHandle(value: string): boolean {
|
||||
const trimmed = value.trim();
|
||||
return !trimmed.startsWith('{') && !trimmed.startsWith('[') && /^[a-zA-Z0-9_.:-]{16,}$/.test(trimmed);
|
||||
}
|
||||
|
||||
function parseDmInviteImportBlob(raw: string): Record<string, unknown> {
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
throw new Error('Public address must be a signed address object.');
|
||||
}
|
||||
return parsed as Record<string, unknown>;
|
||||
} catch (error) {
|
||||
if (isLikelyRawDmLookupHandle(raw)) {
|
||||
throw new Error(
|
||||
'That is a short address ID, not a contact address. Ask them to click Copy Address in Secure Messages and paste the full copied address here.',
|
||||
);
|
||||
}
|
||||
if (error instanceof SyntaxError) {
|
||||
throw new Error(
|
||||
'Public address is not valid JSON. Paste the full signed Public Address copied from Secure Messages.',
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function inviteImportDisplayText(raw: string, hint: string): string {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return '';
|
||||
if (isLikelyRawDmLookupHandle(trimmed)) {
|
||||
return 'Short address ID pasted - use Copy Address instead.';
|
||||
}
|
||||
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
|
||||
return hint ? 'Copied address could not be read.' : 'Copied address received. Ready to import.';
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function encodeMailPayload(subject: string, body: string): string {
|
||||
@@ -632,6 +681,7 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro
|
||||
});
|
||||
const [inviteImportAlias, setInviteImportAlias] = useState('');
|
||||
const [inviteImportBlob, setInviteImportBlob] = useState('');
|
||||
const [inviteImportDetailsOpen, setInviteImportDetailsOpen] = useState(false);
|
||||
const [inviteBusy, setInviteBusy] = useState(false);
|
||||
const [inviteScanOpen, setInviteScanOpen] = useState(false);
|
||||
const [inviteScanStatus, setInviteScanStatus] = useState('');
|
||||
@@ -646,6 +696,7 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro
|
||||
const [privateDeliveryBusyId, setPrivateDeliveryBusyId] = useState('');
|
||||
const inviteVideoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const dmLaneWarmRef = useRef<Promise<boolean> | null>(null);
|
||||
const dmLaneBackgroundPrepStartedRef = useRef(false);
|
||||
|
||||
const scopeId = identity?.nodeId || 'guest';
|
||||
const qrScanAvailable =
|
||||
@@ -704,9 +755,12 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro
|
||||
return;
|
||||
}
|
||||
|
||||
if (secureRequired && wormholeReadyState) {
|
||||
if (secureRequired || !localIdentity) {
|
||||
try {
|
||||
const wormholeIdentity = await fetchWormholeIdentity();
|
||||
let wormholeIdentity = await fetchWormholeIdentity().catch(() => null);
|
||||
if (!wormholeIdentity) {
|
||||
wormholeIdentity = await bootstrapWormholeIdentity();
|
||||
}
|
||||
purgeBrowserSigningMaterial();
|
||||
purgeBrowserContactGraph();
|
||||
await purgeBrowserDmState();
|
||||
@@ -733,7 +787,7 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [secureRequired, wormholeReadyState]);
|
||||
}, [secureRequired]);
|
||||
|
||||
const dmLaneReady =
|
||||
wormholeTransportTier === 'private_control_only' ||
|
||||
@@ -750,7 +804,13 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro
|
||||
() =>
|
||||
dmAddresses.filter((address) => {
|
||||
const server = remoteDmHandles[address.handle];
|
||||
return !address.revokedAt && !server?.expired && !server?.exhausted && !server?.revoked;
|
||||
return (
|
||||
Boolean(dmAddressShareText(address)) &&
|
||||
!address.revokedAt &&
|
||||
!server?.expired &&
|
||||
!server?.exhausted &&
|
||||
!server?.revoked
|
||||
);
|
||||
}),
|
||||
[dmAddresses, remoteDmHandles],
|
||||
);
|
||||
@@ -771,6 +831,42 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro
|
||||
return [...dmAddresses, ...serverOnly].sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
|
||||
}, [dmAddresses, remoteDmHandles]);
|
||||
const primaryDmAddress = activeDmAddresses[0] || null;
|
||||
const inviteImportHint = useMemo(() => {
|
||||
const raw = inviteImportBlob.trim();
|
||||
if (!raw) return '';
|
||||
if (isLikelyRawDmLookupHandle(raw)) {
|
||||
return 'This is only a short address ID. It cannot add a contact by itself. Ask them to click Copy Address and paste that full copied address here.';
|
||||
}
|
||||
if (!raw.startsWith('{') && !raw.startsWith('[')) {
|
||||
return 'Paste the full copied public address. It should start with { and include shadowbroker.infonet.dm.invite.';
|
||||
}
|
||||
try {
|
||||
parseDmInviteImportBlob(raw);
|
||||
} catch {
|
||||
return 'Copied address could not be read. Ask them to click Copy Address again and paste the full copied text.';
|
||||
}
|
||||
return '';
|
||||
}, [inviteImportBlob]);
|
||||
const inviteImportFieldValue = useMemo(
|
||||
() => inviteImportDisplayText(inviteImportBlob, inviteImportHint),
|
||||
[inviteImportBlob, inviteImportHint],
|
||||
);
|
||||
const inviteImportCanImport = Boolean(inviteImportBlob.trim()) && !inviteImportHint;
|
||||
|
||||
const applyInviteImportText = useCallback(
|
||||
(value: string) => {
|
||||
const nextValue = String(value || '').trim();
|
||||
setInviteImportBlob(nextValue);
|
||||
setInviteImportDetailsOpen(false);
|
||||
if (composeError) {
|
||||
setComposeError('');
|
||||
}
|
||||
if (composeStatus) {
|
||||
setComposeStatus('');
|
||||
}
|
||||
},
|
||||
[composeError, composeStatus],
|
||||
);
|
||||
|
||||
const resolveMessagingIdentity = useCallback(async () => {
|
||||
const localIdentity = getNodeIdentity();
|
||||
@@ -778,7 +874,10 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro
|
||||
return localIdentity;
|
||||
}
|
||||
try {
|
||||
const wormholeIdentity = await fetchWormholeIdentity();
|
||||
let wormholeIdentity = await fetchWormholeIdentity().catch(() => null);
|
||||
if (!wormholeIdentity) {
|
||||
wormholeIdentity = await bootstrapWormholeIdentity();
|
||||
}
|
||||
return {
|
||||
publicKey: wormholeIdentity.public_key,
|
||||
privateKey: '',
|
||||
@@ -853,11 +952,19 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro
|
||||
}, [dmLaneReady]);
|
||||
|
||||
useEffect(() => {
|
||||
if (dmLaneReady && wormholeReadyState && identity) {
|
||||
void syncSecureMailRuntime();
|
||||
if (dmLaneReady || dmLaneBackgroundPrepStartedRef.current) {
|
||||
return;
|
||||
}
|
||||
void ensureSecureMailLane('Preparing secure mail in the background...');
|
||||
}, [dmLaneReady, ensureSecureMailLane, identity, wormholeReadyState]);
|
||||
dmLaneBackgroundPrepStartedRef.current = true;
|
||||
void prepareWormholeInteractiveLane({
|
||||
minimumTransportTier: 'private_control_only',
|
||||
timeoutMs: DM_LANE_BACKGROUND_PREP_TIMEOUT_MS,
|
||||
}).catch(() => {
|
||||
// The backend continues transport startup and queued-release work. The UI
|
||||
// should not keep a user-visible action waiting on this background check.
|
||||
});
|
||||
}, [dmLaneReady, syncSecureMailRuntime]);
|
||||
|
||||
const handlePrivateDeliveryAction = useCallback(
|
||||
async (itemId: string, action: 'wait' | 'relay') => {
|
||||
@@ -1064,6 +1171,26 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro
|
||||
.sort(([left], [right]) => left.localeCompare(right)),
|
||||
[contacts],
|
||||
);
|
||||
const pendingContactRequests = useMemo(
|
||||
() =>
|
||||
messages
|
||||
.filter(
|
||||
(item) =>
|
||||
item.kind === 'request' &&
|
||||
item.direction === 'inbound' &&
|
||||
item.folder !== 'trash' &&
|
||||
item.folder !== 'junk' &&
|
||||
item.folder !== 'spam' &&
|
||||
(item.requestStatus === 'pending' || item.requestStatus === 'unresolved'),
|
||||
)
|
||||
.sort((left, right) => {
|
||||
if (right.timestamp !== left.timestamp) {
|
||||
return right.timestamp - left.timestamp;
|
||||
}
|
||||
return left.id.localeCompare(right.id);
|
||||
}),
|
||||
[messages],
|
||||
);
|
||||
const composeRecipient = draft.recipient.trim();
|
||||
const composeRecipientContact = composeRecipient ? contacts[composeRecipient] : undefined;
|
||||
const composeTrustHint = composeRecipientContact ? buildDmTrustHint(composeRecipientContact) : null;
|
||||
@@ -1519,14 +1646,8 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro
|
||||
setComposeError('Write a message first.');
|
||||
return;
|
||||
}
|
||||
if (!wormholeReadyState || !dmLaneReady || !activeIdentity) {
|
||||
setComposeStatus('Preparing secure mail in the background...');
|
||||
const warmed = await ensureSecureMailLane('Preparing secure mail in the background...');
|
||||
if (!warmed) {
|
||||
setComposeStatus('');
|
||||
setComposeError('Secure mail is still warming up in the background.');
|
||||
return;
|
||||
}
|
||||
if (!activeIdentity) {
|
||||
setComposeStatus('Preparing secure identity...');
|
||||
activeIdentity = await syncSecureMailRuntime();
|
||||
}
|
||||
if (!activeIdentity) {
|
||||
@@ -1592,7 +1713,11 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro
|
||||
transport: sent.transport || '',
|
||||
deliveryClass: 'shared',
|
||||
});
|
||||
setComposeStatus(`Mail delivered to ${displayNameForPeer(recipient, hydratedContacts)}.`);
|
||||
setComposeStatus(
|
||||
sent.queued || sent.private_transport_pending
|
||||
? `Mail sealed locally for ${displayNameForPeer(recipient, hydratedContacts)}. Private delivery will release when the lane is ready.`
|
||||
: `Mail delivered to ${displayNameForPeer(recipient, hydratedContacts)}.`,
|
||||
);
|
||||
setDraft({
|
||||
recipient,
|
||||
subject: '',
|
||||
@@ -1667,7 +1792,11 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro
|
||||
deliveryClass: 'request',
|
||||
requestStatus: 'pending',
|
||||
});
|
||||
setComposeStatus(`Contact request sent to ${recipient}.`);
|
||||
setComposeStatus(
|
||||
sent.queued || sent.private_transport_pending
|
||||
? `Contact request sealed locally for ${recipient}. Private delivery will release when the lane is ready.`
|
||||
: `Contact request sent to ${recipient}.`,
|
||||
);
|
||||
setDraft({
|
||||
recipient,
|
||||
subject: '',
|
||||
@@ -1680,7 +1809,7 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [contacts, dmLaneReady, draft, ensureSecureMailLane, identity, queueSentMail, secureRequired, syncSecureMailRuntime, wormholeReadyState]);
|
||||
}, [contacts, draft, identity, queueSentMail, secureRequired, syncSecureMailRuntime]);
|
||||
|
||||
const handleImportInvite = useCallback(async () => {
|
||||
const raw = inviteImportBlob.trim();
|
||||
@@ -1689,18 +1818,17 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro
|
||||
setComposeError('Paste a signed DM invite first.');
|
||||
return;
|
||||
}
|
||||
if (isLikelyRawDmLookupHandle(raw)) {
|
||||
setComposeStatus('');
|
||||
setComposeError('');
|
||||
return;
|
||||
}
|
||||
|
||||
setInviteBusy(true);
|
||||
setComposeError('');
|
||||
setComposeStatus('');
|
||||
try {
|
||||
if (!wormholeReadyState) {
|
||||
const warmed = await ensureSecureMailLane('Preparing secure mail in the background...');
|
||||
if (!warmed) {
|
||||
throw new Error('Secure mail is still warming up in the background.');
|
||||
}
|
||||
}
|
||||
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||
const parsed = parseDmInviteImportBlob(raw);
|
||||
const nestedInvite = parsed?.invite;
|
||||
const invite =
|
||||
nestedInvite && typeof nestedInvite === 'object' && !Array.isArray(nestedInvite)
|
||||
@@ -1719,10 +1847,12 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro
|
||||
: 'INVITE IMPORTED');
|
||||
setContacts(hydratedContacts);
|
||||
setInviteImportBlob('');
|
||||
setInviteImportDetailsOpen(false);
|
||||
setInviteImportAlias('');
|
||||
setComposeStatus(
|
||||
`${importedTrustLabel} for ${displayNameForPeer(result.peer_id, hydratedContacts)} (${shortFingerprint(result.trust_fingerprint)}).${result.detail ? ` ${result.detail}` : ''}`,
|
||||
);
|
||||
void syncSecureMailRuntime();
|
||||
} catch (error) {
|
||||
setComposeStatus('');
|
||||
const failure = getWormholeDmInviteImportErrorResult(error);
|
||||
@@ -1742,7 +1872,7 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro
|
||||
} finally {
|
||||
setInviteBusy(false);
|
||||
}
|
||||
}, [ensureSecureMailLane, inviteImportAlias, inviteImportBlob, wormholeReadyState]);
|
||||
}, [inviteImportAlias, inviteImportBlob, syncSecureMailRuntime]);
|
||||
|
||||
const refreshDmAddressHandles = useCallback(async () => {
|
||||
try {
|
||||
@@ -1802,7 +1932,7 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro
|
||||
};
|
||||
setDmAddresses((prev) => [record, ...prev.filter((item) => item.handle !== handle)].slice(0, 32));
|
||||
setDmAddressLabel('');
|
||||
await navigator.clipboard?.writeText(handle).catch(() => undefined);
|
||||
await navigator.clipboard?.writeText(dmAddressShareText(record)).catch(() => undefined);
|
||||
setDmAddressCopyStatus(
|
||||
exported.prekey_publish_pending
|
||||
? `Generated and copied ${label} address. Private delivery will activate as soon as the lane finishes connecting.`
|
||||
@@ -1822,7 +1952,7 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro
|
||||
try {
|
||||
const shareText = dmAddressShareText(address);
|
||||
if (!shareText) {
|
||||
throw new Error('This address has no local handle to copy.');
|
||||
throw new Error('This saved address only has a legacy lookup handle. Generate a new public address.');
|
||||
}
|
||||
await navigator.clipboard?.writeText(shareText);
|
||||
setDmAddressCopyStatus(`Copied ${address.label || shortHandle(address.handle)}.`);
|
||||
@@ -1929,12 +2059,7 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro
|
||||
setComposeError('This request cannot be accepted until the sender is resolved.');
|
||||
return;
|
||||
}
|
||||
if (!wormholeReadyState || !dmLaneReady || !activeIdentity) {
|
||||
const warmed = await ensureSecureMailLane('Preparing secure mail in the background...');
|
||||
if (!warmed) {
|
||||
setComposeError('Secure mail is still warming up in the background.');
|
||||
return;
|
||||
}
|
||||
if (!activeIdentity) {
|
||||
activeIdentity = await syncSecureMailRuntime();
|
||||
}
|
||||
if (!activeIdentity) {
|
||||
@@ -2026,14 +2151,18 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro
|
||||
requestStatus: 'accepted',
|
||||
});
|
||||
setContacts(getContacts());
|
||||
setComposeStatus(`Contact accepted: ${displayNameForPeer(mail.senderId, getContacts())}.`);
|
||||
setComposeStatus(
|
||||
sent.queued || sent.private_transport_pending
|
||||
? `Contact acceptance sealed locally for ${displayNameForPeer(mail.senderId, getContacts())}. Private delivery will release when the lane is ready.`
|
||||
: `Contact accepted: ${displayNameForPeer(mail.senderId, getContacts())}.`,
|
||||
);
|
||||
} catch (error) {
|
||||
setComposeError(error instanceof Error ? error.message : 'accept failed');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[dmLaneReady, ensureSecureMailLane, identity, moveMessageToFolder, queueSentMail, secureRequired, syncSecureMailRuntime, wormholeReadyState],
|
||||
[identity, moveMessageToFolder, queueSentMail, secureRequired, syncSecureMailRuntime],
|
||||
);
|
||||
|
||||
const handleDenyRequest = useCallback(
|
||||
@@ -2043,12 +2172,7 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro
|
||||
moveMessageToFolder(mail.id, 'trash');
|
||||
return;
|
||||
}
|
||||
if (!wormholeReadyState || !dmLaneReady || !activeIdentity) {
|
||||
const warmed = await ensureSecureMailLane('Preparing secure mail in the background...');
|
||||
if (!warmed) {
|
||||
setComposeError('Secure mail is still warming up in the background.');
|
||||
return;
|
||||
}
|
||||
if (!activeIdentity) {
|
||||
activeIdentity = await syncSecureMailRuntime();
|
||||
}
|
||||
if (!activeIdentity) {
|
||||
@@ -2071,10 +2195,11 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro
|
||||
const sharedKey = await deriveSharedKey(mail.requestDhPubKey);
|
||||
ciphertext = await encryptDM(denyPlaintext, sharedKey);
|
||||
}
|
||||
let sentQueued = false;
|
||||
if (ciphertext) {
|
||||
const msgId = `dm_${Date.now()}_${activeIdentity.nodeId.slice(-4)}`;
|
||||
const timestamp = Math.floor(Date.now() / 1000);
|
||||
await sendOffLedgerConsentMessage({
|
||||
const sent = await sendOffLedgerConsentMessage({
|
||||
apiBase: API_BASE,
|
||||
identity: activeIdentity,
|
||||
recipientId: mail.senderId,
|
||||
@@ -2083,6 +2208,7 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro
|
||||
msgId,
|
||||
timestamp,
|
||||
});
|
||||
sentQueued = Boolean(sent.queued || sent.private_transport_pending);
|
||||
queueSentMail({
|
||||
msgId,
|
||||
kind: 'system',
|
||||
@@ -2096,14 +2222,18 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro
|
||||
});
|
||||
}
|
||||
moveMessageToFolder(mail.id, 'trash');
|
||||
setComposeStatus(`Request denied: ${displayNameForPeer(mail.senderId, getContacts())}.`);
|
||||
setComposeStatus(
|
||||
sentQueued
|
||||
? `Request denial sealed locally for ${displayNameForPeer(mail.senderId, getContacts())}. Private delivery will release when the lane is ready.`
|
||||
: `Request denied: ${displayNameForPeer(mail.senderId, getContacts())}.`,
|
||||
);
|
||||
} catch (error) {
|
||||
setComposeError(error instanceof Error ? error.message : 'deny failed');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[dmLaneReady, ensureSecureMailLane, identity, moveMessageToFolder, queueSentMail, secureRequired, syncSecureMailRuntime, wormholeReadyState],
|
||||
[identity, moveMessageToFolder, queueSentMail, secureRequired, syncSecureMailRuntime],
|
||||
);
|
||||
|
||||
const handleReply = useCallback((mail: MailItem) => {
|
||||
@@ -2123,7 +2253,7 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro
|
||||
return 'Secure identity is loading.';
|
||||
}
|
||||
if (!wormholeReadyState || !dmLaneReady) {
|
||||
return 'Private message delivery is connecting. You can generate and copy your public address now.';
|
||||
return 'Private delivery route is connecting. Addresses, contacts, and sealed sends can proceed now.';
|
||||
}
|
||||
if (syncing) {
|
||||
return 'SYNCING SECURE MAILBOX...';
|
||||
@@ -2176,8 +2306,25 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro
|
||||
{primaryDmAddress ? (
|
||||
<>
|
||||
<div className="text-white">{primaryDmAddress.label || 'Default address'}</div>
|
||||
<div className="mt-2 overflow-auto break-all border border-emerald-500/20 bg-black/40 p-3 font-mono text-[12px] leading-relaxed text-emerald-200">
|
||||
{dmAddressShareText(primaryDmAddress)}
|
||||
<div className="mt-2 grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||
<div className="border border-emerald-500/20 bg-black/40 p-3">
|
||||
<div className="text-[10px] tracking-[0.18em] uppercase text-gray-500">Address ID</div>
|
||||
<div className="mt-1 font-mono text-[12px] text-emerald-200 break-all">
|
||||
{dmAddressDisplayId(primaryDmAddress)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="border border-emerald-500/20 bg-black/40 p-3">
|
||||
<div className="text-[10px] tracking-[0.18em] uppercase text-gray-500">Payload</div>
|
||||
<div className="mt-1 text-[12px] text-emerald-200">
|
||||
{dmAddressStatusLabel(primaryDmAddress)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="border border-emerald-500/20 bg-black/40 p-3">
|
||||
<div className="text-[10px] tracking-[0.18em] uppercase text-gray-500">Expires</div>
|
||||
<div className="mt-1 text-[12px] text-emerald-200">
|
||||
{primaryDmAddress.expiresAt ? formatDmAddressDate(primaryDmAddress.expiresAt) : 'Rolling'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
@@ -2458,7 +2605,7 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro
|
||||
|
||||
<div className="mt-4 border border-amber-500/20 bg-amber-950/10 px-4 py-3 text-xs text-amber-300">
|
||||
{!wormholeReadyState || !dmLaneReady
|
||||
? 'Private message lane is still connecting. You can write now and send when it is ready.'
|
||||
? 'Private delivery route is still connecting. Sending now seals the message locally and releases it when the route is ready.'
|
||||
: 'To message someone new, paste their public address in Contacts first. Existing contacts can be messaged from here.'}
|
||||
</div>
|
||||
|
||||
@@ -2617,7 +2764,85 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro
|
||||
<Users size={14} className="mr-2" />
|
||||
Contacts
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-6">
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-[11px] tracking-[0.18em] uppercase text-emerald-300">
|
||||
Contact Requests
|
||||
</div>
|
||||
<div className="text-[11px] text-gray-500">
|
||||
{pendingContactRequests.length} pending
|
||||
</div>
|
||||
</div>
|
||||
{pendingContactRequests.length === 0 ? (
|
||||
<div className="text-sm text-gray-500">No pending contact requests.</div>
|
||||
) : (
|
||||
pendingContactRequests.map((request) => {
|
||||
const unresolved = request.requestStatus === 'unresolved';
|
||||
return (
|
||||
<div
|
||||
key={request.id}
|
||||
className="border border-emerald-500/25 bg-emerald-950/5 p-4"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="text-emerald-200 font-semibold break-words">
|
||||
{displayNameForPeer(request.senderId, contacts)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-1 break-all">
|
||||
{request.senderId}
|
||||
</div>
|
||||
<div
|
||||
className={`inline-flex mt-2 px-2 py-1 border text-[11px] tracking-[0.16em] uppercase ${
|
||||
unresolved
|
||||
? 'border-amber-500/40 text-amber-300 bg-amber-950/20'
|
||||
: 'border-emerald-500/35 text-emerald-300 bg-emerald-950/20'
|
||||
}`}
|
||||
>
|
||||
{unresolved ? 'Needs Sender Resolution' : 'Requested'}
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-gray-400">
|
||||
{messagePreview(request)}
|
||||
</div>
|
||||
<div className="mt-2 text-[11px] text-gray-500">
|
||||
Received {formatTimestamp(request.timestamp)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleAcceptRequest(request)}
|
||||
disabled={busy || unresolved}
|
||||
className="px-3 py-2 border border-emerald-500/40 bg-emerald-950/20 text-emerald-300 text-xs tracking-[0.18em] uppercase disabled:opacity-40"
|
||||
>
|
||||
Approve
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleDenyRequest(request)}
|
||||
disabled={busy}
|
||||
className="px-3 py-2 border border-red-500/35 text-red-300 text-xs tracking-[0.18em] uppercase disabled:opacity-40"
|
||||
>
|
||||
{unresolved ? 'Dismiss' : 'Deny'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{unresolved && (
|
||||
<div className="mt-3 text-[11px] text-amber-200/80">
|
||||
The request arrived through reduced sealed-sender metadata. It can be
|
||||
dismissed now or approved after the sender is resolved.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<div className="text-[11px] tracking-[0.18em] uppercase text-cyan-300">
|
||||
Approved Contacts
|
||||
</div>
|
||||
{activeContacts.length === 0 ? (
|
||||
<div className="text-sm text-gray-500">No approved secure contacts yet.</div>
|
||||
) : (
|
||||
@@ -2713,6 +2938,7 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro
|
||||
);
|
||||
})
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2824,8 +3050,25 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro
|
||||
{dmAddressBusy === `rename:${address.handle}` ? 'Saving...' : 'Save Label'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="border border-gray-800 bg-black/40 p-3 text-[11px] text-emerald-200 font-mono break-all">
|
||||
{shareText || 'Address unavailable locally.'}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||
<div className="border border-gray-800 bg-black/40 p-3">
|
||||
<div className="text-[10px] tracking-[0.18em] uppercase text-gray-500">Address ID</div>
|
||||
<div className="mt-1 text-[11px] text-emerald-200 font-mono break-all">
|
||||
{dmAddressDisplayId(address)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="border border-gray-800 bg-black/40 p-3">
|
||||
<div className="text-[10px] tracking-[0.18em] uppercase text-gray-500">Payload</div>
|
||||
<div className="mt-1 text-[11px] text-emerald-200">
|
||||
{shareText ? 'Signed invite ready' : 'Address unavailable locally.'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="border border-gray-800 bg-black/40 p-3">
|
||||
<div className="text-[10px] tracking-[0.18em] uppercase text-gray-500">Share</div>
|
||||
<div className="mt-1 text-[11px] text-emerald-200">
|
||||
{shareText ? 'Copy sends the full signed address.' : 'Generate a new address.'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-[11px] text-gray-500 leading-relaxed">
|
||||
Revoking disables this public address for new first-contact requests. Existing approved
|
||||
@@ -2861,19 +3104,69 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro
|
||||
/>
|
||||
</label>
|
||||
<label className="text-xs tracking-[0.18em] uppercase text-gray-500 block mt-4">
|
||||
Public Address
|
||||
Copied Public Address
|
||||
<textarea
|
||||
value={inviteImportBlob}
|
||||
onChange={(event) => setInviteImportBlob(event.target.value)}
|
||||
className="mt-2 w-full min-h-[200px] bg-transparent border border-gray-800 px-4 py-3 text-sm text-white outline-none focus:border-emerald-500/40 font-mono"
|
||||
placeholder="Paste the address blob someone copied from their Secure Messages screen..."
|
||||
value={inviteImportFieldValue}
|
||||
onPaste={(event) => {
|
||||
const pasted = event.clipboardData.getData('text');
|
||||
if (pasted) {
|
||||
event.preventDefault();
|
||||
applyInviteImportText(pasted);
|
||||
}
|
||||
}}
|
||||
onChange={(event) => {
|
||||
const nextValue = event.target.value;
|
||||
if (!nextValue.trim()) {
|
||||
applyInviteImportText('');
|
||||
return;
|
||||
}
|
||||
if (nextValue === inviteImportFieldValue && inviteImportBlob) {
|
||||
return;
|
||||
}
|
||||
applyInviteImportText(nextValue);
|
||||
}}
|
||||
className="mt-2 w-full min-h-[96px] bg-transparent border border-gray-800 px-4 py-3 text-sm text-white outline-none focus:border-emerald-500/40"
|
||||
placeholder="Paste the full text copied by Copy Address. A short address ID by itself will not work."
|
||||
spellCheck={false}
|
||||
/>
|
||||
</label>
|
||||
<div className="mt-4 text-sm text-gray-500 leading-[1.65]">
|
||||
The pasted address is signed. Importing it creates the first-contact anchor for
|
||||
that person without exposing your private keys.
|
||||
</div>
|
||||
{inviteImportHint ? (
|
||||
<div className="mt-4 border border-amber-500/30 bg-amber-950/10 px-4 py-3 text-sm text-amber-200 leading-[1.65]">
|
||||
{inviteImportHint}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 text-sm text-gray-500 leading-[1.65]">
|
||||
Importing a copied public address adds that person as a contact without exposing
|
||||
your private keys.
|
||||
</div>
|
||||
)}
|
||||
{inviteImportBlob && (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setInviteImportDetailsOpen((open) => !open)}
|
||||
className="px-3 py-2 border border-gray-700 bg-gray-950/20 text-gray-400 text-xs tracking-[0.18em] uppercase"
|
||||
>
|
||||
{inviteImportDetailsOpen ? 'Hide Details' : 'Advanced Details'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => applyInviteImportText('')}
|
||||
className="px-3 py-2 border border-gray-700 bg-gray-950/20 text-gray-400 text-xs tracking-[0.18em] uppercase"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{inviteImportDetailsOpen && inviteImportBlob && (
|
||||
<textarea
|
||||
value={inviteImportBlob}
|
||||
readOnly
|
||||
className="mt-3 w-full min-h-[160px] bg-black/40 border border-gray-800 px-4 py-3 text-[11px] text-gray-400 outline-none font-mono"
|
||||
spellCheck={false}
|
||||
aria-label="Raw copied public address"
|
||||
/>
|
||||
)}
|
||||
{(inviteScanOpen || inviteScanStatus) && (
|
||||
<div className="mt-4 border border-emerald-500/20 bg-black/30 p-4">
|
||||
{inviteScanOpen && (
|
||||
@@ -2902,7 +3195,7 @@ export default function MessagesView({ onBack, onOpenDeadDrop }: MessagesViewPro
|
||||
<div className="mt-6 flex flex-wrap gap-3">
|
||||
<button
|
||||
onClick={() => void handleImportInvite()}
|
||||
disabled={inviteBusy || !inviteImportBlob.trim()}
|
||||
disabled={inviteBusy || !inviteImportCanImport}
|
||||
className="px-4 py-3 border border-emerald-500/40 bg-emerald-950/20 text-emerald-300 text-xs tracking-[0.18em] uppercase disabled:opacity-50"
|
||||
>
|
||||
{inviteBusy ? 'Importing...' : 'Import Address'}
|
||||
|
||||
@@ -212,10 +212,13 @@ export async function fetchWormholeSettings(
|
||||
return inflight;
|
||||
}
|
||||
|
||||
export async function connectWormhole(): Promise<WormholeState> {
|
||||
export async function connectWormhole(
|
||||
options: { requireAdminSession?: boolean } = {},
|
||||
): Promise<WormholeState> {
|
||||
resetWormholeCaches();
|
||||
const res = await controlPlaneFetch('/api/wormhole/connect', {
|
||||
method: 'POST',
|
||||
requireAdminSession: options.requireAdminSession,
|
||||
});
|
||||
const state = await parseState(res);
|
||||
wormholeStateCache = {
|
||||
|
||||
@@ -881,7 +881,7 @@ export async function prepareWormholeInteractiveLane(
|
||||
let settings = await fetchWormholeSettings(true).catch(() => null);
|
||||
if (!runtime?.ready) {
|
||||
if (settings?.enabled || runtime?.configured) {
|
||||
runtime = await connectWormhole().catch((error) => {
|
||||
runtime = await connectWormhole({ requireAdminSession: false }).catch((error) => {
|
||||
throw new Error(
|
||||
normalizeWormholeInteractivePrepError(
|
||||
error instanceof Error ? error.message : 'wormhole_connect_failed',
|
||||
@@ -1055,6 +1055,7 @@ export async function importWormholeDmInvite(
|
||||
invite,
|
||||
alias,
|
||||
}),
|
||||
requireAdminSession: false,
|
||||
});
|
||||
const data = (await response.json().catch(() => ({}))) as WormholeDmInviteImportResult & {
|
||||
message?: string;
|
||||
|
||||
Reference in New Issue
Block a user