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:
BigBodyCobain
2026-05-12 21:23:38 -06:00
parent 2ce0e43ee5
commit 25a98a9869
13 changed files with 727 additions and 82 deletions
+6
View File
@@ -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
View File
@@ -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(
+1 -1
View File
@@ -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(
+3
View File
@@ -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,
+9 -3
View File
@@ -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'}
+4 -1
View File
@@ -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 = {
+2 -1
View File
@@ -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;