SD2 (14 §3): out of credits pauses IPQualityScore (one warning, one
event), the FTC list answers meanwhile, the dashboard shows a dismissable
spam_quota warning, and spamQuotaCheck resumes it every 6 hours once
credits are back. A newly saved key resumes it at once.
Follow-up: confirm with the owner's real key that the Credit Usage API
costs no credit (TODO in 14 §2.1).
SD1 (14 §9): the spam/lookups tables and calls columns, the
IPQualityScore and FTC lookups with the cache, the spam settings with
write-only keys, the provider Test routes and Settings → Spam check.
SD1 (spec 14, decision 0006): IPQualityScore first, then the FTC Do Not
Call complaint list, for outside US callers who aren't in anyone's
contacts. Information only, off by default; credits running out pause
IPQualityScore until its credit usage API says they're back.
Owner follow-up to the softphone (spec 05 §5).
- the incoming call notification comes from a service worker
(public/sw.js) so it carries Answer and Decline buttons (Chromium);
Answer and a plain click focus the softphone tab, Decline doesn't
- shown whenever the tab isn't focused, not only when it's hidden; kept
up while it rings and closed once it stops
- the softphone panel says how to allow notifications when they're blocked
MB1 (spec 13). Decision 0005: our own Android/iPhone app, Expo in the
mobile repo, user portal only, no push in v1.
- mobile_devices (migration 0013): one-time pairing codes from My settings
(QR or server + code, 10 minutes), traded by the app for an spm_ device
token that acts as its user and never as an admin
- POST /api/me/mobile-devices/pair (web session only), POST /api/mobile/pair,
GET/DELETE /api/me/mobile-devices
- signing out everywhere (password change or reset, disabling) removes
paired phones too
- My settings: Mobile app section with the QR code and paired phones
- mobile/ (its own repo) ignored here
Two separate reviews of the SSO branch and all 207 API routes (spec 12 §8.6). Every route was
guarded as meant. Fixed, each with a test:
- login: a directory 503 counts as a failed sign-in; a locked directory account is a refused
password, not "unavailable"
- open redirect after OpenID sign-in (a tab in next)
- directory email matching only for non-admins without their own password; others connect on
My settings (POST /api/me/identities/ldap); only admins disconnect a directory account
- OpenID session check reads the refreshed ID token's claims too
- connect is POST /api/auth/oidc/connect (CSRF header)
- stored bind password, client secret and SMTP password tied to their server
- 409 role_managed when a provider sets the role; empty Contrack PATCH is a 400
- @fastify/rate-limit on every route (RATE_LIMIT_PER_MINUTE), 10/min on Test buttons; a
password asked again shares the login limiter
Live tests pass against lldap 0.6.3, Samba 4 AD and Keycloak 26.8.1.
Spec 12 S5: job directoryCheck (hourly) looks up every active user with a directory account;
one deleted, disabled there or no longer matched by the user filter is disabled here, sessions
ended. Nothing changes when the directory is unreachable or a run would disable most people;
the last admin with a password is kept; nobody is re-enabled. Dashboard warning
directory_check (system.directoryWarning). The by-id search is a filter object: ldapts string
filters send bytes >= 0x80 as UTF-8, so AD's objectGUID never matched. Live test against a
Samba 4 AD DC: nested-group admin, the disabled bit, objectGUID, a deleted user disabled.
Spec 12 S4: the login form checks the SelfPhoneNG password first, then the directory (ldapts,
service-account search, the user's own bind on a second connection). Presets for lldap, AD,
FreeIPA and OpenLDAP; Test with an optional test login. A directory account connects by email,
never makes a user; adminFilter sets the role (applyRole shared with OpenID). One Disconnect
route per kind (oidc, ldap). Live test against lldap 0.6.3 over LDAPS.
Spec 12 S3: job oidcCheck refreshes every OpenID session at the provider every 15 minutes and
applies groups and role again; invalid_grant ends the session, an outage doesn't. A live test
makes a throwaway Keycloak realm and signs in through its login form (passed against 26.8.1).
NODE_EXTRA_CA_CERTS for a private CA. Admin guide: docs/admin-guide/sign-in.md.
Spec 12 S2: openid-client 6.8 (decision 0004). Discovery, PKCE + state + nonce, ID token
signatures checked, userinfo always read. Users connect their OpenID account from My account
and sign in either way; no accounts are made; a user without a password is matched once by
verified email. auth.mode mixed and oidc (admins keep a password way in), role from adminGroups,
allowedGroups, signing out at the provider. Settings -> Sign-in with Test. Tested against a fake
provider with RS256 and ES256 keys.
Spec 12 S1: migration 0012 (password_hash nullable, user_identities, sessions.method and the
OpenID token columns), auth.mode (only "password" until S2), GET /api/auth/methods, login body
{login, password} (email still accepted), signIn on users and /api/auth/me, a Signs in with
column on Users. The last active admin with a password can't be deleted, disabled or demoted.
Owner decisions 2026-10-25: a user connects their OpenID account and signs in with either it or
their password; the admin picks passwords only, OpenID only (admins keep a password way in), or
mixed. No automatic accounts. OIDC sessions re-checked every 15 min with the refresh token;
signing out also signs out at the provider; password sign-ins keep two-step. LDAP is one more way
to check a password. Decision 0004: openid-client and ldapts. Tasks S1-S5.
Spec 11 §6.2: a phone's directory is the extensions, its owner's own contacts (accepted shares
included), then the company's, in both modes. In Contrack mode, contacts from before Contrack
are offered for copying (contrack.toCopy, POST …/copy-to-contrack: one Contrack bulk import,
skipping what's already there; the builtin rows stay). Tested against a fake and a real
Contrack 2.0.2.
Spec 11 §4.4: the contrackLog job puts each connected person's calls with their Contrack
contacts on that contact's timeline once (contrack_call_log, migration 0011), from when they
connected. With summaries on (My settings → Contacts, needs call summaries), the escaped summary
and action items go in, and the first action item becomes a follow-up due the next day. Waits up
to 30 min for a summary; Contrack down is retried, refusals noted once. Tested against a fake and
a real Contrack 2.0.2.
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.
Spec 11 §4: contacts.provider and contacts.contrack (local address only, company token
write-only), each person's own token (users.contrack_token_enc, migration 0010) in My settings,
books read from Contrack with a 5-minute cache, Add to contacts and accepted shares created in
Contrack, the company account on desk phones. Setup wizard step 8 Contacts. deploy/contrack
(2.0.2, pinned), Caddy/nginx blocks, decision 0003, admin guide Contacts. Tested against a fake
and a real Contrack 2.0.2 (contrack-live.test.ts).
C1 (spec 11): /api/me/contacts and /api/contacts (admin), our own vCard 2.1/3.0/4.0 and CSV
readers with a preview, contact_shares (migration 0009) for offers, and contact names in call
history, voicemail, Home, the softphone and the call report email. Admin → Contacts.
Owner decisions 2026-10-23: vCard import for admins and users; desk-phone caller name while
ringing is its own task (C4); the company is one Contrack account shown to everyone, each user
has their own; sharing offers a copy the colleague accepts or denies; imports skip duplicates.
My settings → Call report: each user's calls of the last hour, day (at a time) or week (on a
day at a time), company time zone: numbers, status, talk time, recorded, and optionally each
recorded call's summary (10 §3). No calls, no email. Job callReports every minute, 5 min after
the period ends; periods computed in PostgreSQL (DST-safe). Migration 0008_call_reports.
Owner asked for total minutes, call time and missed calls in one place, and the money left on
voip.ms. Reports now totals answered calls, talk time and outside minutes (in from arrival, out
from the answer: roughly what the provider bills), charts outside minutes per day, and shows each
voip.ms trunk's balance (getBalance; verify with a real account).
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.
Owner wanted the first user on 1. Numbering ranges now choose the Next free suggestion
(Settings → Numbering), the setup wizard takes a number per person, and a one-digit number
can't also be a menu key of a digital receptionist with direct dialing. Dialplan patterns that
take an internal number match one digit (_X!); golden case short-numbers proves it in Asterisk.
Each release attaches selfphoneng-probe-linux-x64 and -arm64 (Node's single executable
applications: esbuild bundle + postject into the official Node binary, checked against
nodejs.org's SHASUMS256.txt), the bundle as selfphoneng-probe.cjs (Node 22+) and SHA256SUMS.
Built in binary.Dockerfile with BuildKit's local output, so Gitea's runner can build it too.
deploy/probe/selfphoneng-probe.service runs it under systemd, sandboxed.
The server checks itself (public IP, DNS, local networks, phone ACL, SIP transports, free audio
ports) and says how to fix each problem. A quick or complete network test asks the probe
(apps/probe, own image) to test this server from outside: HTTP/HTTPS, SIP over UDP/TCP/TLS, the
browser phone's /ws, sampled audio ports. The probe only tests the address that asks.
The audio port range comes from RTP_PORT_START/END (default 10000-20000).
Docker only, never bare metal (owner, 2026-10-21). A new version with pending migrations backs
up first (database, voicemail, sounds, MOH) and stops if it can't (SKIP_BACKUP_BEFORE_MIGRATE
to override). update.sh: checks, changelog, backup (node dist/backup-cli.js), log compaction,
build while the old version runs, restart, self-check, and rollback with restore. Container
logs capped in compose. Admin guide: Updating, for compose and Portainer.
- 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.
.github/workflows/release.yml runs on Gitea and on the GitHub mirror (release: published only)
and pushes selfphoneng-api and selfphoneng-asterisk to that forge's registry: ghcr.io with the
job token, the Gitea registry with secrets REGISTRY_USER + REGISTRY_TOKEN (Gitea's job token
can't push packages). Tags: the version, plus <major>.<minor> and latest unless it's a
pre-release. It stops unless the tag is v + package.json's version, and checks the images
before pushing.
Run locally with act_runner exec against a throwaway registry: v0.0.1 pushed 0.0.1, 0.0 and
latest for both images (VERSION 0.0.1, OCI labels, source archive selfphoneng-0.0.1/);
v9.9.9 failed at the version check.
Version 0.0.1 in all package.json files, CHANGELOG.md started, release steps in 08 §3.1.
Task: none (owner request, 2026-10-20)
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
Under a full parallel run the event-log and webhook inserts took longer than the sleep, so
2-3 tests failed on most runs (also before M11-T8). They poll for the rows now.
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
System page laid out after 3CX's admin dashboard: a System information panel (version,
host name, company, public IP, LAN IPs, ports, telephony, restart needed, copy buttons,
how to restart) and an Event log panel (system_events, 90 days: trunk down/up, Asterisk
down/up, restart needed, config failed, 911, public IP changed, backups, disk) with Load
more and a CSV download.
About (/about, no sign-in, linked from the login page, My phone and System): AGPL-3.0
notice, credits, the repository link, and "Code v<version>", which downloads
selfphone.tar.gz: the api image now tars its own build context (no .env, data,
node_modules, build output or .git) before installing anything.
Task: M11-T7
Nothing has shipped, so there is nothing to update (the script itself was removed in
a152508). The v1.0 definition of done now requires update.sh written, reviewed and
tested on a real server; the admin guide says there are no releases yet.
Task: M11-T7
System shows whether telephony runs and needs a restart, and how to restart it
(docker compose restart asterisk); the dashboard's restart warning, the Network hint and
the public IP email point there. The user drawer's Calling tab gets the Record calls
select the spec listed (recordMode, already in the dialplan). e2e m11-system.mjs.
Task: M11-T7
docs/admin-guide/: install and the setup wizard, voip.ms with screenshots, NAT and SIP
ALG, firewall (ufw, nftables limited to the voip.ms POP), fail2ban and CrowdSec, E911
duties, recording consent, troubleshooting (08 §3.3), updating, backup and restore,
monitoring and log rotation. scripts/update.sh as 08 §3.1 describes. The e2e checks
fail2ban's and CrowdSec's stock Asterisk rules against a real failed sign-in, takes the
screenshots and checks every link and anchor.
Task: M11-T7
GET /api/reports/summary and /api/reports/queues (company-time-zone days, last 30 by
default, a year at most) and the Reports page: totals, stacked bars per day, missed per
day, a weekday x hour heatmap and the queue table. Plain divs, no chart library.
Task: M11-T6
API tokens (Settings -> API tokens): Authorization: Bearer sp_<40 base62>,
stored as sha256, shown once. A token acts as its admin with their current
role and stops when they're disabled; Bearer requests skip the CSRF header
and never fall back to the cookie. A token can't manage tokens.
Webhooks (Settings -> Webhooks): call.started/answered/ended,
voicemail.new, emergency.call, trunk/extension registration changes, from
the live state; queued per webhook, sent every 10 s with
X-SelfPhoneNG-Signature (HMAC of timestamp.body), retried after 1 m, 5 m,
30 m, 2 h, 12 h, then failed. Private/loopback targets are refused in the
connection's own DNS lookup (no rebinding), unless allowed. Send test and
a delivery log per webhook.
The e2e found voicemail.new missing for mailboxes without a user: the
voicemail scan now queues it for every mailbox. vmpin.test.ts gets 180 s:
Git Bash forks make it ~85 s on Windows (the earlier flaky gate run).
Task: M11-T5
apps/api/src/jobs.ts takes over the once-a-minute timer from index.ts
(no croner: every schedule in 04 §8 is whole minutes). Each job has a
running flag; errors are logged.
- Retention (at start and when the date changes, once AMI is up):
recordings, voicemail past voicemail.retentionDays (VoicemailRemove, so
Asterisk renumbers and updates MWI), expired sessions, webhook
deliveries older than 30 days.
- publicIpWatch (every 5 min, Settings -> Network): a changed IP is saved,
audited, applied (restart required) and emailed to admins.
- trunkWatch: a registering trunk rejected/unregistered for 5 minutes ->
one email per outage.
- diskWatch (hourly): under 10% free -> one email a day.
- A failed daily backup emails admins too.
Settings gets a Network tab (public IP, Detect, re-check) and voicemail
retention on the Recording tab.
Task: M11-T4
A backup is data/backups/selfphoneng-YYYYMMDD-HHMM.tar.gz (mode 0600):
pg_dump, voicemail, custom sounds, music on hold, recordings if asked,
and a manifest. No generated Asterisk config: it holds plaintext
passwords and comes back from the database. The api makes one a day after
backup.time (once-a-minute check, one try a day) and the dashboard warns
when today's is missing. Settings -> Backups sets the schedule, lists them
for download and has Back up now.
scripts/restore.sh <file> refuses a backup made under another APP_SECRET
(the manifest carries a fingerprint, not the secret), asks for yes, then
replaces the database and the folders the backup has.
Tested on two throwaway servers (docker:dind running the real images):
back up on one, restore on the other. That found the api image crashing
on start since M1: tsc doesn't copy auth/common-passwords.txt into dist.
The Dockerfile copies it now and CI loads the app inside the image.
Task: M11-T3
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
auth/totp.ts per 08 §2.4: RFC 6238 (HMAC-SHA1, 30 s, 6 digits, ±1
step) with node:crypto only, checked against the RFC's test vectors.
Migration 0003: users.totp_recovery_hashes and totp_last_step.
Admins turn it on from My account (the header's "Account"): scan the
QR or type the key, confirm a code, get 10 recovery codes once. Login
asks for the code only after the password is right (401 totp_required,
no session); a wrong code is a failed login (rate limit, audit). A
step signs in once (totp_last_step updated atomically) and a recovery
code once (array_remove guarded by any()). Off and new codes need the
password; another admin can turn it off for a lost phone, which signs
that user out. Users list shows it (twoStep).
Also, found by the full e2e chain on one shared database:
- Linphone tokens: only the exact token issued is accepted. The last
base64 character can carry ignored bits, so another spelling decoded
to the same signed token.
- Test scripts no longer assume a fresh system: m3-t3 picks Dan in the
test box, m8-check dials whatever slot the park took (findslot =
next), m10-t4 uses the admin's existing extension, m11-t1 clears the
public IP on its own first (local nets are in the transports while
one is set, and transports change only on a restart).
e2e m11-t1-totp; the full chain M1-M11 (33 scripts) passes.
Task: M11-T1
Headers (08 §2.4) on every response: CSP (connect-src also allows
WEBRTC_WS_URL's origin when set), nosniff, Referrer-Policy,
Permissions-Policy. The dark-mode bootstrap moved from an inline
<script> to /theme.js so script-src stays 'self'.
Phone ACL (08 §2.2): with sip.restrictPhonesToLocalNets, every desk
extension endpoint gets deny 0.0.0.0/0 and permit for each local and
extra net. The spec's snippet was wrong: contact_acl names acl.conf
sections, a pjsip type=acl is the global ACL (it would filter trunks),
and Contact-header rules let a NATed remote phone through. Browser
endpoints come through the reverse proxy and aren't filtered. Golden
case phone-acl. Settings → Security holds it, with the provisioning
switches.
PIN rules for PINs changed from the phone's voicemail menu, which went
around the api: externpasscheck = sp-vm-pincheck (in the Asterisk
image, same rules as isTrivialPin, a unit test keeps them in step) and
minpassword = 4. Accepted PINs reach the api through AMI
VoicemailPasswordChange and are stored; before, the next config write
silently put the old PIN back.
e2e m11-t1: no CSP violations across admin pages; Asterisk refuses a
REGISTER ("Not match Endpoint ACL") until the network is listed; from
the phone, 1111 is refused by the check script and 7391 is stored and
survives a later config write.
Task: M11-T1
POST /api/live/calls/:linkedid/spy {mode} per 04 §7.3: rings the
admin's desk phone (409 if it isn't connected, 400 without an
extension) and, when they answer, runs ChanSpy on the call's first
user leg (never a trunk or Local channel, never the admin's own),
which for an inbound or queue call is the agent. Options qE / qEw /
qEB: no beep or name, the spy leg ends with the call, whisper to that
user only, or barge (both sides hear). Audited as call_spy.
Dashboard: Listen / Whisper / Barge on talking calls with a user on
them, and "your desk phone is ringing" once sent. The spy leg shows as
its own call; its Hang up stops listening.
e2e m10-t4: 380 calls 381, the admin's 382 runs ChanSpy with each
mode's options, and the barge leg ends with the call. The roadmap
records that M10's milestone check (real Yealink/Grandstream, Linphone
scanning the QR) waits for hardware the owner doesn't have yet.
Task: M10-T4
GET /api/extensions/:id/softphone-qr (admin) and /api/me/softphone-qr
return {url, qr, expiresAt}: the link Linphone fetches and its QR as a
PNG data URL (qrcode), valid 24 h. Both are audited like revealing the
SIP password. JSON rather than a bare PNG, so the page can show the
link for desktop Linphone too.
The token is stateless per 07 §5: base64url(extensionId.expiry.hmac),
HMAC-SHA256 with APP_SECRET, checked timing-safe. /prov/linphone/
<token>.xml matches it before the other file names are lower-cased
(the token is case-sensitive); a bad or expired token is a 404 and a
counted failure. auth_info keys checked against liblinphone's config
reader; proxy keys stay (verify).
Web: a "Set up a phone app" section on My settings and in the admin
Credentials tab: Linphone with the QR, MicroSIP/Baresip by hand, and
the plain warning about mobile apps without push. e2e m10-t3: as a
user, the QR's link gives the XML and a baresip phone registers with
only what it holds.
Task: M10-T3
Yealink: BLF keys from line key 2, named after the extension (or the
number, e.g. a parking slot), with **N pickup; keys up to 33 past the
last BLF are cleared so removed BLFs leave the phone. Both vendors get
the time zone and NTP, and a phonebook URL that carries the phone's
credentials (phones fetch it like the config).
Time zones: packages/shared/src/phone-timezones.ts maps the US/Canada
zones (with aliases), UTC and London to Yealink's offset and name and
a POSIX string (Grandstream P64). Anything else is UTC, and the Phones
page warns.
/prov/phonebook/<mac>.xml (and <mac>/phonebook.xml, the name
Grandstream asks for under its path): every extension, then the
company contacts that have numbers, in the phone's vendor's format.
Same Basic auth as the config.
The config preview now masks both secrets wherever they appear (the
phonebook URL carries the provisioning password) and says when the
time zone falls back.
Golden files for both templates and both phonebooks in
src/provisioning/__golden__. e2e m10-t2: BLF keys, Central time, the
phonebook fetched from the URL in the config, hints for the BLF
targets, and the time zone warning. Vendor key names stay (verify per
model) until a real phone is checked.
Task: M10-T2
/api/devices CRUD (MAC in any format → 12 lowercase hex; a 20-char
provisioning password, stored encrypted and shown to admins, who type
it into the phone) and GET /:id/config-preview with passwords masked.
GET /prov/... per 07 §2: <mac>.cfg (Yealink), cfg<mac>.xml
(Grandstream), the empty Yealink common file. Basic auth with the MAC
and the device's password (timing-safe), 404 for unknown MACs or the
other vendor's file, 403 outside the local nets when LAN-only, 404 when
provisioning is off, Cache-Control no-store, last provisioned time and
IP recorded. Limits: 20 requests / 10 min per IP outside the local nets
(an office's phones rebooting together would pass it), and 10 failures
block the IP for an hour. A first request without credentials isn't a
failure: phones ask that way and retry after the 401.
Templates (pure functions of a ProvisionContext) hold the account,
voicemail, pickup codes and the provisioning settings; BLF, time zone
and phonebook come with M10-T2.
Web: Phones page (table, drawer with what to type into the phone,
config preview; manual settings for generic phones). e2e m10-t1: phone
added in the UI, 401 then the file, and a baresip phone registers with
only the account from it.
Also: the loadSnapshot test made its database inside the test (20 s);
under a full parallel run that timed out now and then. It now does it
in a hook like the others.
Task: M10-T1
The owner's voip.ms account has no SMS. M9-T1 (API client, number
import, server list) stays: it doesn't need SMS. Its check against a
real account (API enabled, this server's IP allowed) is still open.
Nothing in later milestones depends on SMS.
Task: M9-T1
integrations/voipms/client.ts: voipmsApi(url, creds, method, params)
per 06 §5.1 (GET rest.php, 15 s timeout, status "success" or an error
code). The API password is only in the query string: never logged,
never in an error. Known codes get a message for people
(invalid_credentials, ip_not_enabled, ...). getServers and getDids
parse forgivingly; no_did is an empty list.
Routes: GET /api/trunks/:id/voipms-servers, GET and POST
/api/trunks/:id/import-dids. Import adds only numbers voip.ms lists
and that aren't added yet, label from the description, destination
hangup. 400 without API credentials, 502 provider_error with voip.ms's
reason. VOIPMS_API_URL overrides the endpoint for tests.
Web: the trunk form's server list comes from voip.ms once the trunk
has API access, falling back to the built-in list with the reason.
"Import numbers from voip.ms" sits in the trunk form's API section and
the setup wizard's numbers step. Saving a new API password asks
voip.ms again (found in e2e: the old error stayed).
The response shapes are from the 06 spec, not a real account: voip.ms's
API docs need a login. Marked TODO(spec) and (verify) in 06. Tests use
a fake rest.php; e2e m9-t1 too (trunk created off, so nothing registers
to the real voip.ms).
Task: M9-T1
withinSlaPercent was "answered within sla_seconds / answered", so a
queue where a caller timed out still showed 100%. It is now the usual
service level: answered within sla_seconds out of every caller the
queue is done with, leaving out those who hung up before sla_seconds
(nobody could have answered them in time). Timed out and turned away
count as missed; callers still waiting aren't counted. Written into
04; the page label says "Callers answered within N s".
Real calls: m8-t1 now shows 50% (1 answered in time, 1 timed out, the
early hang-up left out) and the milestone check 67% (2 of 3), and
asserts it. The check moves to queue 552 (*45552) so it doesn't clash
with m8-t1's 550 on a shared database.
Task: M8-T1
scripts/e2e/m8-check.mjs, the scenario from 09 M8 with real calls from
a DID: queue 550 with agents 340 and 341, max wait 30 s, then 342. 340
pauses with *45550 and 341 is paused by an admin, so three callers
wait. 341 comes back and gets the longest waiting caller, parks them
on 701 (blind transfer to 700) and 343 picks up by dialing 701; 341
takes the next caller and the last one rings 342 after 30 s. Stats:
3 offered, 2 answered, 1 timed out.
Roadmap marks the M8 milestone check done; the e2e README describes
m8-t4, m8-t5 and m8-check.
Task: M8-check
/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
public/logo.png is the source; icon-192.png (the mark, cropped and padded
on the logo's background) and logo-512.png (login card) are cut from it
with ffmpeg so pages don't load the 1.2 MB original. Brand (icon + name)
replaces the plain app name in the admin and My Phone headers.
res_parking was configured in M0 (700, slots 701-720, slot hints); this
makes parked calls visible. ParkingTracker loads ParkedCalls and follows
ParkedCall/UnParkedCall/ParkedCallTimeOut/ParkedCallGiveUp; everyone
gets parked.updated/parked.removed. Who parked comes from
ParkerDialString.
A blind transfer to 700 parks without running Park() in the dialplan,
so the live call's "parked" state now comes from those events by
ParkeeLinkedid; the picking-up phone runs ParkedCall() while talking,
which no longer counts as parked.
Web: "Parked calls" on the Dashboard and on Home, where "Pick up 701"
calls the slot like Call back (softphone, or the desk phone first).
CDR fixture park-pickup (recorded: 101 parks an inbound call, 103 picks
it up); the call counts as answered by 101 with 103 in rang. e2e m8-t2
with real phones: park by blind transfer, hint InUse, both lists, pick
up from Home, a second park on 702 (findslot = next) picked up by
dialing it from another phone. Specs 01/04/05 note what was verified.
Task: M8-T2
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
Asterisk sends NewCallerid after a Local;1 channel's Hangup, which re-created
the channel and left a ghost ringing call on the dashboard. Only Newchannel
creates a channel now. The e2e waits that matched text also shown on the
previous page (now that the dashboard lists calls) wait for the route.
Task: M7-T4
CallTracker groups channels by Linkedid from channel events (loaded from
CoreShowChannels on connect); call.updated/call.removed go to admins.
The dashboard lists calls now with state and timer, hang up (confirm)
and transfer to an internal number (AMI Redirect of the caller), both
audited. e2e m7-t4: 3 concurrent calls match core show channels.
Task: M7-T4
sp-sub-record now also sets CDR(SP_REC); phone numbers can record every
call (record_inbound), with the notice from Settings → Recording played
first (SP_REC_ANNOUNCE). Aggregation links the MixMonitor file as a
recordings row (size, ffprobe length). /api/recordings lists, plays
(Range; admins and the people on the call) and deletes; recordings past
recordings.retentionDays are deleted daily.
Web: Recordings page (play, download, delete), a player in Call history
and My calls, a Recording settings tab with the consent notice (08 §5),
and "Record every call" on phone numbers.
e2e m7-t3: the notice plays before a recorded inbound call and the
recording plays from Call history (the M7 acceptance). The AMI
Originate helper moved to scripts/e2e/ami.mjs.
Task: M7-T3
/api/calls (admin): filters by company-time-zone days, direction,
status, user, phone number and number search; newest first with a
keyset cursor; /:id adds the raw CDR legs; /export.csv with the same
filters (RFC 4180, spreadsheet-formula cells quoted). /api/me/calls
lists my calls with my side (in/out) and whether I missed it.
Web: admin Call history (filters, Load more, a row expands to its legs,
Export CSV), My Phone → Calls (click a number to call back: softphone
or desk phone first), and Missed calls on Home.
e2e m7-t2 with real calls found that a call going straight to voicemail
(DND) wasn't in the user's history: the mailbox owner of a VoiceMail
leg now counts as rang (and the IVR fixture's expectation follows).
Task: M7-T2
services/cdr.ts stores every AMI Cdr event in cdr and, 5 s after a
call's last event, builds its calls row (direction, status, from, to,
DID, answered by, talk time) and call_extensions (caller, rang,
answered) per 04 §5.4; linkedids without a calls row are caught up on
AMI connect. Aggregation is idempotent.
test/asterisk/cdr-record.ts records 8 scenarios with our config, the
fake voip.ms (now shared in provider.ts) and baresip phones; each has a
fixture and a test with the expected calls row. Queue abandon and park
come with M8.
Found while recording: Asterisk 24's Cdr event has no LinkedID unless
cdr_manager maps it, its times carry no zone (now usegmtime), and the
mappings read CDR variables, so the dialplan sets CDR(SP_DID) and
CDR(SP_DIRECTION) next to the channel variables. The Asterisk image
needs a rebuild for the cdr_manager.conf change.
Task: M7-T1
GitHub warned on every run that checkout@v4, setup-node@v4 and
pnpm/action-setup@v4 target the deprecated Node 20. checkout v7 and
action-setup v6 run on node24; setup-node is dropped because the check
job already runs in node:24.
Task: M6-T8
On GitHub's hosted runners a job that isn't in a container can't reach
a service by its name, so every database test failed with getaddrinfo
EAI_AGAIN postgres. The check job now runs in node:24 (Gitea/Forgejo
act_runner already runs jobs in containers) and installs ffmpeg so the
sound and greeting tests run instead of skipping. The voicemail test's
afterAll no longer throws when beforeAll failed.
Checked locally by running the job's steps in node:24 next to a
postgres:18-alpine reachable as "postgres": 481 passed, 5 skipped
(ffmpeg).
Task: M5-T3
Killing the baresip container left 'Avail' contacts in Asterisk; the
next script's phones (same IP) then had two contacts each and m5-t3's
presence check saw no state change. stop() now sends baresip SIGTERM
(found via /proc: no procps in the image) and waits before removing.
Task: M6-T8
/api/blocked-numbers stores numbers normalized (any input format) and
mirrors them to AstDB SP/BLOCK/<number>, which every trunk entry checks
(Hangup 21); the AstDB resync on connect includes them. Blocked numbers
page: add with a note, unblock.
e2e m6-check runs the M6 milestone scenario with real calls into a trunk
entry: DID → office hours (closed → voicemail; *31N from an admin's phone
forces open) → menu (1 → ring group ringing two phones together, 2 → a
user), and a caller blocked in the UI is rejected while others get
through.
Task: M6-T8
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
The DestinationPicker now offers every type that exists (user, voicemail,
ring group, digital receptionist, office hours, announcement, outside
number, hang up) on every page that routes calls; queues and
conferences join in M8. API summaries show the number of dialable
targets ("Ring group Sales (500)", 05 §3). Wizard step 8 gains "Ring
everyone": it creates or refreshes a ring group "Everyone" of all
extensions (unanswered → the first user's voicemail); an IVR isn't
created there because it needs a recorded greeting. m3-t5 now routes
the main number through "Ring everyone" and still passes.
Task: M6-T6
/api/announcements CRUD (sound must be a prompt, destination must exist
and not be itself, delete refused while referenced); the generator
renders sp-ann-{id} (Answer, Playback, destApp) and destApp sends
announcement destinations there. The ivr golden case gains "press 3:
hours announcement, then back to the menu". Announcements are managed in
a section of Sounds & music (05 updated); the DestinationPicker gains
"Announcement".
Task: M6-T5
/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
/api/sounds (admin) takes the raw file, converts it with ffmpeg in one
pass to <id>.wav (8 kHz) and <id>.g722 (HD for G.722 calls) in SOUNDS_DIR,
measures it with ffprobe, and stores it as custom/<id>. Delete answers 409
with what still plays it (IVR greeting/invalid sound, announcements, the
recording notice). Sounds & music page: drop or pick a file (name
suggested from the file name), play, rename, delete. MOH classes stay in
M8. The web client and greetings share one raw-upload helper; the
api test audio helpers moved to test/audio.ts.
e2e m6-t1: drag-and-drop upload in the browser, Asterisk plays
custom/<id>, rename, delete refused while in use, then deleted.
Task: M6-T1
People lists every extension with its status dot and a search box: a
right-hand column on wide screens, /me/people on small ones. Call uses the
browser softphone when it's on, else POST /api/me/click-to-call, which
originates to the user's desk phone first (409 if none is registered).
During a call: Transfer (blind), Talk first (consult), or drag the call
card onto a person; a held call card offers Complete transfer whenever
another call is active. useSoftphone moved to its own module.
e2e m5-t3: the dot changes ~30 ms after Asterisk's hint; click-to-call
rings 174 before 172. cdp click() takes a scope (People also has
Call/Transfer buttons). trunk-test ignores the RTCP-at-teardown EPERM
error seen once.
Task: M5-T3