trunks: CRUD API, PJSIP trunk config, live registration status, Trunks page

voip.ms-first trunk form with the portal checklist, POP server list
and Test button; passwords are write-only and encrypted. Registration
and IP-auth trunks render per 03 §5.3 (TCP/TLS add ;transport=). The
API refuses a server name that doesn't resolve and transports that are
off, because either makes Asterisk drop the trunk.

Live state: registration from PJSIPShowRegistrationsOutbound (re-read
on every Registry event: two sub-accounts on one server share
Username/Domain), reachability from PJSIPShowEndpoint and trunk
ContactStatus (static contacts aren't in PJSIPShowContacts). Trunk
state is admin-only. Dashboard gets a "Trunks up" card.

trunk-test.ts (in pnpm test:asterisk) runs a fake voip.ms (second
Asterisk): the good trunk registers, a wrong password is Rejected,
qualify reaches the provider; its recorded AMI drives the unit tests.
scripts/e2e/m3-t1.mjs does the same through the UI. The acceptance
check with a real voip.ms sub-account is still to do.

Tests: settle() now awaits an apply instead of a fixed sleep (flaked
under full-suite load).

Task: M3-T1
This commit is contained in:
iamdoubz committed 2026-10-17 19:42:14 -05:00
1 parent c0c4036887
commit 1b7f60b5f2
48 files changed
+2749 -33

No files matched your search

+11 -1
View File
@@ -1,19 +1,22 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Live data from /api/live (05 §1): one WebSocket per tab, reconnecting with backoff.
import { create } from 'zustand';
import type { ExtensionLive, LiveMessage } from '@selfphoneng/shared';
import type { ExtensionLive, LiveMessage, TrunkLive } from '@selfphoneng/shared';
type LiveStore = {
/** WebSocket state: 'connecting' before the first snapshot. */
status: 'connecting' | 'open' | 'reconnecting';
amiConnected: boolean;
extensions: Record<string, ExtensionLive>;
/** Admins only (empty for users). */
trunks: Record<string, TrunkLive>;
};
export const useLive = create<LiveStore>(() => ({
status: 'connecting',
amiConnected: false,
extensions: {},
trunks: {},
}));
function apply(m: LiveMessage) {
@@ -24,6 +27,7 @@ function apply(m: LiveMessage) {
status: 'open',
amiConnected: m.data.ami.connected,
extensions: Object.fromEntries(m.data.extensions.map((e) => [e.number, e])),
trunks: Object.fromEntries((m.data.trunks ?? []).map((t) => [t.trunkId, t])),
};
case 'extension.updated':
return { extensions: { ...s.extensions, [m.data.number]: m.data } };
@@ -31,6 +35,12 @@ function apply(m: LiveMessage) {
const { [m.data.number]: _gone, ...rest } = s.extensions;
return { extensions: rest };
}
case 'trunk.updated':
return { trunks: { ...s.trunks, [m.data.trunkId]: m.data } };
case 'trunk.removed': {
const { [m.data.trunkId]: _gone, ...rest } = s.trunks;
return { trunks: rest };
}
case 'ami.status':
return { amiConnected: m.data.connected };
default: