mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-05-27 09:32:28 +02:00
Harden infonet control surfaces
This commit is contained in:
@@ -10,7 +10,7 @@ const mocks = vi.hoisted(() => ({
|
||||
buildMailboxClaims: vi.fn(async () => []),
|
||||
countDmMailboxes: vi.fn(async () => ({ ok: true, count: 0 })),
|
||||
ensureRegisteredDmKey: vi.fn(async () => ({ dhPubKey: 'local-dh', dhAlgo: 'X25519' })),
|
||||
fetchDmPublicKey: vi.fn(async () => ({ dh_pub_key: 'peer-dh', dh_algo: 'X25519' })),
|
||||
fetchDmPublicKey: vi.fn(async () => ({ agent_id: '!sb_peer', dh_pub_key: 'peer-dh', dh_algo: 'X25519' })),
|
||||
pollDmMailboxes: vi.fn(async () => ({ ok: true, messages: [] })),
|
||||
sendDmMessage: vi.fn(async () => ({ ok: true, transport: 'relay' })),
|
||||
sendOffLedgerConsentMessage: vi.fn(async () => ({ ok: true, transport: 'relay' })),
|
||||
@@ -252,7 +252,7 @@ describe('MessagesView first-contact trust UX', () => {
|
||||
mocks.pollDmMailboxes.mockResolvedValue({ ok: true, messages: [] });
|
||||
mocks.countDmMailboxes.mockResolvedValue({ ok: true, count: 0 });
|
||||
mocks.ensureRegisteredDmKey.mockResolvedValue({ dhPubKey: 'local-dh', dhAlgo: 'X25519' });
|
||||
mocks.fetchDmPublicKey.mockResolvedValue({ dh_pub_key: 'peer-dh', dh_algo: 'X25519' });
|
||||
mocks.fetchDmPublicKey.mockResolvedValue({ agent_id: '!sb_peer', dh_pub_key: 'peer-dh', dh_algo: 'X25519' });
|
||||
mocks.sendOffLedgerConsentMessage.mockResolvedValue({ ok: true, transport: 'relay' });
|
||||
mocks.canUseWormholeBootstrap.mockResolvedValue(false);
|
||||
mocks.exportWormholeDmInvite.mockResolvedValue({
|
||||
@@ -334,8 +334,9 @@ describe('MessagesView first-contact trust UX', () => {
|
||||
await openComposeForRecipient('!sb_invited', 'hello to pinned peer');
|
||||
|
||||
expect(screen.queryByText('Unverified First Contact')).not.toBeInTheDocument();
|
||||
expect(await screen.findByText('ROOT LOCAL QUORUM')).toBeInTheDocument();
|
||||
expect(await screen.findByText(/Local quorum root rootabcd\.\.123456/i)).toBeInTheDocument();
|
||||
expect(screen.queryByText('ROOT LOCAL QUORUM')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/Local quorum root rootabcd\.\.123456/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/Fingerprint/i)).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Send Secure Mail' })).toBeEnabled();
|
||||
});
|
||||
|
||||
@@ -375,7 +376,34 @@ describe('MessagesView first-contact trust UX', () => {
|
||||
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 () => {
|
||||
it('repairs the local sending key before sending instead of surfacing backend key jargon', async () => {
|
||||
contactsState = {
|
||||
'!sb_pinned': {
|
||||
alias: 'Pinned Peer',
|
||||
blocked: false,
|
||||
trust_level: 'invite_pinned',
|
||||
dhPubKey: 'peer-dh',
|
||||
remotePrekeyFingerprint: 'abcdef123456',
|
||||
},
|
||||
};
|
||||
mocks.ensureRegisteredDmKey
|
||||
.mockResolvedValueOnce({ ok: true, dhPubKey: '', dhAlgo: 'X25519', detail: 'Missing DH public key' })
|
||||
.mockResolvedValueOnce({ ok: true, dhPubKey: 'local-dh-repaired', dhAlgo: 'X25519' });
|
||||
mocks.sendDmMessage.mockResolvedValueOnce({ ok: true, transport: 'relay' });
|
||||
|
||||
renderMessagesView();
|
||||
await openComposeForRecipient('!sb_pinned', 'hello after repair');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Send Secure Mail' }));
|
||||
|
||||
await waitFor(() => expect(mocks.ensureRegisteredDmKey).toHaveBeenCalledTimes(2));
|
||||
await waitFor(() => expect(mocks.sendDmMessage).toHaveBeenCalled());
|
||||
expect(await screen.findByText(/Mail delivered to Pinned Peer/i)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Local DM key is unavailable/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/Missing DH public key/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows saved contacts without witness-policy implementation detail', async () => {
|
||||
contactsState = {
|
||||
'!sb_policy': {
|
||||
alias: 'Policy Peer',
|
||||
@@ -404,10 +432,39 @@ describe('MessagesView first-contact trust UX', () => {
|
||||
renderMessagesView();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'CONTACTS' }));
|
||||
|
||||
expect(await screen.findByText(/Witness-policy root rootpoli\.\.123456/i)).toBeInTheDocument();
|
||||
expect(await screen.findByText('Saved Contact')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Witness-policy root rootpoli\.\.123456/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/Witnessed root rootpoli\.\.123456/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hydrates Wormhole contacts on first load even when a local browser identity exists', async () => {
|
||||
let wormholeIdentityResolved = false;
|
||||
contactsState = {
|
||||
'!sb_saved': {
|
||||
alias: 'Saved Person',
|
||||
blocked: false,
|
||||
trust_level: 'invite_pinned',
|
||||
invitePinnedPrekeyLookupHandle: 'handle-saved',
|
||||
invitePinnedTrustFingerprint: 'savedfingerprint123456',
|
||||
},
|
||||
};
|
||||
mocks.isWormholeSecureRequired.mockResolvedValue(true);
|
||||
mocks.fetchWormholeIdentity.mockImplementation(async () => {
|
||||
wormholeIdentityResolved = true;
|
||||
return { node_id: '!sb_local', public_key: 'local-pub' };
|
||||
});
|
||||
mocks.hydrateWormholeContacts.mockImplementation(async () =>
|
||||
wormholeIdentityResolved ? contactsState : {},
|
||||
);
|
||||
|
||||
renderMessagesView();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'CONTACTS' }));
|
||||
|
||||
expect(await screen.findByText('Saved Person')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/No approved secure contacts yet/i)).not.toBeInTheDocument();
|
||||
expect(mocks.fetchWormholeIdentity).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows an import-invite shortcut for unpinned contacts in the contact list', async () => {
|
||||
contactsState = {
|
||||
'!sb_unpinned': {
|
||||
@@ -426,7 +483,7 @@ 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 () => {
|
||||
it('surfaces pending contact requests in a top-level requests tab with approve and deny actions', async () => {
|
||||
localStorage.setItem(
|
||||
'sb_infonet_mailbox_v1:!sb_local',
|
||||
JSON.stringify({
|
||||
@@ -464,7 +521,7 @@ describe('MessagesView first-contact trust UX', () => {
|
||||
});
|
||||
|
||||
renderMessagesView();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'CONTACTS' }));
|
||||
fireEvent.click(await screen.findByRole('button', { name: /REQUESTS/i }));
|
||||
|
||||
expect(await screen.findByText('Contact Requests')).toBeInTheDocument();
|
||||
expect(await screen.findByText('1 pending')).toBeInTheDocument();
|
||||
@@ -576,13 +633,13 @@ describe('MessagesView first-contact trust UX', () => {
|
||||
|
||||
expect(
|
||||
await screen.findByText(
|
||||
/Import or re-import a signed invite before sending a contact request; legacy direct lookup is disabled\./i,
|
||||
/This contact needs their full contact address once before messages can be sent/i,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(mocks.fetchDmPublicKey).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('announces attested invite imports as INVITE PINNED', async () => {
|
||||
it('announces attested invite imports as a saved contact', async () => {
|
||||
mocks.importWormholeDmInvite.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
peer_id: '!sb_attested',
|
||||
@@ -595,32 +652,34 @@ 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 full text copied/i), {
|
||||
fireEvent.change(screen.getByPlaceholderText(/Paste a short address/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_attested \(invitefp\.\.tested\)\./i),
|
||||
).toBeInTheDocument();
|
||||
expect(await screen.findByText(/Contact saved: !sb_attested\./i)).toBeInTheDocument();
|
||||
expect(await screen.findByText('Saved Contact')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/INVITE PINNED for/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('generates and copies the full signed public address instead of the lookup handle', async () => {
|
||||
it('automatically creates a share address and keeps copy actions simple', 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(await screen.findByText(/Contact address ready/i)).toBeInTheDocument();
|
||||
expect(await screen.findByText('handle-123')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Signed invite ready/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Copy Short Address/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Copy Full Address/i })).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Copy Short Address/i }));
|
||||
|
||||
await waitFor(() => expect(mocks.writeClipboard).toHaveBeenCalledWith('handle-123'));
|
||||
const copied = String(mocks.writeClipboard.mock.calls.at(-1)?.[0] || '');
|
||||
expect(copied).toBe('handle-123');
|
||||
expect(screen.queryByText(/shadowbroker\.infonet\.dm\.invite/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not advertise legacy handle-only addresses as copyable public addresses', async () => {
|
||||
it('does not advertise legacy handle-only addresses as copyable contact addresses', async () => {
|
||||
localStorage.setItem(
|
||||
'sb_infonet_dm_addresses_v1:!sb_local',
|
||||
JSON.stringify({
|
||||
@@ -641,25 +700,33 @@ describe('MessagesView first-contact trust UX', () => {
|
||||
|
||||
renderMessagesView();
|
||||
|
||||
expect(await screen.findByText(/Generate an address, then send it to someone/i)).toBeInTheDocument();
|
||||
expect(await screen.findByText(/Contact address ready/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();
|
||||
expect(screen.getAllByRole('button', { name: 'Copy Short' }).some((button) => !button.hasAttribute('disabled'))).toBe(true);
|
||||
expect(screen.getAllByRole('button', { name: 'Copy Full' }).some((button) => button.hasAttribute('disabled'))).toBe(true);
|
||||
});
|
||||
|
||||
it('explains raw lookup handles instead of showing a JSON parser error', async () => {
|
||||
it('sends a contact request from a short address instead of requiring JSON', 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), {
|
||||
fireEvent.change(screen.getByPlaceholderText(/Paste a short address/i), {
|
||||
target: { value: 'f0eee9e9ccf849bcb2d86c0d7a1e0669c75be4e05533b0f6c67' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Send Request' }));
|
||||
|
||||
expect(await screen.findByText(/only a short address ID/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Import Address' })).toBeDisabled();
|
||||
await waitFor(() => expect(mocks.sendOffLedgerConsentMessage).toHaveBeenCalled());
|
||||
expect(await screen.findByText(/Contact request sent to/i)).toBeInTheDocument();
|
||||
expect(mocks.fetchDmPublicKey).toHaveBeenCalledWith(
|
||||
'http://localhost:8000',
|
||||
'',
|
||||
'f0eee9e9ccf849bcb2d86c0d7a1e0669c75be4e05533b0f6c67',
|
||||
);
|
||||
expect(mocks.sendOffLedgerConsentMessage).toHaveBeenCalled();
|
||||
expect(screen.queryByText(/Unexpected number in JSON/i)).not.toBeInTheDocument();
|
||||
expect(mocks.importWormholeDmInvite).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -675,7 +742,7 @@ describe('MessagesView first-contact trust UX', () => {
|
||||
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);
|
||||
const addressField = screen.getByPlaceholderText(/Paste a short address/i);
|
||||
fireEvent.paste(addressField, {
|
||||
clipboardData: {
|
||||
getData: () => signedAddress,
|
||||
@@ -687,7 +754,7 @@ describe('MessagesView first-contact trust UX', () => {
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Advanced Details' }));
|
||||
|
||||
expect(screen.getByLabelText('Raw copied public address')).toHaveValue(signedAddress);
|
||||
expect(screen.getByLabelText('Raw copied contact address')).toHaveValue(signedAddress);
|
||||
});
|
||||
|
||||
it('imports a copied address without waiting for secure mail warm-up', async () => {
|
||||
@@ -710,17 +777,113 @@ 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 full text copied/i), {
|
||||
fireEvent.change(screen.getByPlaceholderText(/Paste a short address/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(await screen.findByText(/Contact saved: !sb_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 () => {
|
||||
it('saves pending-delivery contacts without showing prekey jargon', async () => {
|
||||
mocks.importWormholeDmInvite.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
peer_id: '!sb_pending',
|
||||
trust_fingerprint: 'invitefp-pending',
|
||||
trust_level: 'invite_pinned',
|
||||
pending_prekey: true,
|
||||
detail: 'Contact saved.',
|
||||
contact: {
|
||||
alias: 'Pending Person',
|
||||
blocked: false,
|
||||
trust_level: 'invite_pinned',
|
||||
invitePinnedPrekeyLookupHandle: 'handle-pending',
|
||||
invitePinnedTrustFingerprint: 'invitefp-pending',
|
||||
dhPubKey: '',
|
||||
},
|
||||
});
|
||||
|
||||
renderMessagesView();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'CONTACTS' }));
|
||||
expect(await screen.findByText("Paste Someone's Address")).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText(/Paste a short address/i), {
|
||||
target: { value: JSON.stringify({ invite: { event_type: 'dm_invite', payload: {} } }) },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Import Address' }));
|
||||
|
||||
expect(await screen.findByText(/Contact saved: Pending Person\./i)).toBeInTheDocument();
|
||||
expect(await screen.findByText('Saved Contact')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/prekey/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('saves mail locally when a saved contact is not reachable yet', async () => {
|
||||
contactsState = {
|
||||
'!sb_pending': {
|
||||
alias: 'Pending Person',
|
||||
blocked: false,
|
||||
trust_level: 'invite_pinned',
|
||||
invitePinnedPrekeyLookupHandle: 'handle-pending',
|
||||
invitePinnedTrustFingerprint: 'invitefp-pending',
|
||||
dhPubKey: '',
|
||||
},
|
||||
};
|
||||
mocks.fetchDmPublicKey.mockResolvedValueOnce({ agent_id: '!sb_pending', dh_pub_key: '', dh_algo: 'X25519' });
|
||||
|
||||
renderMessagesView();
|
||||
await openComposeForRecipient('!sb_pending', 'hello when ready');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Send Secure Mail' }));
|
||||
|
||||
expect(await screen.findByText(/Mail is saved locally and will send automatically when the contact is reachable/i)).toBeInTheDocument();
|
||||
expect(mocks.sendOffLedgerConsentMessage).not.toHaveBeenCalled();
|
||||
expect(screen.queryByText(/delivery key has not reached/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('removes an approved contact immediately from the visible contact list', async () => {
|
||||
contactsState = {
|
||||
'!sb_remove': {
|
||||
alias: 'Remove Me',
|
||||
blocked: false,
|
||||
trust_level: 'invite_pinned',
|
||||
invitePinnedTrustFingerprint: 'removefingerprint123456',
|
||||
dhPubKey: 'peer-dh',
|
||||
},
|
||||
};
|
||||
mocks.removeContact.mockImplementation((peerId: string) => {
|
||||
delete contactsState[peerId];
|
||||
});
|
||||
|
||||
renderMessagesView();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'CONTACTS' }));
|
||||
|
||||
expect(await screen.findByText('Remove Me')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Remove' }));
|
||||
|
||||
expect(await screen.findByText(/Removed contact: Remove Me\./i)).toBeInTheDocument();
|
||||
expect(screen.queryByText('Remove Me')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('explains unresolved address delivery without exposing backend jargon', async () => {
|
||||
mocks.importWormholeDmInvite.mockRejectedValueOnce(new Error('peer prekey lookup unavailable'));
|
||||
|
||||
renderMessagesView();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'CONTACTS' }));
|
||||
expect(await screen.findByText("Paste Someone's Address")).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText(/Paste a short address/i), {
|
||||
target: { value: JSON.stringify({ invite: { event_type: 'dm_invite', payload: {} } }) },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Import Address' }));
|
||||
|
||||
expect(await screen.findByText(/This address is valid, but contact delivery is not ready on this node yet/i)).toBeInTheDocument();
|
||||
expect(screen.queryByText('peer prekey lookup unavailable')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/sender prekey/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('announces compat invite imports as a saved contact without backend detail', async () => {
|
||||
mocks.importWormholeDmInvite.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
peer_id: '!sb_compat',
|
||||
@@ -734,17 +897,16 @@ 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 full text copied/i), {
|
||||
fireEvent.change(screen.getByPlaceholderText(/Paste a short address/i), {
|
||||
target: { value: JSON.stringify({ invite: { event_type: 'dm_invite', payload: {} } }) },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Import Address' }));
|
||||
|
||||
expect(await screen.findByText(/Contact saved: !sb_compat\./i)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/TOFU PINNED for/i)).not.toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByText(/TOFU PINNED for !sb_compat \(invitefp\.\.compat\)\./i),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/legacy invite imported as tofu_pinned; SAS verification required before first contact/i),
|
||||
).toBeInTheDocument();
|
||||
screen.queryByText(/legacy invite imported as tofu_pinned; SAS verification required before first contact/i),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('surfaces stable root continuity breaks on invite re-import', async () => {
|
||||
@@ -783,7 +945,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 full text copied/i), {
|
||||
fireEvent.change(screen.getByPlaceholderText(/Paste a short address/i), {
|
||||
target: { value: JSON.stringify({ invite: { event_type: 'dm_invite', payload: {} } }) },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Import Address' }));
|
||||
|
||||
@@ -1357,7 +1357,9 @@ describe('wormholeIdentityClient strict profile hints', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(controlPlaneJson).toHaveBeenCalledWith('/api/wormhole/dm/root-health');
|
||||
expect(controlPlaneJson).toHaveBeenCalledWith('/api/wormhole/dm/root-health', {
|
||||
requireAdminSession: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('prepares the interactive lane through the configured wormhole runtime and bootstraps identity state', async () => {
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
* - /api/tools/* (Sprint 1C addition)
|
||||
* - /api/wormhole/* (pre-existing, regression)
|
||||
* - /api/settings/* (pre-existing, regression)
|
||||
* - /api/layers, /api/ais/feed, /api/ai/agent-actions
|
||||
*
|
||||
* Also verifies that:
|
||||
* - non-sensitive mesh paths (e.g. mesh/events) do NOT receive injected key
|
||||
@@ -272,6 +273,77 @@ describe('proxy admin-key injection coverage', () => {
|
||||
expect(capturedHeaders(fetchMock).get('X-Admin-Key')).toBe(ADMIN_KEY);
|
||||
});
|
||||
|
||||
it('POST /api/layers with valid session injects X-Admin-Key', async () => {
|
||||
const cookie = await mintSession(ADMIN_KEY);
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ status: 'ok' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const req = new NextRequest('http://localhost/api/layers', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ layers: { aircraft: true } }),
|
||||
headers: { cookie, 'Content-Type': 'application/json' },
|
||||
});
|
||||
const res = await proxyPost(req, {
|
||||
params: Promise.resolve({ path: ['layers'] }),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(capturedHeaders(fetchMock).get('X-Admin-Key')).toBe(ADMIN_KEY);
|
||||
});
|
||||
|
||||
it('POST /api/ais/feed with valid session injects X-Admin-Key', async () => {
|
||||
const cookie = await mintSession(ADMIN_KEY);
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ status: 'ok' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const req = new NextRequest('http://localhost/api/ais/feed', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ msgs: [] }),
|
||||
headers: { cookie, 'Content-Type': 'application/json' },
|
||||
});
|
||||
const res = await proxyPost(req, {
|
||||
params: Promise.resolve({ path: ['ais', 'feed'] }),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(capturedHeaders(fetchMock).get('X-Admin-Key')).toBe(ADMIN_KEY);
|
||||
});
|
||||
|
||||
it('GET /api/ai/agent-actions with valid session injects X-Admin-Key', async () => {
|
||||
const cookie = await mintSession(ADMIN_KEY);
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ ok: true, actions: [] }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const req = new NextRequest('http://localhost/api/ai/agent-actions', {
|
||||
method: 'GET',
|
||||
headers: { cookie },
|
||||
});
|
||||
const res = await proxyGet(req, {
|
||||
params: Promise.resolve({ path: ['ai', 'agent-actions'] }),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(capturedHeaders(fetchMock).get('X-Admin-Key')).toBe(ADMIN_KEY);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Non-sensitive mesh paths must NOT receive injected admin key
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@@ -64,6 +64,9 @@ function isSensitiveProxyPath(pathSegments: string[]): boolean {
|
||||
if (joined === 'refresh') return true;
|
||||
if (joined === 'debug-latest') return true;
|
||||
if (joined === 'system/update') return true;
|
||||
if (joined === 'layers') return true;
|
||||
if (joined === 'ais/feed') return true;
|
||||
if (joined === 'ai/agent-actions') return true;
|
||||
if (pathSegments[0] === 'settings') return true;
|
||||
if (joined === 'mesh/infonet/ingest') return true;
|
||||
if (joined === 'mesh/meshtastic/send') return true;
|
||||
|
||||
@@ -203,8 +203,8 @@ export default function Dashboard() {
|
||||
uap_sightings: true,
|
||||
// Biosurveillance
|
||||
wastewater: true,
|
||||
// CrowdThreat
|
||||
crowdthreat: true,
|
||||
// CrowdThreat is operator opt-in only.
|
||||
crowdthreat: false,
|
||||
// Shodan
|
||||
shodan_overlay: false,
|
||||
// AI Intel
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,16 +7,17 @@ import { fetchInfonetNodeStatusSnapshot } from '@/mesh/controlPlaneStatusClient'
|
||||
interface Stats {
|
||||
meshtastic: number;
|
||||
aprs: number;
|
||||
infonetNodes: number;
|
||||
ledgerNodes: number;
|
||||
infonetEvents: number;
|
||||
syncPeers: number;
|
||||
seedPeers: number;
|
||||
nodeEnabled: boolean;
|
||||
syncOutcome: string;
|
||||
}
|
||||
|
||||
const EMPTY: Stats = {
|
||||
meshtastic: 0, aprs: 0, infonetNodes: 0, infonetEvents: 0,
|
||||
syncPeers: 0, nodeEnabled: false, syncOutcome: 'offline',
|
||||
meshtastic: 0, aprs: 0, ledgerNodes: 0, infonetEvents: 0,
|
||||
syncPeers: 0, seedPeers: 0, nodeEnabled: false, syncOutcome: 'offline',
|
||||
};
|
||||
|
||||
export default function NetworkStats() {
|
||||
@@ -32,22 +33,21 @@ export default function NetworkStats() {
|
||||
fetchInfonetNodeStatusSnapshot(true).catch(() => null),
|
||||
]);
|
||||
if (!alive) return;
|
||||
const knownNodes = Number(infonet?.known_nodes || 0);
|
||||
const authorNodes = Number(infonet?.author_nodes ?? infonet?.known_nodes ?? 0);
|
||||
const registeredNodes = Number(infonet?.registered_nodes || 0);
|
||||
const syncPeerCount = Number(infonet?.bootstrap?.sync_peer_count || 0);
|
||||
const defaultSyncPeerCount = Number(infonet?.bootstrap?.default_sync_peer_count || 0);
|
||||
const lastPeerUrl = String(infonet?.sync_runtime?.last_peer_url || '').trim();
|
||||
const visibleInfonetNodes = Math.max(
|
||||
knownNodes,
|
||||
syncPeerCount,
|
||||
defaultSyncPeerCount,
|
||||
lastPeerUrl ? 1 : 0,
|
||||
const seedPeerCount = Number(
|
||||
infonet?.bootstrap?.bootstrap_seed_peer_count
|
||||
?? infonet?.bootstrap?.default_sync_peer_count
|
||||
?? 0,
|
||||
);
|
||||
setStats({
|
||||
meshtastic: Number(channelsRes?.total_live || channelsRes?.total_nodes || meshRes?.signal_counts?.meshtastic || 0),
|
||||
aprs: Number(meshRes?.signal_counts?.aprs || 0),
|
||||
infonetNodes: visibleInfonetNodes,
|
||||
ledgerNodes: Math.max(authorNodes, registeredNodes),
|
||||
infonetEvents: Number(infonet?.total_events || 0),
|
||||
syncPeers: syncPeerCount,
|
||||
seedPeers: seedPeerCount,
|
||||
nodeEnabled: Boolean(infonet?.node_enabled),
|
||||
syncOutcome: String(infonet?.sync_runtime?.last_outcome || 'offline').toLowerCase(),
|
||||
});
|
||||
@@ -74,11 +74,21 @@ export default function NetworkStats() {
|
||||
<span className="text-gray-700">|</span>
|
||||
<span>APRS <span className={stats.aprs > 0 ? 'text-green-400' : 'text-gray-600'}>{stats.aprs.toLocaleString()}</span></span>
|
||||
<span className="text-gray-700">|</span>
|
||||
<span>INFONET NODES <span className="text-white">{stats.infonetNodes}</span></span>
|
||||
<span title="Distinct identities this node has seen on the accepted Infonet ledger. This is not a live user count.">
|
||||
LEDGER NODES <span className="text-white">{stats.ledgerNodes}</span>
|
||||
</span>
|
||||
<span className="text-gray-700">|</span>
|
||||
<span>EVENTS <span className="text-white">{stats.infonetEvents}</span></span>
|
||||
<span className="text-gray-700">|</span>
|
||||
<span>PEERS <span className="text-white">{stats.syncPeers}</span></span>
|
||||
<span title="Configured peers this node pulls from. Usually this is just the seed unless another device is added as a sync peer.">
|
||||
SYNC PEERS <span className="text-white">{stats.syncPeers}</span>
|
||||
</span>
|
||||
{stats.seedPeers > stats.syncPeers ? (
|
||||
<>
|
||||
<span className="text-gray-700">|</span>
|
||||
<span title="Bootstrap seed peers available from config or manifest.">SEEDS <span className="text-white">{stats.seedPeers}</span></span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -305,6 +305,42 @@ function createPublicMeshAddress(): string {
|
||||
return `!${fallback.toString(16).padStart(8, '0')}`;
|
||||
}
|
||||
|
||||
function errorMessage(err: unknown, fallback: string = 'unknown error'): string {
|
||||
if (err instanceof Error && err.message) return err.message;
|
||||
if (typeof err === 'string' && err.trim()) return err.trim();
|
||||
if (typeof err === 'object' && err !== null && 'message' in err) {
|
||||
const message = String((err as { message?: unknown }).message || '').trim();
|
||||
if (message) return message;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function describeMeshChatControlError(raw: string): string {
|
||||
const message = String(raw || '').trim();
|
||||
if (!message) return 'MeshChat could not update the local control plane.';
|
||||
if (
|
||||
message === 'control_plane_request_failed:530' ||
|
||||
message === 'HTTP 530' ||
|
||||
message.includes('control_plane_request_failed:530')
|
||||
) {
|
||||
return 'The local control plane did not complete the lane switch. Check that the backend is running and reachable, then try Mesh again.';
|
||||
}
|
||||
if (
|
||||
message === 'control_plane_request_failed:502' ||
|
||||
message === 'HTTP 502' ||
|
||||
/Backend unavailable/i.test(message)
|
||||
) {
|
||||
return 'The frontend cannot reach the backend right now. Start or restart the backend, then try Mesh again.';
|
||||
}
|
||||
if (message === 'admin_session_required' || /local operator access only/i.test(message)) {
|
||||
return 'This control action needs a local operator session. Open Settings or Node controls once so the app can authorize local changes, then try Mesh again.';
|
||||
}
|
||||
if (message.startsWith('{') || message.startsWith('<')) {
|
||||
return 'MeshChat could not update the local control plane. Check the backend log for the upstream error.';
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
function describeGateCompatConsentRequired(): string {
|
||||
return 'Local gate runtime is unavailable for this room.';
|
||||
}
|
||||
@@ -507,8 +543,13 @@ export function useMeshChatController({
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const detail = await res.text().catch(() => '');
|
||||
throw new Error(detail || `HTTP ${res.status}`);
|
||||
const data = await res.clone().json().catch(() => null) as
|
||||
| { detail?: unknown; message?: unknown; error?: unknown }
|
||||
| null;
|
||||
const detail =
|
||||
String(data?.detail || data?.message || data?.error || '').trim() ||
|
||||
(await res.text().catch(() => '')).trim();
|
||||
throw new Error(describeMeshChatControlError(detail || `HTTP ${res.status}`));
|
||||
}
|
||||
const data = (await res.json()) as MeshMqttSettings;
|
||||
applyMeshMqttSettings(data);
|
||||
@@ -528,7 +569,7 @@ export function useMeshChatController({
|
||||
setMeshMqttStatusText(status);
|
||||
return { ok: true as const, text: status, data };
|
||||
} catch (err) {
|
||||
const text = err instanceof Error ? err.message : 'MQTT settings update failed';
|
||||
const text = describeMeshChatControlError(errorMessage(err, 'MQTT settings update failed'));
|
||||
setMeshMqttStatusText(text);
|
||||
return { ok: false as const, text };
|
||||
} finally {
|
||||
@@ -4222,7 +4263,14 @@ export function useMeshChatController({
|
||||
);
|
||||
|
||||
const disablePrivateNodeForPublicMesh = useCallback(async () => {
|
||||
await setInfonetNodeEnabled(false);
|
||||
try {
|
||||
await setInfonetNodeEnabled(false);
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
'[mesh] private node pre-disable failed before public Mesh activation; MQTT enable will retry lane isolation',
|
||||
err,
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const disableWormholeForPublicMesh = useCallback(async () => {
|
||||
@@ -4287,10 +4335,7 @@ export function useMeshChatController({
|
||||
}
|
||||
return { ok: true as const, text: successText };
|
||||
} catch (err) {
|
||||
const message =
|
||||
typeof err === 'object' && err !== null && 'message' in err
|
||||
? String((err as { message?: string }).message)
|
||||
: 'unknown error';
|
||||
const message = describeMeshChatControlError(errorMessage(err));
|
||||
const errorText =
|
||||
message === 'browser_identity_blocked_secure_mode'
|
||||
? 'Mesh key creation is blocked while Wormhole secure mode is active. Turn Wormhole off first if you want a separate public mesh key.'
|
||||
@@ -4345,10 +4390,7 @@ export function useMeshChatController({
|
||||
setMeshQuickStatus(null);
|
||||
return { ok: true as const, text };
|
||||
} catch (err) {
|
||||
const message =
|
||||
typeof err === 'object' && err !== null && 'message' in err
|
||||
? String((err as { message?: string }).message)
|
||||
: 'unknown error';
|
||||
const message = describeMeshChatControlError(errorMessage(err));
|
||||
const text = `Could not turn MeshChat on: ${message}`;
|
||||
setIdentityWizardStatus({ type: 'err', text });
|
||||
setMeshQuickStatus({ type: 'err', text });
|
||||
|
||||
@@ -49,8 +49,12 @@ export async function controlPlaneJson<T>(
|
||||
const fallback =
|
||||
res.status === 429
|
||||
? 'control_plane_rate_limited'
|
||||
: res.status === 530
|
||||
? 'local_control_plane_unavailable'
|
||||
: res.status === 502
|
||||
? 'backend_unavailable'
|
||||
: `control_plane_request_failed:${res.status || 'unknown'}`;
|
||||
throw new Error(data?.detail || data?.message || fallback);
|
||||
throw new Error(data?.detail || data?.message || data?.error || fallback);
|
||||
}
|
||||
return data as T;
|
||||
}
|
||||
|
||||
@@ -58,6 +58,8 @@ export interface InfonetNodeStatusSnapshot {
|
||||
total_events?: number;
|
||||
active_events?: number;
|
||||
known_nodes?: number;
|
||||
author_nodes?: number;
|
||||
registered_nodes?: number;
|
||||
chain_size_kb?: number;
|
||||
head_hash?: string;
|
||||
unsigned_events?: number;
|
||||
|
||||
@@ -66,6 +66,7 @@ function callWorker(payload: Omit<WorkerRequest, 'id'> & Record<string, unknown>
|
||||
async function callWormhole(path: string, body: Record<string, unknown>): Promise<string> {
|
||||
const data = await controlPlaneJson<{ result?: string }>(path, {
|
||||
method: 'POST',
|
||||
requireAdminSession: false,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
@@ -1554,6 +1554,7 @@ function normalizeContactMap(input: Record<string, Contact> | Record<string, unk
|
||||
async function persistContactToWormhole(peerId: string, contact: Contact): Promise<void> {
|
||||
await controlPlaneJson('/api/wormhole/dm/contact', {
|
||||
method: 'PUT',
|
||||
requireAdminSession: false,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
peer_id: peerId,
|
||||
@@ -1565,6 +1566,7 @@ async function persistContactToWormhole(peerId: string, contact: Contact): Promi
|
||||
async function deleteContactFromWormhole(peerId: string): Promise<void> {
|
||||
await controlPlaneJson(`/api/wormhole/dm/contact/${encodeURIComponent(peerId)}`, {
|
||||
method: 'DELETE',
|
||||
requireAdminSession: false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1601,17 +1603,20 @@ export async function hydrateWormholeContacts(force: boolean = false): Promise<R
|
||||
if (!force && contactsHydration) {
|
||||
return contactsHydration;
|
||||
}
|
||||
contactsHydration = controlPlaneJson<{ ok: boolean; contacts: Record<string, unknown> }>(
|
||||
'/api/wormhole/dm/contacts',
|
||||
)
|
||||
.then((data) => {
|
||||
contactCache = normalizeContactMap(data.contacts || {});
|
||||
return contactCache;
|
||||
})
|
||||
.catch(() => contactCache);
|
||||
contactsHydration = hydrateWormholeContactsFromNode().catch(() => contactCache);
|
||||
return contactsHydration;
|
||||
}
|
||||
|
||||
export async function hydrateWormholeContactsFromNode(): Promise<Record<string, Contact>> {
|
||||
const data = await controlPlaneJson<{ ok: boolean; contacts: Record<string, unknown> }>(
|
||||
'/api/wormhole/dm/contacts',
|
||||
{ requireAdminSession: false },
|
||||
);
|
||||
contactCache = normalizeContactMap(data.contacts || {});
|
||||
contactsHydration = Promise.resolve(contactCache);
|
||||
return contactCache;
|
||||
}
|
||||
|
||||
function getStoredContacts(): Record<string, Contact> {
|
||||
if (!shouldUseWormholeContacts() && !contactsHydration && typeof window !== 'undefined') {
|
||||
void hydrateWormholeContacts();
|
||||
|
||||
@@ -13,6 +13,7 @@ export async function bootstrapEncryptAccessRequest(peerId: string, plaintext: s
|
||||
await ensureWormholeReadyForSecureAction('bootstrap_encrypt');
|
||||
const data = await controlPlaneJson<{ result: string }>('/api/wormhole/dm/bootstrap-encrypt', {
|
||||
method: 'POST',
|
||||
requireAdminSession: false,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
peer_id: peerId,
|
||||
@@ -26,6 +27,7 @@ export async function bootstrapDecryptAccessRequest(senderId: string, ciphertext
|
||||
await ensureWormholeReadyForSecureAction('bootstrap_decrypt');
|
||||
const data = await controlPlaneJson<{ result: string }>('/api/wormhole/dm/bootstrap-decrypt', {
|
||||
method: 'POST',
|
||||
requireAdminSession: false,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
sender_id: senderId,
|
||||
|
||||
@@ -102,6 +102,8 @@ export interface WormholeDmInviteImportResult {
|
||||
trust_fingerprint: string;
|
||||
trust_level: string;
|
||||
detail?: string;
|
||||
pending_prekey?: boolean;
|
||||
prekey_detail?: string;
|
||||
contact: Record<string, unknown>;
|
||||
}
|
||||
|
||||
@@ -1091,7 +1093,9 @@ export function getWormholeDmInviteImportErrorResult(
|
||||
}
|
||||
|
||||
export async function fetchWormholeDmRootHealth(): Promise<WormholeDmRootHealth> {
|
||||
return controlPlaneJson<WormholeDmRootHealth>('/api/wormhole/dm/root-health');
|
||||
return controlPlaneJson<WormholeDmRootHealth>('/api/wormhole/dm/root-health', {
|
||||
requireAdminSession: false,
|
||||
});
|
||||
}
|
||||
|
||||
export async function bootstrapWormholeIdentity(): Promise<WormholeIdentity> {
|
||||
@@ -1759,7 +1763,8 @@ export async function registerWormholeDmKey(): Promise<WormholeIdentity & { ok:
|
||||
return controlPlaneJson<WormholeIdentity & { ok: boolean; detail?: string }>(
|
||||
'/api/wormhole/dm/register-key',
|
||||
{
|
||||
method: 'POST',
|
||||
method: 'POST',
|
||||
requireAdminSession: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1771,6 +1776,7 @@ export async function issueWormholeDmSenderToken(
|
||||
): Promise<WormholeDmSenderToken> {
|
||||
return controlPlaneJson<WormholeDmSenderToken>('/api/wormhole/dm/sender-token', {
|
||||
method: 'POST',
|
||||
requireAdminSession: false,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
recipient_id: recipientId,
|
||||
@@ -1788,6 +1794,7 @@ export async function issueWormholeDmSenderTokens(
|
||||
): Promise<WormholeDmSenderTokenBatch> {
|
||||
return controlPlaneJson<WormholeDmSenderTokenBatch>('/api/wormhole/dm/sender-token', {
|
||||
method: 'POST',
|
||||
requireAdminSession: false,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
recipient_id: recipientId,
|
||||
@@ -1815,6 +1822,7 @@ export async function openWormholeSenderSeal(
|
||||
): Promise<WormholeOpenedSeal> {
|
||||
return controlPlaneJson<WormholeOpenedSeal>('/api/wormhole/dm/open-seal', {
|
||||
method: 'POST',
|
||||
requireAdminSession: false,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
sender_seal: senderSeal,
|
||||
@@ -1833,6 +1841,7 @@ export async function buildWormholeSenderSeal(
|
||||
): Promise<WormholeBuiltSeal> {
|
||||
return controlPlaneJson<WormholeBuiltSeal>('/api/wormhole/dm/build-seal', {
|
||||
method: 'POST',
|
||||
requireAdminSession: false,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
recipient_id: recipientId,
|
||||
@@ -1850,6 +1859,7 @@ export async function deriveWormholeDeadDropTokenPair(
|
||||
): Promise<WormholeDeadDropTokenPair> {
|
||||
return controlPlaneJson<WormholeDeadDropTokenPair>('/api/wormhole/dm/dead-drop-token', {
|
||||
method: 'POST',
|
||||
requireAdminSession: false,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
peer_id: peerId,
|
||||
@@ -1865,6 +1875,7 @@ export async function issueWormholePairwiseAlias(
|
||||
): Promise<WormholePairwiseAlias> {
|
||||
return controlPlaneJson<WormholePairwiseAlias>('/api/wormhole/dm/pairwise-alias', {
|
||||
method: 'POST',
|
||||
requireAdminSession: false,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
peer_id: peerId,
|
||||
@@ -1880,6 +1891,7 @@ export async function rotateWormholePairwiseAlias(
|
||||
): Promise<WormholeRotatedPairwiseAlias> {
|
||||
return controlPlaneJson<WormholeRotatedPairwiseAlias>('/api/wormhole/dm/pairwise-alias/rotate', {
|
||||
method: 'POST',
|
||||
requireAdminSession: false,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
peer_id: peerId,
|
||||
@@ -1895,6 +1907,7 @@ export async function deriveWormholeDeadDropTokens(
|
||||
): Promise<WormholeDeadDropTokensBatch> {
|
||||
return controlPlaneJson<WormholeDeadDropTokensBatch>('/api/wormhole/dm/dead-drop-tokens', {
|
||||
method: 'POST',
|
||||
requireAdminSession: false,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
contacts,
|
||||
@@ -1911,6 +1924,7 @@ export async function deriveWormholeSasPhrase(
|
||||
): Promise<WormholeSasPhrase> {
|
||||
return controlPlaneJson<WormholeSasPhrase>('/api/wormhole/dm/sas', {
|
||||
method: 'POST',
|
||||
requireAdminSession: false,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
peer_id: peerId,
|
||||
@@ -1929,6 +1943,7 @@ export async function confirmWormholeSasVerification(
|
||||
): Promise<WormholeSasConfirmResult> {
|
||||
return controlPlaneJson<WormholeSasConfirmResult>('/api/wormhole/dm/sas/confirm', {
|
||||
method: 'POST',
|
||||
requireAdminSession: false,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
peer_id: peerId,
|
||||
@@ -1944,6 +1959,7 @@ export async function acknowledgeWormholeSasFingerprint(
|
||||
): Promise<WormholeSasConfirmResult> {
|
||||
return controlPlaneJson<WormholeSasConfirmResult>('/api/wormhole/dm/sas/acknowledge', {
|
||||
method: 'POST',
|
||||
requireAdminSession: false,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
peer_id: peerId,
|
||||
@@ -1959,6 +1975,7 @@ export async function recoverWormholeSasRootContinuity(
|
||||
): Promise<WormholeSasConfirmResult> {
|
||||
return controlPlaneJson<WormholeSasConfirmResult>('/api/wormhole/dm/sas/recover-root', {
|
||||
method: 'POST',
|
||||
requireAdminSession: false,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
peer_id: peerId,
|
||||
@@ -1970,7 +1987,9 @@ export async function recoverWormholeSasRootContinuity(
|
||||
}
|
||||
|
||||
export async function listWormholeDmContacts(): Promise<WormholeDmContactsResponse> {
|
||||
return controlPlaneJson<WormholeDmContactsResponse>('/api/wormhole/dm/contacts');
|
||||
return controlPlaneJson<WormholeDmContactsResponse>('/api/wormhole/dm/contacts', {
|
||||
requireAdminSession: false,
|
||||
});
|
||||
}
|
||||
|
||||
export async function putWormholeDmContact(
|
||||
@@ -1979,6 +1998,7 @@ export async function putWormholeDmContact(
|
||||
): Promise<{ ok: boolean; peer_id: string; contact: Record<string, unknown> }> {
|
||||
return controlPlaneJson('/api/wormhole/dm/contact', {
|
||||
method: 'PUT',
|
||||
requireAdminSession: false,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
peer_id: peerId,
|
||||
@@ -1992,6 +2012,7 @@ export async function deleteWormholeDmContact(
|
||||
): Promise<{ ok: boolean; peer_id: string; deleted: boolean }> {
|
||||
return controlPlaneJson(`/api/wormhole/dm/contact/${encodeURIComponent(peerId)}`, {
|
||||
method: 'DELETE',
|
||||
requireAdminSession: false,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user