Spec 11 §6.1: sp-sub-dial-user asks /api/internal/caller-name (func_curl, 2 s at most; this
machine only, with an APP_SECRET-derived key, not logged) for the ringing user's name for the
caller, and sets it on each ringing leg (pre-dial handler, CONNECTEDLINE), never on the call, so
forwards and other members don't see someone else's contacts. Ring group/queue/number prefixes
kept. Contrack lookups never wait (cache, contactsWarm every 5 min). Proven on real Asterisk
with baresip: apps/api/test/asterisk/cnam-test.ts.
Owner: the first upload always played first. A class is now an Asterisk playlist in the order
set on Sounds & music (move up/down), new uploads last, with a Shuffle option (sort = random).
moh-test.ts plays both in a real Asterisk and reads the file order from its debug log.
- Asterisk doesn't load app_system, func_shell, app_disa, res_agi, app_externalivr.
- Turning two-step sign-in on asks for the password again.
- *97/*98: one voicemail login at a time per phone, 10 an hour (sp-sub-vmlimit).
- /prov: an unknown MAC gets the same 401 as a known one.
- The login limiter sweeps keys past every window.
Also the v1 checklist with the owner's results from 2026-10-21.
Its Contact holds pn-prid=...:voip&...:remote, and PJSIP_DIAL_CONTACTS passes that '&' through,
so Dial rejected the whole device list and the call dropped for every device of the user.
sp-sub-contacts walks the AOR's contacts, drops pn-* params and FILTERs the rest. call-test
registers a phone with such a Contact and checks it is rung.
Owner's decision after the M11-T8 review: nothing capped how many calls a stolen password could
run at once. Settings → Security: calls.maxPerExtension (3) and calls.maxTotal (10), 0 = no
limit. Each sp-route-* counts the caller's extension (or whose forward it is: SP_LIMITEXT from
sp-sub-forward) with GROUP(spext) and every outside call with GROUP(spout); over a limit the
caller hears "all circuits are busy". 911 is never limited.
trunk-test.ts on a real Asterisk, limits 1 and 2: a 2nd call from 101's phone and a 3rd in total
are refused (with both 0 the same check fails). call-limits.test.ts covers the 0 combinations.
Also fixes the Security tab's hint: browser phones still work from anywhere.
Task: M11-T8
The 08 §1 threat table checked line by line against the code, plus a review by a separate agent
that hadn't written it. Findings were reproduced with a failing test before each fix:
- Critical: "011…,4" turned the route's Goto into Goto(sp-route-…,011…,4,1), starting past the
class check, so any extension could call abroad. FILTER on the dialed number (also stops "&"
adding a Dial target). trunk-test.ts proves it.
- High: -web endpoints (desk phone's password, any transport) skipped the phone ACL; now
127.0.0.1 only when it's on. call-test.ts: UDP refused, WebSocket from 127.0.0.1 registers.
- IVR direct dial no longer reaches paging groups or parked calls (sp-ivr-direct).
- Trunks identify_by = ip: a From of trunk-<id> from anywhere no longer rings phones.
- .dockerignore is an allowlist, so the public source archive can't pick up stray files.
- CSRF check on the matched route (/%61pi/… got around it); Linphone tokens masked in logs;
/api/live checks Origin and re-checks the session; *45/** filter digits; pg_dump password in
the environment; more webhook ranges blocked.
The review and what's left for the owner are in 08 §6.
Task: M11-T8
The certificate lives in data/tls (fullchain.pem + privkey.pem), mounted
read-only into asterisk and the api. Asterisk quietly drops transport-tls
when the files are missing, so the api won't turn TLS on unless they load
and match, shows the certificate on Settings -> Security, and warns on the
dashboard when it expires within 14 days. scripts/tls-cert.sh copies it
from the bundled Caddy (or given paths) and reloads res_pjsip: Asterisk 24
re-reads a changed certificate on reload, calls stay up.
transport-tls checks the provider's certificate (verify_server). Asterisk
24 checks a qualify's certificate against the IP instead of the name, which
fails on voip.ms, so TLS trunks don't qualify; REGISTER and INVITE verify
the name fine.
Task: M11-T2
/api/moh-classes: create (slug, not "default"), delete (409 while an
extension or queue uses it), add/play/remove files. Files are 8 kHz
wav in MOH_DIR/<class>/ and sound_files rows of kind moh. Extensions
and queues may only pick default or an existing class.
Two things found with real calls, written back into 03 and 04:
- An empty class plays silence on Asterisk 24. Only classes with
music are rendered, so a channel asking for an empty one gets
default.
- The generator reloads only changed files, and a file upload changes
no config (except a class's first/last). The route sends moh reload
after every file change.
Web: a Music on hold section on Sounds & music, and a select of sets
in the user drawer's Phone tab and the queue form. load-golden makes
the class folders like the api does. Golden case queue gains two
classes. e2e m8-t5: set made in the UI, upload listed by Asterisk, a
queue caller hears it, a user's moh_suggest, delete refused while
used, an emptied set falls back to default.
Task: M8-T5
/api/paging-groups CRUD (members as memberIds). The generator renders
sp-page-{id} per 03 §6.11 (desk phones only, auto-answer headers via
sp-sub-autoanswer) plus the number in sp-local-numbers. Added the `s`
option so phones already on a call aren't paged, which also keeps a
pager who is a member from being paged themselves. A group with no
members just hangs up.
*80<ext> intercom checks the target first: digits only and an endpoint
in sp-from-ext, else ss-noservice (the spec's one-liner passed
${EXTEN:3} straight into Page()). Both written back into 03.
Web: Paging page (list, drawer form with member checkboxes). Golden
case paging. e2e m8-t4 with baresip phones that honor the headers
(sip_autoanswer): an ordinary call rings, a page is answered by itself
and muted, the busy pager is skipped, *80 is two-way, *80999 refused.
The check on a real Yealink/Grandstream stays open (09).
Task: M8-T4
/api/conferences CRUD: PINs stored encrypted, shown to admins (they hand
them out), never in the audit log; an admin PIN needs a different room
PIN (checked against the stored values on PATCH). The generator renders
sp-conf-{id} per 03 §6.11 (PIN prompt, 3 tries, admin PIN joins with
sp_admin_user), the number in sp-local-numbers and destApp conference.
Two changes from the spec's snippet, both found with real calls and
written back into 03:
- Set(CONFBRIDGE(bridge,max_members)) is ignored once ConfBridge()
names a bridge profile: a 4th caller got into a room of 3. Each room
now has its own [sp_room_<n>](default_bridge) profile in
confbridge_selfphoneng.conf.
- A recorded room runs sp-sub-record on each caller's leg (just before
ConfBridge) instead of record_conference, so the files are ordinary
call recordings: call history, Recordings page, retention.
Web: Conference rooms page (list, drawer form), "Conference room" in
the DestinationPicker. Golden case conferences. e2e m8-t3: room built
in the UI, wrong PIN refused and asked again, admin PIN joins with
flags AM, a 4th caller turned away, recordings listed, and a no-PIN
room with two desk phones.
Task: M8-T3
Queues per 03 §6.8: queues_selfphoneng.conf (members as Local agents
following their hint), sp-queue-{id} (answer, prefix, record, Queue with
max wait, timeout destination), the number in sp-local-numbers and
destApp queue. Golden case queue; the Asterisk load test now checks
`queue show` lists each queue's members.
*45<queue> toggles login and checks PQMSTATUS/UPQMSTATUS, so a code for
a queue the caller isn't an agent of plays ss-noservice and stores
nothing (spec snippet fixed). Pauses live in queue_member_state, AstDB
SP/QPAUSE (resynced on connect) and app_queue; QueuePause is re-sent
after every `queue reload all` and rollback, which unpause everyone.
/api/queues CRUD with members [{extensionId, penalty}], admin
pause/unpause, GET /:id/stats; /api/me/queues/:id/pause|unpause for
Home. Stats come from queue_calls (new table), filled from app_queue's
AMI events plus UserEvent SpQueueExit in the fail branch: a real call
showed that when no agent rings, the Queue part of a call vanishes into
the next leg's CDR, and Asterisk 24 sends QueueCallerAbandon on
timeouts too.
Live: QueueTracker (QueueStatus on connect and after applies, queue
events); admins see every queue, users the queues they're agents of.
Web: Queues page (list, form with every column, detail page with
waiting callers, agents and pause buttons, today's stats), queue tiles
on the dashboard, queue login switches on Home, Queue in the
DestinationPicker.
CDR fixtures for queue answered/abandoned/timeout calls. e2e m8-t1 with
real phones: 3 callers wait with 2 agents, one paused by *45550 and one
in the UI, unpause connects the longest waiting caller, one times out
to the overflow phone, stats count it all. The whole e2e chain passes.
Task: M8-T1
Feature codes per 03 §6.13: *78/*79 DND, *72<number>/*73 forward always,
**<ext> directed pickup, *8 group pickup (also as a dialplan line, since
pickupexten only catches a phone's own INVITE), *43 echo test, *65 say
my extension; queue login and intercom stay for M8. SpToggle DND/CFU
update user_runtime_state; a *72 target the user's calling permission
doesn't allow is removed from AstDB, audited (forward_rejected) and sent
to the user as a toast (new private live message). Forward targets are
checked the same way everywhere (services/forwards.ts: an existing
internal number, or an outside number some outbound rule allows them).
GET/PATCH /api/me: DND, cfu/cfb/cfna, voicemail email options and PIN;
PostgreSQL first, then AstDB; AstDB resync on connect now includes DND
and forwards. Web: /me/settings (forwarding, DND, voicemail PIN and
email options), a status card on Home, toasts.
e2e m6-t7 with real phones covers every code, the refused forward toast,
and the settings page reaching AstDB; it found the settings forms losing
"Saved." on remount.
Task: M6-T7
/api/time-conditions (company time zone by default, ranges validated,
unique override digit, never pointing at itself) with a live status
("open/closed/holiday now", same logic as the dialplan, shared), and
POST /:id/override, which writes PostgreSQL then AstDB SP/TCOVR.
/api/holidays CRUD plus import-us-federal (observed dates, computed in
code, skips days already listed). services/astdb.ts resyncs AstDB on
every AMI connect (PostgreSQL wins); services/toggles.ts applies
SpToggle TCOVR from *31N/*32N/*33N.
Generator: sp-tc-{id} per 03 §6.10 (override, holidays within the next
year, ranges rendered a minute short because GotoIfTime's end is
inclusive), sp-tc-override with a no-service fallback, admin-only
*3[123]X in sp-featurecodes, destApp time_condition. The date changing
in the company time zone triggers a forced apply so holidays roll over.
Golden cases holidays and ivr-business-hours.
Office hours page: click-and-drag half-hour grid plus an exact range
list, destinations, holiday destination, override key, status chip with
Force open / Force closed / Automatic; Holidays tab with the US federal
import. e2e m6-t4 found two bugs (pointer capture release throwing,
drawer overflow) and checks routing, AstDB, *321/*331 from an admin
phone, refusal for others, and the import reaching the dialplan.
Task: M6-T4
/api/ivrs CRUD: greeting and wrong-key sounds must be prompts, every
option destination must exist (an option may point at its own menu),
keys unique; delete drops the menu's own options before checking
references, inside a transaction so a refused delete keeps them. The
generator renders sp-ivr-{id} per 03 §6.9 (Background greeting,
WaitExten, keys sorted, i/t retry then give up, include sp-local-numbers
for direct dial), adds the number to sp-local-numbers, and destApp sends
ivr destinations there. Golden case ivr (incl. # and self-reference).
Web: Digital receptionist page with a 3x4 keypad grid (pick a key, then
its destination or "unused"), SoundPicker (pick, play, upload new right
in the form), DestinationPicker gains "Digital receptionist" and a shared
target source; useDescribeDestination labels unsaved keys.
e2e m6-t3: menu built in the UI, the greeting plays, keys 1/2 ring the
right phones, a wrong key plays pbx-invalid and repeats the greeting.
Task: M6-T3
/api/ring-groups CRUD with members as ordered memberIds (unique, must
exist), number from the ring-group range, a no-answer destination
(never itself), and delete refused while something points at it. The
generator renders sp-rg-{id} (Dial through sp-rg-member: all at once with
the total ring time, or one at a time with the per-member time, music
instead of ringing on request), adds the number to sp-local-numbers, and
destApp sends ring_group destinations there. Golden case ringgroups.
The DestinationPicker is now generic over targets picked by id and gains
"Voicemail of a user" and "Ring group" (M6-T6 completes it). Caller-name
prefixes (DIDs and ring groups) get a space before the caller's name.
Ring groups page with an ordered member list.
e2e m6-t2 found the new-group form failing on an unset no-answer
destination (fixed: defaults to hang up). It checks ring-all (both ring,
191 answers) and hunt (190 alone for 5 s, then 191) with real calls. The
generator debounce test now waits for the apply instead of a fixed 150 ms.
Task: M6-T2
GET /api/me/webrtc returns the <ext>-web identity, WebSocket URL
(WEBRTC_WS_URL overrides wss://PUBLIC_HOSTNAME/ws) and STUN/TURN servers.
SoftphoneEngine wraps SIP.js 0.21.2: register with backoff reconnect,
call, answer, decline, hang up, remote audio. A minimal dock on /me
enables it (asks for the microphone on the click) and dials/answers;
M5-T2 grows it into the full panel.
Generator: ice_host_candidates keeps the LAN address
(include_local_address), so LAN browsers don't need hairpin NAT, and -web
endpoints set from_domain = selfphoneng.invalid because SIP.js drops
INVITEs whose From host starts with a digit (Docker/VPS hostnames).
e2e m5-t1 (Docker): browser registers as 170-web and has two-way audio
with a baresip phone both ways. The LAN and hotspot check on the owner's
server is still open (roadmap note).
Task: M5-T1
Generate voicemail_selfphoneng.conf (03 §7) with one mailbox per extension
that has voicemail on, turn on ARG3 in sp-sub-user, and add *97/*98 to
sp-featurecodes (which replaces the pending placeholder). Endpoints with a
mailbox get mwi_subscribe_replaces_unsolicited so phones that SUBSCRIBE for
MWI aren't refused with a 500. A new test:asterisk check leaves a message,
sees MWI on a baresip phone, plays it with *97 and sees MWI turn off.
Task: M4-T1
Dialplan: sp-emergency-dial (911, 9911) first in sp-from-ext;
sp-emergency has no class, channel-limit or DND check, dials every
enabled trunk in name order with the E911 caller ID, and raises
UserEvent SpEmergency. Static sp-emergency-alert (_X! so "0" matches)
for the ring-out.
api: SpEmergency -> emergency_events row, live emergency.call (admins
and notify users only), email via nodemailer (SMTP from settings) and
an Originate to every notify extension, each outcome recorded. Test
button runs the same with TEST in front, records and dials nothing.
GET/PATCH /api/settings (smtp password write-only, references
checked, read-only system keys), SMTP test, /api/system/warnings.
Fix: clearing a nullable setting stored SQL NULL (now JSON null).
UI: Settings page (Email, Emergency with the 06 §6 notice), user
Emergency tab, dashboard warnings banner, red 911 banner.
Verified: generator/emergency.test.ts; trunk-test.ts dials 911 from a
caller with no class to the fake provider (E911 caller ID in RPID,
SpEmergency on AMI, alert plays on baresip 101); scripts/e2e/m3-t4.mjs
(SMTP sink receives the test email, 101 rings, banner shows).
nodemailer pinned to 10.0.11 (newest release over two weeks old).
Task: M3-T4
Outbound routes CRUD, reorder and POST /test (mirrors the dialplan:
911 and internal numbers first, then the first matching enabled route,
class check, usable trunks, caller ID priority route > user > default).
The first trunk creates the default rules: toll-free first (because
1NXXNXXXXXX also matches 1800...), US/Canada, international disabled.
Generator: Asterisk orders patterns in one context by specificity, not
by file order, so each route gets its own sp-or-{id} context and
sp-outbound-routes includes them in position order (spec 03 §6.12
updated). sp-route-{id}: class check -> ss-noservice, caller ID
priority, per-trunk GROUP channel limit, failover on CHANUNAVAIL or
CONGESTION, all-circuits-busy-now when every trunk is full or off.
UI: Outbound rules page (↑/↓ reorder, pattern rows with help, ordered
trunk list, caller ID override, test box) and a Calling tab on users
(calling permission, outbound caller ID).
trunk-test.ts: 101 dials 3125550000 -> the fake provider receives
13125550000 with the DID in Remote-Party-ID (From user is the
sub-account), two-way audio; a caller without a class hears
ss-noservice. scripts/e2e/m3-t3.mjs covers the UI. With real voip.ms
still to do: whether per-call caller ID is honored (06 §4).
Task: M3-T3
DIDs CRUD (any input format, normalized; 409 on duplicates; a number
that is an emergency or default caller ID can't be removed). Shared
destination service: assertDestinationExists, describeDestination
("Ring Dan Doe (101)") and findReferences over every destination
column in 02 §1 plus the unmatched-number setting; deleting an
extension a destination points at is now a 409 naming the places.
Generator: destApp (user, external, hangup for now), sp-from-trunk-*
per enabled trunk, sp-did-exists (left out when empty) and sp-inbound
with caller-name prefix. Golden case voipms-basic. Phone numbers page
with a minimal DestinationPicker (user, outside number, hang up).
trunk-test.ts: the fake provider calls the DID with a 10-digit
Request-URI like voip.ms; baresip 101 answers, caller ID arrives
normalized, 500 packets each way on both legs. scripts/e2e/m3-t2.mjs
checks the UI → Asterisk path. Still to do with real voip.ms: the
mobile-to-DID call and recording which header carries the DID (06 §3).
Task: M3-T2
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
LiveState loads extension states and registered devices from Asterisk on
every AMI connect and follows ExtensionStatus/ContactStatus; the hub sends
a snapshot then deltas batched every 250 ms (devices for admins only),
pings every 30 s and closes unauthenticated sockets with 4401. The web UI
keeps one reconnecting socket in a zustand store, shows a Reconnecting
banner, a Dashboard (online, on a call, telephony engine) and status dots.
Fix: Asterisk 24 has no 'pjsip reload'; reload with
'module reload res_pjsip.so'. AMI errors now carry the CLI output.
Milestone check, automated: call-test.ts registers two baresip phones and
holds a 30 s call with RTP both ways (pnpm test:asterisk); the recorded
session drives the live-state tests. scripts/e2e/m2-live.mjs runs api +
Asterisk + phones + browser on a private Docker network: a new extension
is live in Asterisk in ~1.3 s, the dashboard shows 2/2 online and red dots
during the call.
Task: M2-T6
Golden cases empty, one-user, webrtc-disabled and hostile-names; each also
renders identically from shuffled input. pnpm test:asterisk boots the
asterisk image with every golden case and fails on a missing endpoint or
context or any ERROR log line (checked against a deliberately broken case).
On 24.0.0-rc2: 101 and 101-web load with every set_var.
Task: M2-T3