40 Commits
Author SHA1 Message Date
iamdoubz 10b499e263 Merge pull request 'Security/freepbx campaign' (#10) from security/freepbx-campaign into main
ci / check (push) Successful in 1m5s
ci / images (push) Successful in 4s
release / images (release) Successful in 59s
Reviewed-on: #10
2026-10-29 18:48:11 -05:00
iamdoubz 34f443385d release: version 0.0.13
ci / check (push) Successful in 1m5s
ci / images (push) Successful in 22s
ci / check (pull_request) Successful in 1m5s
ci / images (pull_request) Successful in 4s
2026-10-29 18:40:30 -05:00
iamdoubz f435b475ed repo: ignore voip.ms's API example bundle
ci / check (push) Successful in 1m19s
ci / images (push) Successful in 1m30s
2026-10-29 18:35:06 -05:00
iamdoubz 1979a02827 voipms: IP restriction on the setup checklist, getCDR checked against voip.ms's examples 2026-10-29 18:35:06 -05:00
iamdoubz 08bc611dec security: fraud alerts (M11-T10), admin access spec (M11-T9) 2026-10-29 18:35:06 -05:00
iamdoubz 63286a1954 asterisk: AMI limited to the classes we use, no shell-running modules in the image 2026-10-29 18:35:06 -05:00
iamdoubz 6bd2aaec56 Merge pull request 'Asterisk/24.0.0 rc3' (#9) from asterisk/24.0.0-rc3 into main
ci / check (push) Successful in 1m11s
ci / images (push) Successful in 48s
release / images (release) Successful in 2m10s
Reviewed-on: #9
2026-10-28 17:23:19 -05:00
iamdoubz 80e97b4118 release: version 0.0.12
ci / check (push) Successful in 1m25s
ci / images (push) Successful in 2m35s
ci / check (pull_request) Successful in 1m13s
ci / images (pull_request) Successful in 5s
2026-10-28 17:13:31 -05:00
iamdoubz 0a18d5354d asterisk: 24.0.0-rc3, TLS trunks qualified again
rc3 checks a qualify's certificate against the host name, so TLS trunks get
qualify_frequency = 60 and Test qualifies them. The upgrade check and
pnpm test:asterisk pass on rc3. Spec 01's copy of the Dockerfile is in sync
again, and the version test now compares all of it.
2026-10-28 17:11:18 -05:00
iamdoubz a0121d4975 test: Asterisk upgrade check
verified.ts records what the code relies on Asterisk doing and the version it
was measured on; pnpm test fails while the Dockerfile pins another one.
pnpm test:asterisk-upgrade boots the image next to a TLS provider and lists
every behaviour that moved and what depends on it.
2026-10-28 17:01:26 -05:00
iamdoubz 678d45e4f9 Merge pull request 'Security/review 2' (#8) from security/review-2 into main
ci / check (push) Successful in 1m14s
ci / images (push) Successful in 53s
Reviewed-on: #8
2026-10-28 16:59:55 -05:00
iamdoubz 60348cf0c5 security: a key for the network test probe (08 §7 #9)
ci / check (push) Successful in 1m13s
ci / images (push) Successful in 4m2s
ci / check (pull_request) Successful in 1m42s
ci / images (pull_request) Successful in 28s
Settings → Network makes a key, shown once and stored encrypted; the api sends
it to the probe as a Bearer token, and a new probe address drops it. A probe
with PROBE_KEY only answers that key. Without one it stays open, and
Diagnostics strongly recommends setting it.
2026-10-28 16:28:23 -05:00
iamdoubz 958405651b docs: security review 2 (08 §7) 2026-10-28 16:01:57 -05:00
iamdoubz db244b116a security: recordings, webhooks, Contrack tokens, probe (08 §7 #3 #4 #6 #7)
A phone that only rang no longer gets a coworker's recording, transcript or
summary. The voicemail webhook leaves the transcript out. The saved Contrack
token only goes to its own address. The probe trusts X-Forwarded-For from a
local proxy only.
2026-10-28 16:01:56 -05:00
iamdoubz 1db5aa22dc security: sign-in by email and connecting accounts (08 §7 #1 #2 #5 #8)
Matching by email skips admins and anyone with a connected account, in both
directions. A connected directory account is only swapped by an admin, and
an OpenID connect finishes only in the browser whose session asked.
2026-10-28 16:01:56 -05:00
iamdoubz 8a1f17099d Merge pull request 'Chore/qol' (#7) from chore/qol into main
ci / check (push) Successful in 1m17s
ci / images (push) Successful in 5s
Reviewed-on: #7
2026-10-28 15:18:49 -05:00
iamdoubz a75113b6ac web: report spam from my calls, reports on Blocked numbers
ci / check (push) Successful in 1m24s
ci / images (push) Successful in 25s
ci / check (pull_request) Successful in 1m15s
ci / images (pull_request) Successful in 7s
Users report an outside call that rang them: the spam check's line when it
said medium or high, else a reason they type. Admins block or dismiss each
report on Blocked numbers, and the Dashboard counts them. Migration 0015 adds
spam_reports.
2026-10-28 15:07:05 -05:00
iamdoubz 2bc7d5cde5 web: my reports, the calls made to me
Users get a Reports tab: the admin summary limited to calls that rang or were
answered by them. Answered and talk time are the calls they picked up.
2026-10-28 13:39:33 -05:00
iamdoubz 19ca4ffc91 docs: v1 checklist ticks, DID in the Request-URI
ci / check (push) Successful in 1m12s
ci / images (push) Successful in 30s
The outside install, restores, the week on the owner's server, Portainer
updates and both registries are done. The mobile app is after v1.
2026-10-28 13:28:42 -05:00
iamdoubz 1bc0fe8811 docs: CrowdSec rule for slow SIP scanners
ci / check (push) Successful in 1m21s
ci / images (push) Successful in 22s
The collection's asterisk_bf and asterisk_user_enum hold 5 failures per
address and drain one every 10 s, so a scanner trying every 20 s (two
failures per try: InvalidAccountID, ChallengeResponseFailed) drains as
fast as it fills and is never banned. Seen on a live install: hours of
INVITEs from one address, no decision.

network.md: a local scenario, selfphoneng/asterisk-slow-bf (10 failures,
one drained every 5 min), a table of how it differs from the stock rule,
a whitelist for remote offices and phones, how to read cscli metrics,
and cscli explain with a log line (pointing --file at the acquisition
YAML parses the YAML itself and fails on every line).
2026-10-28 10:56:40 -05:00
iamdoubz add90204c0 Merge pull request 'Feat/ui refresh' (#6) from feat/ui-refresh into main
ci / check (push) Successful in 1m5s
ci / images (push) Successful in 5s
release / images (release) Successful in 58s
Reviewed-on: #6
2026-10-28 10:22:30 -05:00
iamdoubz 4369dd5ae1 release: version 0.0.11
ci / check (push) Successful in 1m7s
ci / images (push) Successful in 22s
ci / check (pull_request) Successful in 1m10s
ci / images (pull_request) Successful in 9s
2026-10-28 10:04:42 -05:00
iamdoubz 3d0f091bba web: icons, grouped admin sidebar, Home cards, phone-style softphone
ci / check (push) Successful in 2m5s
ci / images (push) Successful in 3m7s
lucide-react (1.56.0) is the one icon set; it hides icons from screen
readers by default and only the icons used are bundled (+6 kB gzipped).
It replaces the ✕ ☀ ☾ ↑ ↓ ▶ ✓ ← characters and the hand-drawn menu and
phone icons.

The admin sidebar's 21 pages are grouped under Call routing, People,
Features, History and System, each with an icon; the current page gets a
left bar. The My Phone tabs have icons and a blue active state.

Home is a greeting and cards (new shared Panel): My status, Missed calls
with a call-back button, New voicemails, with friendly empty states.

Softphone: a status pill (dot and word), call controls as icon-over-label
buttons that are toggles with aria-pressed (Mute, Hold, Keypad, Transfer,
red Hang up), and a round keypad with the letters under the digits.
2026-10-28 10:00:29 -05:00
iamdoubz 5db4914fa7 web: theme colors from the logo, readable in both themes
Pages used zinc and sky classes with every dark: variant paired by hand,
and several pairs failed WCAG AA: white on the sky-600 buttons (4.1:1),
zinc-500 secondary text on dark backgrounds (3.7-4.1:1), the 10px zinc-400
labels (2.6:1) and the dashboard's green "Connected" (3.3:1).

index.css now defines theme tokens once, light and dark side by side
(page, surface, subtle, line, line-strong, fg, muted, primary and its
soft/line/ink shades, link, focus), exposed to Tailwind with @theme
inline. Pages use those names, so a palette change is one file. Blue
#2563eb from the logo for buttons and links, the logo's navy #0b1120 for
the dark page. Every text pair is at least 4.5:1 in both themes, most 7:1.

Cards and lists sit on the surface color with a light shadow on an
off-white page, at rounded-xl; native controls follow the theme
(color-scheme). Zinc and sky stay only as data colors: status dots and
chart swatches.
2026-10-28 10:00:20 -05:00
iamdoubz fd3f06c167 web: answer calls and navigate on a phone
On an iPhone the incoming call dimmed the screen with no buttons: iOS
zooms into fields under 16px (the 14px login inputs) and stays zoomed,
and the My Phone tabs were wider than the screen, so the centered
dialog was drawn outside what the phone showed.

Fields are 16px on touch screens and buttons at least 44px. The My Phone
tabs and header links fold into a menu button below tablet width. A
ringing call gets its own screen: full screen on a phone, a card on wider
screens, with round Decline and Answer buttons. Esc still declines.
2026-10-28 09:59:39 -05:00
iamdoubz 79bb1fa78b release: version 0.0.10
ci / check (push) Successful in 1m7s
ci / images (push) Successful in 5s
release / images (release) Successful in 2m53s
2026-10-27 18:37:53 -05:00
iamdoubz 11b531d5b4 Merge pull request 'Feat/spam' (#5) from feat/spam into main
ci / check (push) Successful in 1m6s
ci / images (push) Successful in 6s
Reviewed-on: #5
2026-10-27 18:35:05 -05:00
iamdoubz 70083f8f83 api: share a spam check from its start
ci / check (push) Successful in 1m11s
ci / images (push) Successful in 21s
ci / check (pull_request) Successful in 1m9s
ci / images (pull_request) Successful in 33s
SD5 follow-up (14 §4): checks for the same number were shared only once
they reached the IPQualityScore request, after reading the cache and the
pause state. A check that finished in between cost a second request
(CI: "five at once cost one request" failed). Now shared before anything
is awaited.
2026-10-27 18:32:04 -05:00
iamdoubz 6b0c5aa827 web: spam check in call history
ci / check (push) Failing after 1m9s
ci / images (push) Successful in 44s
SD5 (14 §9.5): each incoming call keeps what the spam check knew when it
ended (saved answers and the FTC list only, no new lookup), so the badge
stays in call history after the saved answer expires. Hidden when the
viewer's contacts name the number. New admin guide page, spam-check.md.

Follow-up: the owner's checks with real keys and a real call (14 §9).
2026-10-27 18:16:24 -05:00
iamdoubz e05065599e web: show the spam check while a call rings
SD4 (14 §5): GET /api/me/caller-check for callers ringing my extensions
now, a badge and line in the softphone's ringing dialog and call card,
and the desk phone label in the caller-name lookup when the answer comes
within 1.2 s. Contacts are never checked; low risk shows nothing.

Follow-up: try it on a real call, in the browser and on a desk phone.
2026-10-27 16:51:29 -05:00
iamdoubz 49d529e185 api: download the FTC complaint list
SD3 (14 §2.2): spamFtcSync reads the FTC's Do Not Call complaints a day
at a time, 15 pages a minute, saving its place; failures are kept for
Settings → Spam check and retried the next minute. Download now starts
one. Old complaints are dropped after windowDays.

Follow-up: one real download with the owner's api.data.gov key.
2026-10-27 16:40:14 -05:00
iamdoubz 70f14288ca api: pause spam check when IPQualityScore credits run out
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).
2026-10-27 16:17:42 -05:00
iamdoubz 6f87d6f663 api: spam check settings, lookups and cache
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.
2026-10-27 15:32:31 -05:00
iamdoubz a3dfcb6b14 docs: spam check spec
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.
2026-10-27 13:51:08 -05:00
iamdoubz dc5965617e release: version 0.0.9
ci / check (push) Successful in 1m4s
ci / images (push) Successful in 5s
release / images (release) Successful in 1m33s
2026-10-27 12:49:05 -05:00
iamdoubz a614319990 Merge pull request 'Feat/mobile' (#4) from feat/mobile into main
ci / check (push) Successful in 1m3s
ci / images (push) Successful in 4s
Reviewed-on: #4
2026-10-27 12:42:08 -05:00
iamdoubz 33d5f1a5f5 web: answer incoming calls from the browser notification
ci / check (push) Successful in 1m34s
ci / images (push) Successful in 27s
ci / check (pull_request) Successful in 1m18s
ci / images (pull_request) Successful in 5s
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
2026-10-27 12:35:43 -05:00
iamdoubz 431ada51aa docs: confirm the mobile app id
ci / check (push) Successful in 1m30s
ci / images (push) Successful in 22s
MB3
2026-10-26 20:54:54 -05:00
iamdoubz 7da0611a98 docs: mobile app MB3 as built
MB3: the Expo skeleton and pairing are in the mobile repo (6e4bc2b);
the device test with a real Android phone and iPhone is still open.
2026-10-26 17:45:28 -05:00
iamdoubz 0cdac0bcdc api: pair the mobile app with device tokens
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
2026-10-26 17:12:53 -05:00
162 changed files with 21347 additions and 987 deletions

No files matched your search

+1
View File
@@ -39,3 +39,4 @@
**/*.key
**/*.log
**/*.pid
docs/voip.ms-api
+4
View File
@@ -6,3 +6,7 @@ data/
coverage/
.pnpm-store/
*.pid
# The mobile app is its own repo (13), checked out here beside the PBX.
mobile/
# voip.ms's API example bundle (06 §5.1): their copyright, kept locally for reference.
docs/voip.ms-api/
+1
View File
@@ -6,3 +6,4 @@ dist/
AGENTS.md
CLAUDE.md
apps/api/src/db/migrations/
mobile/
+2
View File
@@ -29,6 +29,8 @@ write the software around it: database, config generator, API, web UI.
| `docs/spec/09-roadmap.md` | Milestones, ordered tasks, acceptance criteria | Picking your next task |
| `docs/spec/11-contacts.md` | Contacts: the builtin phonebook or Contrack, lookup, the Contrack timeline | Working on contacts or caller names |
| `docs/spec/12-sso.md` | Single sign-on: OpenID Connect (Keycloak and others) and LDAP/LDAPS | Working on sign-in, users or roles |
| `docs/spec/13-mobile.md` | The Android/iPhone app (`mobile` repo): pairing, device tokens, its tasks | Working on the mobile app or its API |
| `docs/spec/14-spam.md` | Spam check: IPQualityScore and the FTC complaint list for unknown callers | Working on spam check or caller information |
## Hard rules
+114
View File
@@ -5,6 +5,120 @@ tag (08 §3.1): `selfphoneng-api`, `selfphoneng-asterisk` and `selfphoneng-probe
the Gitea registry. From v0.0.3 the release also has the probe as downloads for machines without
Docker.
## v0.0.13 — 2026-10-29
**Updating:** no database changes. Portainer: change both tags to `0.0.13` and redeploy. Compose:
`sudo scripts/update.sh v0.0.13`.
Hardening after the attacks on FreePBX servers (spec 08 §8), where stolen trunk passwords were used
for toll fraud:
- **Fraud alerts:** every admin gets an email, and the System page a warning, when the phone system
calls an international or premium number (including Caribbean area codes, which are dialed like
a US call but cost like abroad), when voip.ms bills calls on the trunk that this phone system
didn't make, or when the voip.ms balance drops more than 10 in an hour. The last two need the
voip.ms API login on the trunk. All three can be changed on Settings → Security → **Fraud
alerts**.
- **voip.ms IP restriction** (strongly recommended): the setup checklist on Trunks now says to
turn it on for the sub-account, with only this server's public IP. A stolen trunk password is
then useless anywhere else ([voip.ms](docs/admin-guide/voipms.md#1-in-the-voipms-portal)).
- **Asterisk:** the api's manager login (AMI) can only do what SelfPhoneNG uses, and the image no
longer has the modules that run commands on the server.
## v0.0.12 — 2026-10-28
**Before you update:** this version changes the database (spam reports); the api backs up first,
by itself. Portainer: change both tags to `0.0.12` and redeploy. Compose:
`sudo scripts/update.sh v0.0.12`. If you run the network test probe, update it too (see
**Probe key** below).
- **My reports:** everyone with an extension gets a Reports tab: the calls that rang them, how
many they answered and missed, talk time, calls and missed calls per day, and their busiest
hours.
- **Report spam:** on the Calls page, an incoming outside call has a **Report spam** button. When
the spam check flagged the call, its finding is the reason; otherwise the person types one.
Reports wait at the top of **Blocked numbers** for an admin to Block or Dismiss, and the
Dashboard counts them.
- **Second security review** (spec 08 §7), eight findings fixed, none critical:
- Signing in by email (OpenID or directory) no longer matches admins or anyone who already has
a connected sign-in, and connecting an OpenID account only finishes in the browser that
started it.
- **Recordings, transcripts and AI summaries** only go to whoever made or answered the call.
Ring group and queue members whose phone only rang no longer get a coworker's recording, nor
its summary in their call report email or on their Contrack timeline.
- The `voicemail.new` webhook no longer carries the voicemail's transcript.
- Changing the Contrack address asks for the company token again, and everyone reconnects
their own Contrack token.
- A directory account can only be swapped for another by an admin.
- **Probe key** (strongly recommended): Settings → Network → **Generate key**, then set it as
`PROBE_KEY` on the probe and restart it. The probe then only answers your server. Without a key
it still works, and Diagnostics warns. Behind your own reverse proxy (`TRUST_PROXY=true`), the
proxy must run on the probe's machine.
- **Asterisk 24.0.0-rc3.** TLS trunks get a health check again, and Test checks them too (rc2
couldn't check them over TLS). `pnpm test:asterisk-upgrade` now checks what SelfPhoneNG relies
on Asterisk doing before any version change.
- **Docs:** a CrowdSec rule for slow SIP scanners, and how to check that CrowdSec bans
([network](docs/admin-guide/network.md#crowdsec)).
## v0.0.11 — 2026-10-28
**Updating:** no database changes. Portainer: change both tags to `0.0.11` and redeploy. Compose:
`sudo scripts/update.sh v0.0.11`.
- **Phones:** on an iPhone a ringing call dimmed the screen but showed no Answer button (iOS
zoomed into the small sign-in fields and stayed zoomed, so the call dialog was off screen).
Fields are now 16px on touch screens and buttons at least 44px. A ringing call gets its own
screen: full screen on a phone, with big round Decline and Answer buttons. My Phone's tabs fold
into a menu button on narrow screens.
- **New look:** colors from the logo (blue, and its navy for dark mode), cards on an off-white
page, and text that is easy to read in both themes (all text at least WCAG AA). Icons
throughout.
- **Admin sidebar** grouped into Call routing, People, Features, History and System.
- **My Phone Home** is a set of cards: My status, Missed calls (with call back) and New
voicemails.
- **Browser phone:** a status pill (Ready, Connecting…), phone-style call buttons with icons
(Mute, Hold, Keypad, Transfer, Hang up) and a round keypad with the letters under the digits.
## v0.0.10 — 2026-10-27
**Before you update:** this version changes the database (spam check); the api backs up first,
by itself. Portainer: change both tags to `0.0.10` and redeploy. Compose:
`sudo scripts/update.sh v0.0.10`.
- **Spam check** (spec 14, off until you turn it on in Settings → Spam check): when an outside
US caller isn't in the company's or your contacts, the number is looked up and what's known
shows while it rings. The browser phone shows a **Likely spam** or **Possible spam** badge with
a line such as "Fraud score 92 · spammer · VOIP"; desk phones show the caller's name as
**SPAM LIKELY** or **Spam risk** (you can change these) when the answer is quick; call
history keeps the badge. It's information only: calls are never blocked or delayed, and a
failed lookup just shows nothing. Two sources, either or both:
- **IPQualityScore** (1,000 lookups a month free): sends callers' numbers to IPQualityScore,
so turning it on asks you to confirm. Answers are kept 14 days for flagged numbers and 3 for
the rest. When the lookups run out the dashboard says so, checks fall back to the FTC list,
and it carries on by itself when lookups are back.
- **FTC complaint list** (free api.data.gov key): downloaded to the server, so nothing leaves
it. The first download takes about 7 hours; then it fetches what's new every 24 hours.
- See the [admin guide](docs/admin-guide/spam-check.md), including privacy and GDPR.
- Database changes: `spam_lookups`, `ftc_complaints` (the api backs up first).
## v0.0.9 — 2026-10-27
**Before you update:** this version changes the database (paired phones); the api backs up first,
by itself. Portainer: change both tags to `0.0.9` and redeploy. Compose:
`sudo scripts/update.sh v0.0.9`.
- **Answer from the notification**: the softphone's incoming call notification has **Answer** and
**Decline** buttons (Chrome and Edge; Firefox and Safari show it without buttons). It now shows
whenever the softphone tab isn't in front, not only when it's hidden, stays up while the call
rings and goes away once it stops. If notifications are blocked, the softphone panel says how
to allow them.
- **Pair the mobile app** (spec 13): My settings → Mobile app shows a one-time code (QR, or
server + code, good for 10 minutes) that our own Android/iPhone app trades for a device token.
A paired phone acts as its user, never as an admin, and is listed there to remove. Changing or
resetting a password, or disabling a user, removes their paired phones. The app itself is
still in testing and not in the app stores.
- Database changes: `mobile_devices` (the api backs up first).
## v0.0.8 — 2026-10-26
**Before you update:** this version changes the database (sign-in); the api backs up first, by
+4 -1
View File
@@ -7,7 +7,7 @@
A fully open source, self-hosted business phone system. Bring your own SIP trunk and numbers
(reference provider: voip.ms). Built on Asterisk 24 + TypeScript + PostgreSQL 18, runs in Docker.
**Status:** pre-1.0 (v0.0.8), for testing. See [what's left for v1](docs/v1-checklist.md).
**Status:** pre-1.0 (v0.0.13), for testing. See [what's left for v1](docs/v1-checklist.md).
## What it does today
@@ -19,6 +19,9 @@ A fully open source, self-hosted business phone system. Bring your own SIP trunk
pickup, conference rooms, paging and intercom, music on hold.
- **Voicemail** with email (and transcripts by local AI), **call history**, **recordings**, a **live dashboard** (listen,
whisper, barge) and reports.
- **Spam check** (off until you turn it on): outside callers who aren't contacts are looked up
with IPQualityScore or the FTC complaint list, and flagged in the browser phone, on desk phones
and in call history. Calls are never blocked.
- **Running it:** updates (`scripts/update.sh` for compose installs; a backup before every
database change), backups and restore, background health checks, webhooks and API tokens,
two-step sign-in for admins, limits on outside calls at once.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@selfphoneng/api",
"version": "0.0.8",
"version": "0.0.13",
"private": true,
"license": "AGPL-3.0-or-later",
"type": "module",
+2
View File
@@ -23,6 +23,7 @@ import { didRoutes } from './routes/dids.ts';
import { outboundRouteRoutes } from './routes/outbound.ts';
import { liveRoutes } from './live/hub.ts';
import { meRoutes } from './routes/me.ts';
import { mobileRoutes } from './routes/mobile.ts';
import { soundRoutes } from './routes/sounds.ts';
import { ringGroupRoutes } from './routes/ringgroups.ts';
import { queueRoutes } from './routes/queues.ts';
@@ -159,6 +160,7 @@ export function buildApp(config: Config, db: Db, publicDir = resolve('public'))
app.register(settingsRoutes);
app.register(liveRoutes);
app.register(meRoutes);
app.register(mobileRoutes);
app.register(contactRoutes);
app.register(soundRoutes);
app.register(ringGroupRoutes);
+74
View File
@@ -0,0 +1,74 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Paired phones (13 §2): a one-time pairing code from the web, traded by the app for a device token
// `Authorization: Bearer spm_<40 base62>`, stored as sha256, acting as the phone's user.
import { randomInt } from 'node:crypto';
import { and, eq, gt, isNull } from 'drizzle-orm';
import type { Db } from '../db/client.ts';
import { mobileDevices, users } from '../db/schema.ts';
import { generateToken, hashToken } from './tokens.ts';
export const DEVICE_TOKEN_RE = /^spm_[A-Za-z0-9]{40}$/;
/** No 0/O/1/I: people type it from a screen. */
export const PAIR_CODE_CHARS = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
export const PAIR_CODE_TTL_MS = 10 * 60_000;
export const generateDeviceToken = () => `spm_${generateToken().slice(3)}`;
export function generatePairCode(): string {
let s = '';
for (let i = 0; i < 12; i++) s += PAIR_CODE_CHARS[randomInt(PAIR_CODE_CHARS.length)];
return s;
}
/** The device's (active) user, or null for an unknown, removed or malformed token. */
export async function getDeviceUser(db: Db, token: string) {
if (!DEVICE_TOKEN_RE.test(token)) return null;
const [row] = await db
.select({ id: mobileDevices.id, user: users })
.from(mobileDevices)
.innerJoin(users, eq(users.id, mobileDevices.userId))
.where(and(eq(mobileDevices.tokenHash, hashToken(token)), eq(users.isActive, true)));
if (!row) return null;
await db
.update(mobileDevices)
.set({ lastUsedAt: new Date() })
.where(eq(mobileDevices.id, row.id));
return { user: row.user, deviceId: row.id };
}
/**
* Trades a pairing code for a device token, once. Null for a code that's unknown, used, expired,
* or whose user was disabled since.
*/
export async function pairDevice(
db: Db,
code: string,
device: { name: string; platform: 'android' | 'ios' },
) {
const [row] = await db
.select({ id: mobileDevices.id, user: users })
.from(mobileDevices)
.innerJoin(users, eq(users.id, mobileDevices.userId))
.where(
and(
eq(mobileDevices.pairCodeHash, hashToken(code)),
gt(mobileDevices.pairExpiresAt, new Date()),
eq(users.isActive, true),
),
);
if (!row) return null;
const token = generateDeviceToken();
// The code is cleared in the same statement: two apps racing with one code can't both win.
const [won] = await db
.update(mobileDevices)
.set({
...device,
tokenPrefix: token.slice(0, 8),
tokenHash: hashToken(token),
pairCodeHash: null,
pairExpiresAt: null,
})
.where(and(eq(mobileDevices.id, row.id), isNull(mobileDevices.tokenHash)))
.returning({ id: mobileDevices.id });
return won ? { token, deviceId: won.id, user: row.user } : null;
}
+14 -7
View File
@@ -5,6 +5,7 @@ import type { FastifyReply, FastifyRequest } from 'fastify';
import type { users } from '../db/schema.ts';
import { forbidden, unauthorized } from '../errors.ts';
import { COOKIE, getSessionUser, mustChange, setSessionCookie } from './sessions.ts';
import { getDeviceUser } from './devices.ts';
import { getTokenUser } from './tokens.ts';
export type CurrentUser = typeof users.$inferSelect;
@@ -15,22 +16,28 @@ declare module 'fastify' {
sessionId: string | null;
/** Set when the request came with an API token instead of a session. */
apiTokenId?: string;
/** Set when the request came from a paired phone (13 §2). */
mobileDeviceId?: string;
}
}
/**
* Sets req.user from an API token (Authorization: Bearer, 04 §2.3) or else the session cookie.
* Enforces must_change_password outside /api/auth/*.
* Sets req.user from a device token (`spm_`, 13 §2) or API token (Authorization: Bearer, 04 §2.3),
* or else the session cookie. Enforces must_change_password outside /api/auth/*.
*/
export async function requireAuth(req: FastifyRequest, reply: FastifyReply): Promise<void> {
const auth = req.headers.authorization;
if (auth?.startsWith('Bearer ')) {
const t = await getTokenUser(req.server.db, auth.slice(7).trim());
if (!t) throw unauthorized();
req.user = t.user;
const raw = auth.slice(7).trim();
const d = raw.startsWith('spm_') ? await getDeviceUser(req.server.db, raw) : null;
const t = d ? null : await getTokenUser(req.server.db, raw);
if (!d && !t) throw unauthorized();
// A phone is never an admin (13 §2): a lost one mustn't open the admin panel or recordings.
req.user = d ? { ...d.user, role: 'user' } : t!.user;
req.sessionId = null;
req.apiTokenId = t.tokenId;
if (t.user.mustChangePassword)
if (d) req.mobileDeviceId = d.deviceId;
else req.apiTokenId = t!.tokenId;
if (req.user.mustChangePassword)
throw forbidden('Change your password first', 'password_change_required');
return;
}
+6 -2
View File
@@ -5,7 +5,7 @@ import { and, eq, gt, ne } from 'drizzle-orm';
import type { FastifyReply } from 'fastify';
import type { Db } from '../db/client.ts';
import { encrypt } from '../crypto/secrets.ts';
import { sessions, users } from '../db/schema.ts';
import { mobileDevices, sessions, users } from '../db/schema.ts';
export const COOKIE = 'sp_session';
const DAY = 86_400_000;
@@ -83,8 +83,12 @@ export async function deleteSession(db: Db, token: string) {
await db.delete(sessions).where(eq(sessions.id, sha256(token)));
}
/** Logs a user out everywhere except (optionally) the current session. */
/**
* Logs a user out everywhere except (optionally) the current session, paired phones included (13 §2:
* a password change, a reset, or disabling them).
*/
export async function deleteUserSessions(db: Db, userId: string, keepSessionId?: string) {
await db.delete(mobileDevices).where(eq(mobileDevices.userId, userId));
await db
.delete(sessions)
.where(
+8 -4
View File
@@ -18,6 +18,7 @@ import {
import { todayIn } from '../generator/load.ts';
import { getSetting } from '../services/settings.ts';
import { ContrackError, contrackBook, contrackInteraction, contrackSettings } from './contrack.ts';
import { ON_THE_CALL } from '../services/cdr.ts';
/** A recorded call waits this long for its summary before it's logged without one. */
const SUMMARY_WAIT_MS = 30 * 60_000;
@@ -141,11 +142,13 @@ export async function logCallsToContrack(d: {
.limit(500);
// One entry per call and person (a person can be in a call as caller, rang and answered).
const todo = new Map<string, (typeof rows)[number] & { out: boolean }>();
// `heard`: made or answered it; a phone that only rang gets no recording or summary (08 §7 #3).
const todo = new Map<string, (typeof rows)[number] & { out: boolean; heard: boolean }>();
for (const r of rows) {
const k = `${r.call.id}|${r.userId}`;
const out = r.role === 'caller' || todo.get(k)?.out === true;
todo.set(k, { ...r, out });
const heard = ON_THE_CALL.includes(r.role) || todo.get(k)?.heard === true;
todo.set(k, { ...r, out, heard });
}
const skipUser = new Set<string>(); // Contrack down or token refused: try again next run
let logged = 0;
@@ -169,7 +172,8 @@ export async function logCallsToContrack(d: {
await record(null);
continue;
}
const wantSummary = r.withSummary && summariesOn && r.call.recordingId !== null;
const recorded = r.heard && r.call.recordingId !== null;
const wantSummary = r.withSummary && summariesOn && recorded;
if (wantSummary && !r.aiDoneAt && now.getTime() - r.call.endedAt.getTime() < SUMMARY_WAIT_MS)
continue; // the transcript job hasn't summarized it yet
const e = timelineEntry(
@@ -180,7 +184,7 @@ export async function logCallsToContrack(d: {
toNumber: r.call.toNumber,
talkSeconds: r.call.talkSeconds,
answeredBy: r.answeredById && r.answeredById !== r.extId ? r.answeredBy : null,
recorded: r.call.recordingId !== null,
recorded,
},
wantSummary ? r.summary : null,
);
@@ -0,0 +1,19 @@
CREATE TABLE "mobile_devices" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"name" text,
"platform" text,
"token_prefix" text,
"token_hash" text,
"pair_code_hash" text,
"pair_expires_at" timestamp with time zone,
"last_used_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "mobile_devices_tokenHash_unique" UNIQUE("token_hash"),
CONSTRAINT "mobile_devices_pairCodeHash_unique" UNIQUE("pair_code_hash"),
CONSTRAINT "mobile_devices_platform_check" CHECK ("mobile_devices"."platform" in ('android', 'ios'))
);
--> statement-breakpoint
ALTER TABLE "mobile_devices" ADD CONSTRAINT "mobile_devices_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "mobile_devices_user_id_index" ON "mobile_devices" USING btree ("user_id");
@@ -0,0 +1,25 @@
CREATE TABLE "ftc_complaints" (
"id" text PRIMARY KEY NOT NULL,
"number" text NOT NULL,
"reported_at" timestamp with time zone NOT NULL,
"subject" text,
"robocall" boolean,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "spam_lookups" (
"number" text PRIMARY KEY NOT NULL,
"provider" text NOT NULL,
"risk" text NOT NULL,
"info" jsonb NOT NULL,
"expires_at" timestamp with time zone NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "spam_lookups_risk_check" CHECK ("spam_lookups"."risk" in ('low', 'medium', 'high'))
);
--> statement-breakpoint
ALTER TABLE "calls" ADD COLUMN "spam_risk" text;--> statement-breakpoint
ALTER TABLE "calls" ADD COLUMN "spam_info" jsonb;--> statement-breakpoint
CREATE INDEX "ftc_complaints_number_index" ON "ftc_complaints" USING btree ("number");--> statement-breakpoint
CREATE INDEX "ftc_complaints_reported_at_index" ON "ftc_complaints" USING btree ("reported_at");--> statement-breakpoint
CREATE INDEX "spam_lookups_expires_at_index" ON "spam_lookups" USING btree ("expires_at");
@@ -0,0 +1,11 @@
CREATE TABLE "spam_reports" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"number" text NOT NULL,
"reason" text NOT NULL,
"reported_by" uuid,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "spam_reports_number_reportedBy_unique" UNIQUE("number","reported_by")
);
--> statement-breakpoint
ALTER TABLE "spam_reports" ADD CONSTRAINT "spam_reports_reported_by_users_id_fk" FOREIGN KEY ("reported_by") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -92,6 +92,27 @@
"when": 1792951905226,
"tag": "0012_sso",
"breakpoints": true
},
{
"idx": 13,
"version": "7",
"when": 1793052019332,
"tag": "0013_mobile_devices",
"breakpoints": true
},
{
"idx": 14,
"version": "7",
"when": 1793127210386,
"tag": "0014_spam_check",
"breakpoints": true
},
{
"idx": 15,
"version": "7",
"when": 1793212840276,
"tag": "0015_spam_reports",
"breakpoints": true
}
]
}
+71
View File
@@ -134,6 +134,29 @@ export const apiTokens = pgTable('api_tokens', {
...timestamps,
});
/** Paired phones (13 §3): a row waits with a pairing code, then holds the device token. */
export const mobileDevices = pgTable(
'mobile_devices',
{
id: id(),
userId: uuid()
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
name: text(),
platform: text(),
tokenPrefix: text(),
tokenHash: text().unique(),
pairCodeHash: text().unique(),
pairExpiresAt: tstz(),
lastUsedAt: tstz(),
...timestamps,
},
(t) => [
index().on(t.userId),
check('mobile_devices_platform_check', sql`${t.platform} in ('android', 'ios')`),
],
);
// ───────────── extensions ─────────────
export const outboundClass = pgEnum('outbound_class', [
'internal',
@@ -607,11 +630,59 @@ export const calls = pgTable(
answeredByExtensionId: uuid().references(() => extensions.id, { onDelete: 'set null' }),
talkSeconds: integer().notNull().default(0),
recordingId: uuid().references(() => recordings.id, { onDelete: 'set null' }),
/** The spam check of an incoming call's number (14 §6); null = not checked. */
spamRisk: text(),
spamInfo: jsonb(),
...timestamps,
},
(t) => [index().on(t.startedAt.desc())],
);
// ───────────── spam check (14 §7) ─────────────
/** IPQualityScore answers, so a caller who rings again costs no credit (14 §4). */
export const spamLookups = pgTable(
'spam_lookups',
{
number: text().primaryKey(),
provider: text().notNull(),
risk: text().notNull(),
info: jsonb().notNull(),
expiresAt: tstz().notNull(),
...timestamps,
},
(t) => [
index().on(t.expiresAt),
check('spam_lookups_risk_check', sql`${t.risk} in ('low', 'medium', 'high')`),
],
);
/** The local copy of the FTC's Do Not Call complaints (14 §2.2). */
export const ftcComplaints = pgTable(
'ftc_complaints',
{
id: text().primaryKey(),
number: text().notNull(),
reportedAt: tstz().notNull(),
subject: text(),
robocall: boolean(),
createdAt: createdAt(),
},
(t) => [index().on(t.number), index().on(t.reportedAt)],
);
/** "Report spam" from a user's call history, waiting for an admin (05 §2 Blocked numbers). */
export const spamReports = pgTable(
'spam_reports',
{
id: id(),
number: text().notNull(),
reason: text().notNull(),
reportedBy: uuid().references(() => users.id, { onDelete: 'set null' }),
...timestamps,
},
(t) => [unique().on(t.number, t.reportedBy)],
);
export const callExtensions = pgTable(
'call_extensions',
{
@@ -140,7 +140,7 @@ endpoint = trunk-a1b2c3d4-0000-4000-8000-000000000004
[trunk-a1b2c3d4-0000-4000-8000-000000000004]
type = aor
contact = sip:chicago1.voip.ms:5061;transport=tls
qualify_frequency = 0
qualify_frequency = 60
[trunk-a1b2c3d4-0000-4000-8000-000000000004]
type = endpoint
+3 -5
View File
@@ -232,11 +232,9 @@ function trunkSections(t: SnapTrunk, snap: Snapshot): string {
section(`trunk-${id}`, [
'type = aor',
`contact = sip:${target}`,
// ponytail: Asterisk 24 checks a qualify's TLS certificate against the IP, not the name, so
// OPTIONS to voip.ms fail verify_server and the contact goes Unavailable (calls and
// REGISTER verify fine). No qualify over TLS: registration shows the trunk's health.
// Turn it back on when a qualify to chicago1.voip.ms:5061 passes.
`qualify_frequency = ${t.transport === 'tls' ? 0 : 60}`,
// Over TLS too since 24.0.0-rc3, which checks a qualify's certificate against the host name
// (rc2 used the IP and failed verify_server; upgrade-test.ts tlsQualifyByHostname).
'qualify_frequency = 60',
]),
section(`trunk-${id}`, [
'type = endpoint',
+55 -2
View File
@@ -2,8 +2,8 @@
// voip.ms REST/JSON API (06 §5.1): GET rest.php?api_username&api_password&method&…, JSON back with
// `status` = "success" or an error code. The password is in the query string, so the URL is never
// logged or put in an error.
// TODO(spec): response shapes and error codes are from the 06 spec, not a real account yet
// (voip.ms's API docs need a login); check them with the owner's account and fix here.
// TODO(spec): most response shapes and error codes are from the 06 spec, not a real account yet;
// voip.ms's example bundle (docs/voip.ms-api) only shows getCDR's. Check with the owner's account.
import { normalizeNumber } from '@selfphoneng/shared';
export type VoipmsCreds = { username: string; password: string };
@@ -106,6 +106,59 @@ export async function getDids(baseUrl: string, creds: VoipmsCreds): Promise<Voip
});
}
export type VoipmsCall = {
at: Date;
destination: string;
account: string;
disposition: string;
seconds: number;
total: string;
uniqueid: string;
};
/**
* One sub-account's call records between two UTC dates (getCDR, 06 §5.1). `timezone=0` makes
* `date` UTC. The request, `no_cdr` and most fields are in voip.ms's examples; `account`,
* `disposition` and `uniqueid` aren't (verify), and come back '' if missing.
*/
export async function getCdr(
baseUrl: string,
creds: VoipmsCreds,
q: { from: string; to: string; account: string },
): Promise<VoipmsCall[]> {
let r: Record<string, unknown>;
try {
r = await voipmsApi(baseUrl, creds, 'getCDR', {
date_from: q.from,
date_to: q.to,
answered: '1',
noanswer: '1',
busy: '1',
failed: '1',
timezone: '0',
account: q.account,
});
} catch (e) {
if (e instanceof VoipmsError && e.status === 'no_cdr') return [];
throw e;
}
return list(r.cdr).flatMap((c) => {
const at = new Date(`${str(c.date).replace(' ', 'T')}Z`);
if (Number.isNaN(at.getTime())) return [];
return [
{
at,
destination: str(c.destination),
account: str(c.account),
disposition: str(c.disposition),
seconds: Number(str(c.seconds)) || 0,
total: str(c.total),
uniqueid: str(c.uniqueid),
},
];
});
}
/**
* The account's balance (getBalance → balance.current_balance, in the account's currency).
* (verify) against a real account: the shape is from voip.ms's API docs (06 §5.1).
+33 -3
View File
@@ -10,6 +10,7 @@ import type { Db } from './db/client.ts';
import {
extensions,
sessions,
spamLookups,
systemEvents,
trunks,
users,
@@ -30,6 +31,9 @@ import { logCallsToContrack } from './contacts/timeline.ts';
import { checkDirectory } from './sso/directory.ts';
import { checkOidcSessions } from './sso/oidcCheck.ts';
import { logEvent } from './services/events.ts';
import { createFraudWatch } from './services/fraud.ts';
import { spamQuotaCheck } from './spam/check.ts';
import { ftcSync } from './spam/ftc.ts';
import { deliverDue } from './services/webhooks.ts';
import type { SendMail } from './services/mail.ts';
import { getSetting, setSetting } from './services/settings.ts';
@@ -42,7 +46,11 @@ export type JobDeps = {
generator: Pick<Generator, 'apply'>;
voicemail: { scan(ext: string): Promise<void> };
send: SendMail;
log: { info(o: object, m: string): void; error(o: object, m: string): void };
log: {
info(o: object, m: string): void;
warn(o: object, m: string): void;
error(o: object, m: string): void;
};
now?: () => Date;
statfs?: typeof statfs;
};
@@ -70,7 +78,8 @@ export async function mailAdmins(d: JobDeps, subject: string, text: string) {
await d.send({ to, subject, text }).catch((err: unknown) => d.log.error({ err }, subject));
}
/** Recordings, voicemail, expired sessions and old webhook deliveries (04 §8 retentionCleanup). */
/** Recordings, voicemail, expired sessions, old webhook deliveries and spam check answers
* (04 §8 retentionCleanup, 14 §4). */
export async function retentionCleanup(d: JobDeps) {
const recordings = await cleanupRecordings(
d.db,
@@ -113,6 +122,7 @@ export async function retentionCleanup(d: JobDeps) {
await d.db
.delete(systemEvents)
.where(lt(systemEvents.createdAt, sql`now() - interval '90 days'`));
await d.db.delete(spamLookups).where(lt(spamLookups.expiresAt, sql`now()`));
await cleanupContactShares(d.db);
if (recordings || voicemail) d.log.info({ recordings, voicemail }, 'old messages deleted');
}
@@ -121,6 +131,13 @@ export async function retentionCleanup(d: JobDeps) {
export function createJobs(d: JobDeps) {
const now = d.now ?? (() => new Date());
const tz = () => getSetting(d.db, 'general.timezone');
const fraud = createFraudWatch({
db: d.db,
voipmsUrl: d.config.VOIPMS_API_URL,
mail: (subject, text) => mailAdmins(d, subject, text),
log: d.log,
now,
});
// Holidays roll over at midnight (03 §6.10): render again when the local date changes.
// Retention runs then too (and at start), not at 02:00.
@@ -186,7 +203,8 @@ export function createJobs(d: JobDeps) {
`Public IP changed to ${ip}`,
`The phone system's public IP changed from ${old ?? 'none'} to ${ip}.\n` +
'Restart telephony so calls use the new address: Admin → System says how (it drops calls in progress).\n' +
'Phones and the provider may need the new IP if they were set up with the old one.',
'Phones and the provider may need the new IP if they were set up with the old one ' +
'(voip.ms: the sub-account’s IP restriction or IP authentication, and the API whitelist).',
);
};
@@ -274,6 +292,12 @@ export function createJobs(d: JobDeps) {
};
return {
spamQuotaCheck: () => spamQuotaCheck(d.db),
/** 14 §2.2: a failure is logged when it changes, and shown on Settings → Spam check. */
spamFtcSync: async () => {
const reason = await ftcSync(d.db, { now: now() });
if (reason) d.log.warn({ provider: 'ftc', reason }, 'FTC complaint download failed');
},
oidcCheck,
directoryCheck,
daily,
@@ -281,6 +305,8 @@ export function createJobs(d: JobDeps) {
publicIpWatch,
trunkWatch,
diskWatch,
riskyCallWatch: fraud.riskyCallWatch,
providerWatch: fraud.providerWatch,
callReports,
contactsWarm,
contrackLog,
@@ -306,11 +332,15 @@ export function startJobs(d: JobDeps): () => void {
run('trunkWatch', j.trunkWatch);
if (minute % 5 === 0) run('publicIpWatch', j.publicIpWatch);
if (minute % 60 === 0) run('diskWatch', j.diskWatch);
run('riskyCallWatch', j.riskyCallWatch);
if (minute % 15 === 0) run('providerWatch', j.providerWatch);
run('callReports', j.callReports);
if (minute % 5 === 0) run('contactsWarm', j.contactsWarm);
run('contrackLog', j.contrackLog);
if (minute % 5 === 0) run('oidcCheck', j.oidcCheck);
if (minute % 60 === 0) run('directoryCheck', j.directoryCheck);
if (minute % 360 === 0) run('spamQuotaCheck', j.spamQuotaCheck);
run('spamFtcSync', j.spamFtcSync);
run('callTranscripts', async () => {
await transcribeCalls({ db: d.db, dir: resolve(d.config.RECORDINGS_DIR), log: d.log });
});
+3 -5
View File
@@ -213,12 +213,10 @@ export class LiveState extends EventEmitter<{ message: [LiveMessage] }> {
/** POST /api/trunks/:id/test: qualify now, then read registration and reachability. */
async testTrunk(
id: string,
{ waitMs = 1500, qualify = true } = {},
{ waitMs = 1500 } = {},
): Promise<{ live: TrunkLive; rttMs: number | null }> {
if (qualify) {
await this.ami.action({ Action: 'PJSIPQualify', Endpoint: `trunk-${id}` }).catch(() => {});
await new Promise((r) => setTimeout(r, waitMs)); // the OPTIONS answer arrives asynchronously
}
await this.ami.action({ Action: 'PJSIPQualify', Endpoint: `trunk-${id}` }).catch(() => {});
await new Promise((r) => setTimeout(r, waitMs)); // the OPTIONS answer arrives asynchronously
await this.refreshRegistrations();
const rttMs = await this.refreshReachable(id);
const live = this.trunks.get(id);
+28 -2
View File
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// /api/blocked-numbers (04 §7.3): PostgreSQL, then AstDB SP/BLOCK/<number>, which every trunk
// entry checks (03 §6.4). Admin only.
// entry checks (03 §6.4). /api/spam-reports: users' Report spam, to block or dismiss. Admin only.
import { desc, eq } from 'drizzle-orm';
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
@@ -9,9 +9,10 @@ import {
formatNumber,
type BlockedDto,
type ListResponse,
type SpamReportDto,
} from '@selfphoneng/shared';
import { requireAdmin } from '../auth/guards.ts';
import { blockedNumbers } from '../db/schema.ts';
import { blockedNumbers, spamReports, users } from '../db/schema.ts';
import { conflict, notFound } from '../errors.ts';
import { setBlock } from '../services/astdb.ts';
import { audit } from '../services/audit.ts';
@@ -52,6 +53,7 @@ export async function blockedRoutes(app: FastifyInstance) {
throw e;
}
await mirror(b.number, true);
await db.delete(spamReports).where(eq(spamReports.number, b.number));
await audit(db, {
actorUserId: req.user!.id,
actorIp: req.ip,
@@ -78,4 +80,28 @@ export async function blockedRoutes(app: FastifyInstance) {
});
return { ok: true };
});
app.get('/api/spam-reports', async (): Promise<ListResponse<SpamReportDto>> => ({
items: (
await db
.select({ r: spamReports, name: users.displayName })
.from(spamReports)
.leftJoin(users, eq(users.id, spamReports.reportedBy))
.orderBy(desc(spamReports.createdAt))
).map(({ r, name }) => ({
id: r.id,
number: r.number,
reason: r.reason,
reportedBy: name,
createdAt: r.createdAt.toISOString(),
})),
nextCursor: null,
}));
app.delete('/api/spam-reports/:id', async (req) => {
const { id } = IdParams.parse(req.params);
const [r] = await db.delete(spamReports).where(eq(spamReports.id, id)).returning();
if (!r) throw notFound('Already handled');
return { ok: true };
});
}
+74 -8
View File
@@ -1,20 +1,35 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// /api/calls (admin) and /api/me/calls (04 §7.3): call history, newest first, keyset paging.
import { and, asc, desc, eq, ilike, inArray, or, sql, type SQL } from 'drizzle-orm';
// /api/me/spam-reports: Report spam on a call that rang me (05 §4).
import { and, asc, desc, eq, ilike, inArray, ne, or, sql, type SQL } from 'drizzle-orm';
import type { FastifyInstance } from 'fastify';
import { alias } from 'drizzle-orm/pg-core';
import { z } from 'zod';
import {
CallFilterSchema,
SpamReportCreateSchema,
formatNumber,
normalizeNumber,
type CallDetailDto,
type CallDto,
type ListResponse,
type MyCallDto,
type SpamCheck,
} from '@selfphoneng/shared';
import { requireAdmin, requireAuth } from '../auth/guards.ts';
import type { Db } from '../db/client.ts';
import { callExtensions, calls, cdr, dids, extensions } from '../db/schema.ts';
import { badRequest, notFound } from '../errors.ts';
import {
blockedNumbers,
callExtensions,
calls,
cdr,
dids,
extensions,
spamReports,
} from '../db/schema.ts';
import { contactNames } from '../contacts/book.ts';
import { badRequest, conflict, notFound } from '../errors.ts';
import { ON_THE_CALL } from '../services/cdr.ts';
import { getSetting } from '../services/settings.ts';
const answerer = alias(extensions, 'answerer');
@@ -65,7 +80,8 @@ async function conditions(db: Db, f: Filter): Promise<SQL[]> {
return where;
}
async function page(db: Db, where: SQL[], limit: number) {
/** `viewer`: whose books hide the spam check for a number they name (14 §6). */
async function page(db: Db, where: SQL[], limit: number, viewer: string) {
const rows = await db
.select({ c: calls, did: dids, a: answerer })
.from(calls)
@@ -76,6 +92,12 @@ async function page(db: Db, where: SQL[], limit: number) {
.limit(limit + 1);
const more = rows.length > limit;
const items = rows.slice(0, limit);
const known = await contactNames(
db,
viewer,
items.filter(({ c }) => c.spamRisk).map(({ c }) => c.fromNumber),
{ wait: false },
).catch(() => null);
return {
items: items.map(({ c, did, a }): CallDto => ({
id: c.id,
@@ -91,6 +113,10 @@ async function page(db: Db, where: SQL[], limit: number) {
answeredBy: a ? { id: a.id, number: a.number, name: a.name } : null,
talkSeconds: c.talkSeconds,
recordingId: c.recordingId,
spam:
c.spamRisk && known && !known.has(c.fromNumber ?? '')
? ({ risk: c.spamRisk, ...(c.spamInfo as object) } as SpamCheck)
: null,
})),
nextCursor: more ? encodeCursor(items.at(-1)!.c) : null,
};
@@ -111,7 +137,7 @@ export async function callRoutes(app: FastifyInstance) {
{ preHandler: requireAdmin },
async (req): Promise<ListResponse<CallDto>> => {
const f = CallFilterSchema.parse(req.query);
return page(db, await conditions(db, f), f.limit);
return page(db, await conditions(db, f), f.limit, req.user!.id);
},
);
@@ -123,7 +149,7 @@ export async function callRoutes(app: FastifyInstance) {
'started,direction,status,from,from_name,to,phone_number,answered_by,talk_seconds',
];
// ponytail: one query capped at 50,000 rows; stream with a cursor if exports get bigger.
const { items } = await page(db, where, 50_000);
const { items } = await page(db, where, 50_000, req.user!.id);
const fmt = new Intl.DateTimeFormat('sv-SE', {
timeZone: tz,
dateStyle: 'short',
@@ -156,7 +182,7 @@ export async function callRoutes(app: FastifyInstance) {
app.get('/api/calls/:id', { preHandler: requireAdmin }, async (req): Promise<CallDetailDto> => {
const { id } = z.object({ id: z.uuid() }).parse(req.params);
const { items } = await page(db, [eq(calls.id, id)], 1);
const { items } = await page(db, [eq(calls.id, id)], 1, req.user!.id);
const [c] = await db.select({ linkedid: calls.linkedid }).from(calls).where(eq(calls.id, id));
if (!items[0] || !c) throw notFound('Call not found');
const legs = await db
@@ -189,7 +215,7 @@ export async function callRoutes(app: FastifyInstance) {
.where(eq(extensions.userId, req.user!.id));
if (!ext) throw notFound("You don't have an extension yet. Ask your admin.");
const where = await conditions(db, { ...f, extensionId: ext.id });
const result = await page(db, where, f.limit);
const result = await page(db, where, f.limit, req.user!.id);
const ids = result.items.map((c) => c.id);
const roles = ids.length
? await db
@@ -203,6 +229,8 @@ export async function callRoutes(app: FastifyInstance) {
const out = mine.includes('caller');
return {
...c,
// Only rang (a ring group): someone else's conversation (08 §7 #3).
recordingId: mine.some((r) => ON_THE_CALL.includes(r)) ? c.recordingId : null,
myRole: out ? 'out' : 'in',
missedByMe: !out && (MISSED as readonly string[]).includes(c.status),
};
@@ -211,4 +239,42 @@ export async function callRoutes(app: FastifyInstance) {
};
},
);
/** The admins see it on Blocked numbers and block or dismiss it. */
app.post('/api/me/spam-reports', { preHandler: requireAuth }, async (req, reply) => {
const body = SpamReportCreateSchema.parse(req.body);
const [c] = await db
.select({ number: calls.fromNumber })
.from(calls)
.innerJoin(callExtensions, eq(callExtensions.callId, calls.id))
.innerJoin(extensions, eq(extensions.id, callExtensions.extensionId))
.where(
and(
eq(calls.id, body.callId),
eq(calls.direction, 'inbound'),
eq(extensions.userId, req.user!.id),
ne(callExtensions.role, 'caller'),
),
)
.limit(1);
if (!c) throw notFound('Not a call from outside that rang you');
let number: string;
try {
number = normalizeNumber(c.number ?? '');
} catch {
throw badRequest("This caller's number was hidden, so it can't be blocked");
}
const [blocked] = await db
.select({ id: blockedNumbers.id })
.from(blockedNumbers)
.where(eq(blockedNumbers.number, number));
if (blocked) throw conflict(`${formatNumber(number)} is already blocked`);
const [r] = await db
.insert(spamReports)
.values({ number, reason: body.reason, reportedBy: req.user!.id })
.onConflictDoNothing()
.returning();
if (!r) throw conflict(`You already reported ${formatNumber(number)}`);
return reply.code(201).send({ ok: true });
});
}
+49 -5
View File
@@ -15,6 +15,7 @@ import {
type ContactImportResult,
type ContactShareDto,
type ListResponse,
type SpamCheck,
} from '@selfphoneng/shared';
import { NO_LIMIT, SLOW, requireAdmin, requireAuth } from '../auth/guards.ts';
import {
@@ -37,6 +38,7 @@ import { getSetting } from '../services/settings.ts';
import { decrypt } from '../crypto/secrets.ts';
import { contrackCheck } from '../contacts/contrack.ts';
import { callerNameToken, phoneSafeName, sameToken } from '../contacts/callername.ts';
import { checkCaller, usNumber } from '../spam/check.ts';
const IdParams = z.object({ id: z.uuid() });
const CommitQuery = z.object({ commit: z.enum(['0', '1']).default('0') });
@@ -187,12 +189,52 @@ export async function contactRoutes(app: FastifyInstance) {
.select({ userId: extensions.userId })
.from(extensions)
.where(eq(extensions.number, q.data.ext));
const names = await contactNames(db, e?.userId ?? null, [q.data.number], {
const owner = e?.userId ?? null;
const names = await contactNames(db, owner, [q.data.number], {
wait: false,
}).catch(() => new Map<string, string>());
return reply
.type('text/plain; charset=utf-8')
.send(phoneSafeName(names.get(q.data.number) ?? ''));
let name = names.get(q.data.number) ?? '';
// 14 §5.2: no contact → the spam check's label, if it answers in 1.2 s (of the 2 s).
// app.log: this route's own log is silent (the token is in the query).
// ponytail: the label replaces the carrier's caller ID name, which the dialplan doesn't
// send; IPQualityScore's name for the line follows it when it has one.
if (!name) {
const c = await checkCaller({ db, log: app.log }, owner, usNumber(q.data.number), 1200);
if (c && c.risk !== 'low') {
const labels = (await getSetting(db, 'spam')).deskPhoneLabels;
name = `${labels[c.risk]} ${c.provider === 'ipqs' ? (c.info.name ?? '') : ''}`;
}
}
return reply.type('text/plain; charset=utf-8').send(phoneSafeName(name));
},
);
/**
* The softphone's spam check (14 §5.1): only for a caller ringing one of my extensions right
* now, so nobody can spend the credits on numbers that never called.
*/
app.get(
'/api/me/caller-check',
{ preHandler: requireAuth },
async (req): Promise<{ check: SpamCheck | null }> => {
const { number } = z.object({ number: z.string().max(30) }).parse(req.query);
const n = usNumber(number);
const mine = (
await db
.select({ number: extensions.number })
.from(extensions)
.where(eq(extensions.userId, req.user!.id))
).map((x) => x.number);
const ringing = app.live.calls
.list()
.some(
(c) =>
c.direction === 'inbound' &&
usNumber(c.from.number) === n &&
c.extensions.some((x) => mine.includes(x)),
);
if (!n || !ringing) return { check: null };
return { check: await checkCaller({ db, log: req.log }, req.user!.id, n, 5000) };
},
);
@@ -228,8 +270,10 @@ export async function contactRoutes(app: FastifyInstance) {
.object({ url: z.string().trim().min(1), companyToken: z.string().trim().optional() })
.parse(req.body);
const saved = await getSetting(db, 'contacts.contrack');
// The saved token only for the saved address: another one gets none (08 §7 #7).
const same = saved?.url === b.url.replace(/\/+$/, '');
const token =
b.companyToken || (saved?.companyTokenEnc ? decrypt(saved.companyTokenEnc) : null);
b.companyToken || (same && saved.companyTokenEnc ? decrypt(saved.companyTokenEnc) : null);
try {
return { ok: true, detail: await contrackCheck(b.url, token) };
} catch (err) {
+128
View File
@@ -0,0 +1,128 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Pairing the mobile app (13 §2, §4): a one-time code from My settings, traded by the app for a
// device token; the person's paired phones, and removing one.
import { and, eq, isNotNull, isNull } from 'drizzle-orm';
import type { FastifyInstance, FastifyRequest } from 'fastify';
import QRCode from 'qrcode';
import { z } from 'zod';
import {
MobilePairSchema,
type ListResponse,
type MobileDeviceDto,
type MobilePairCode,
type MobilePairResult,
} from '@selfphoneng/shared';
import { PAIR_CODE_TTL_MS, generatePairCode, pairDevice } from '../auth/devices.ts';
import { SLOW, requireAuth } from '../auth/guards.ts';
import { hashToken } from '../auth/tokens.ts';
import { mobileDevices } from '../db/schema.ts';
import { forbidden, HttpError, notFound } from '../errors.ts';
import { audit } from '../services/audit.ts';
const IdParams = z.object({ id: z.uuid() });
export async function mobileRoutes(app: FastifyInstance) {
const db = app.db;
const act = (req: FastifyRequest) => ({ actorUserId: req.user!.id, actorIp: req.ip });
app.post(
'/api/me/mobile-devices/pair',
{ preHandler: requireAuth, config: SLOW },
async (req): Promise<MobilePairCode> => {
// Only a person at the web page: no token (a stolen phone can't make itself a spare).
if (!req.sessionId) throw forbidden('Pair a phone from the web page');
const userId = req.user!.id;
await db
.delete(mobileDevices)
.where(and(eq(mobileDevices.userId, userId), isNull(mobileDevices.tokenHash)));
const code = generatePairCode();
const expiresAt = new Date(Date.now() + PAIR_CODE_TTL_MS);
const [d] = await db
.insert(mobileDevices)
.values({ userId, pairCodeHash: hashToken(code), pairExpiresAt: expiresAt })
.returning({ id: mobileDevices.id });
await audit(db, {
...act(req),
action: 'create',
entityType: 'mobile_device',
entityId: d!.id,
summary: 'Made a code to pair a phone',
});
const server = `https://${app.config.PUBLIC_HOSTNAME}`;
const link = `selfphoneng://pair?${new URLSearchParams({ server, code })}`;
return {
code,
server,
link,
qr: await QRCode.toDataURL(link, { margin: 2, width: 280 }),
expiresAt: expiresAt.toISOString(),
};
},
);
/** Public: the code is the credential. The only time the device token is shown. */
app.post('/api/mobile/pair', { config: SLOW }, async (req): Promise<MobilePairResult> => {
const { code, ...device } = MobilePairSchema.parse(req.body);
const paired = await pairDevice(db, code, device);
if (!paired)
throw new HttpError(
404,
'pair_code_invalid',
'That code is wrong or expired. Make a new one.',
);
await audit(db, {
actorUserId: paired.user.id,
actorIp: req.ip,
action: 'create',
entityType: 'mobile_device',
entityId: paired.deviceId,
summary: `Paired ${device.platform} phone ${device.name}`,
});
return {
token: paired.token,
user: { name: paired.user.displayName, email: paired.user.email },
};
});
app.get(
'/api/me/mobile-devices',
{ preHandler: requireAuth },
async (req): Promise<ListResponse<MobileDeviceDto>> => {
const rows = await db
.select()
.from(mobileDevices)
.where(and(eq(mobileDevices.userId, req.user!.id), isNotNull(mobileDevices.tokenHash)))
.orderBy(mobileDevices.createdAt);
return {
items: rows.map((d) => ({
id: d.id,
name: d.name!,
platform: d.platform as MobileDeviceDto['platform'],
tokenPrefix: d.tokenPrefix!,
createdAt: d.createdAt.toISOString(),
lastUsedAt: d.lastUsedAt?.toISOString() ?? null,
current: d.id === req.mobileDeviceId,
})),
nextCursor: null,
};
},
);
/** Removes one of my phones; the app's Sign out removes itself. */
app.delete('/api/me/mobile-devices/:id', { preHandler: requireAuth }, async (req) => {
const { id } = IdParams.parse(req.params);
const [d] = await db
.delete(mobileDevices)
.where(and(eq(mobileDevices.id, id), eq(mobileDevices.userId, req.user!.id)))
.returning();
if (!d) throw notFound('Phone not found');
await audit(db, {
...act(req),
action: 'delete',
entityType: 'mobile_device',
entityId: id,
summary: `Removed phone ${d.name ?? '(not paired)'}`,
});
return { ok: true };
});
}
+21 -8
View File
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Sign in with OpenID Connect, connect and disconnect an OpenID account (12 §3.2, §3.3).
import { and, eq, gt, isNull, notExists } from 'drizzle-orm';
import { and, eq, gt, isNull, ne, notExists } from 'drizzle-orm';
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { z } from 'zod';
import { decrypt, encrypt } from '../crypto/secrets.ts';
@@ -15,7 +15,7 @@ import {
} from '../errors.ts';
import { requireAdmin, requireAuth } from '../auth/guards.ts';
import { isLoginBlocked, recordLoginFailure } from '../auth/login-limiter.ts';
import { createSession, setSessionCookie } from '../auth/sessions.ts';
import { COOKIE, createSession, getSessionUser, setSessionCookie } from '../auth/sessions.ts';
import { audit } from '../services/audit.ts';
import { getSetting } from '../services/settings.ts';
import { decide, verifiedEmail } from '../sso/claims.ts';
@@ -146,7 +146,13 @@ export async function oidcRoutes(app: FastifyInstance) {
.where(and(eq(userIdentities.kind, 'oidc'), eq(userIdentities.subject, r.subject)));
if (connecting) {
// Connect (12 §3.3): to the user of the session that asked, if it's still signed in.
// Connect (12 §3.3): to the user of the session that asked, if it's still signed in, and
// only in that browser: a planted sp_oidc can't connect someone else's account (08 §7 #8).
const here = req.cookies[COOKIE]
? await getSessionUser(db, req.cookies[COOKIE], false)
: null;
if (here?.sessionId !== connecting)
return fail('expired', 'connect finished in another browser than the one that asked');
const [sess] = await db
.select({ userId: sessions.userId, email: users.email })
.from(sessions)
@@ -186,7 +192,8 @@ export async function oidcRoutes(app: FastifyInstance) {
let userId = owner?.userId;
if (!userId) {
// A user without a password or an OpenID account yet, by verified email (12 §3.3 step 2).
// A user without a password or any connected account yet, by verified email (12 §3.3
// step 2). Never an admin: a provider account with their email isn't them (08 §7 #2).
const v = verifiedEmail(r.claims, s.trustEmail);
const [u] = v
? await db
@@ -196,13 +203,12 @@ export async function oidcRoutes(app: FastifyInstance) {
and(
eq(users.email, v),
isNull(users.passwordHash),
ne(users.role, 'admin'),
notExists(
db
.select({ id: userIdentities.id })
.from(userIdentities)
.where(
and(eq(userIdentities.userId, users.id), eq(userIdentities.kind, 'oidc')),
),
.where(eq(userIdentities.userId, users.id)),
),
),
)
@@ -322,8 +328,15 @@ export async function oidcRoutes(app: FastifyInstance) {
recordLoginFailure(req.ip, login);
throw badRequest('The directory refused that username or password');
}
if ((await connectDirectory(db, req.log, req.user!.id, entry, req.ip)) === 'taken')
const r = await connectDirectory(db, req.log, req.user!.id, entry, req.ip);
if (r === 'taken')
throw new HttpError(409, 'taken', 'That directory account belongs to another user');
if (r === 'connected_other')
throw new HttpError(
409,
'connected_other',
'Another directory account is connected. Ask an admin to disconnect it first',
);
return { ok: true };
});
app.delete('/api/users/:id/identities/:kind', { preHandler: requireAdmin }, (req, reply) => {
+12 -2
View File
@@ -13,6 +13,7 @@ import { callExtensions, calls, extensions, recordings } from '../db/schema.ts';
import { badRequest, forbidden, notFound } from '../errors.ts';
import { audit } from '../services/audit.ts';
import { sideFiles } from '../ai/calls.ts';
import { ON_THE_CALL } from '../services/cdr.ts';
/** The recording and, when call transcripts left them, its two side files. */
const removeFiles = async (dir: string, filePath: string) => {
@@ -109,7 +110,10 @@ export async function recordingRoutes(app: FastifyInstance) {
},
);
/** Admins, or a participant: their extension is on the call this recording belongs to. */
/**
* Admins, or who was on the call: their extension made or answered it. Not a phone that only
* rang (a ring group, a queue): that's a coworker's conversation (08 §7 #3).
*/
async function mayHear(user: { id: string; role: string }, recordingId: string) {
if (user.role === 'admin') return;
const [mine] = await db
@@ -117,7 +121,13 @@ export async function recordingRoutes(app: FastifyInstance) {
.from(calls)
.innerJoin(callExtensions, eq(callExtensions.callId, calls.id))
.innerJoin(extensions, eq(extensions.id, callExtensions.extensionId))
.where(and(eq(calls.recordingId, recordingId), eq(extensions.userId, user.id)))
.where(
and(
eq(calls.recordingId, recordingId),
eq(extensions.userId, user.id),
inArray(callExtensions.role, ON_THE_CALL),
),
)
.limit(1);
if (!mine) throw forbidden('Not your call');
}
+39 -13
View File
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// /api/reports (04 §7.3, 05 §2 Reports): calls per day, busiest hours, per-queue stats. Admin only.
import { and, asc, eq, sql } from 'drizzle-orm';
// /api/reports (04 §7.3, 05 §2 Reports): calls per day, busiest hours, per-queue stats. Admin only,
// but /api/me/reports/summary: the same summary of the calls made to me.
import { and, asc, eq, inArray, ne, sql } from 'drizzle-orm';
import type { FastifyInstance } from 'fastify';
import {
CALL_DIRECTIONS,
@@ -10,12 +11,12 @@ import {
type ProviderBalanceDto,
type ReportSummary,
} from '@selfphoneng/shared';
import { requireAdmin } from '../auth/guards.ts';
import { requireAdmin, requireAuth } from '../auth/guards.ts';
import type { Db } from '../db/client.ts';
import { decrypt } from '../crypto/secrets.ts';
import { calls, queues, trunks } from '../db/schema.ts';
import { callExtensions, calls, extensions, queues, trunks } from '../db/schema.ts';
import { getBalance, VoipmsError } from '../integrations/voipms/client.ts';
import { badRequest } from '../errors.ts';
import { badRequest, notFound } from '../errors.ts';
import { todayIn } from '../generator/load.ts';
import { queueStats } from '../services/queuestats.ts';
import { getSetting } from '../services/settings.ts';
@@ -35,14 +36,28 @@ async function range(db: Db, query: unknown) {
export async function reportRoutes(app: FastifyInstance) {
const { db } = app;
app.addHook('preHandler', requireAdmin);
const admin = { preHandler: requireAdmin };
app.get('/api/reports/summary', async (req): Promise<ReportSummary> => {
const { tz, from, to } = await range(db, req.query);
/** `toExt`: only the calls that rang or were answered by that extension, not the ones it made. */
async function summary(query: unknown, toExt?: string): Promise<ReportSummary> {
const { tz, from, to } = await range(db, query);
const where = and(
sql`${calls.startedAt} >= (${from}::date)::timestamp at time zone ${tz}`,
sql`${calls.startedAt} < ((${to}::date + 1)::timestamp at time zone ${tz})`,
toExt
? inArray(
calls.id,
db
.select({ id: callExtensions.callId })
.from(callExtensions)
.where(and(eq(callExtensions.extensionId, toExt), ne(callExtensions.role, 'caller'))),
)
: undefined,
);
// Mine: answered and talk time are the calls I picked up, not a coworker in my ring group.
const answered = toExt
? sql`${calls.answeredByExtensionId} = ${toExt}`
: sql`${calls.status} = 'answered'`;
const local = sql`(${calls.startedAt} at time zone ${tz})`;
const missed = sql`${calls.status} in ('missed', 'voicemail', 'abandoned')`;
// Outside line time: inbound from arrival (a receptionist or voicemail answers too),
@@ -73,14 +88,14 @@ export async function reportRoutes(app: FastifyInstance) {
db
.select({
calls: sql<number>`count(*)::int`,
answered: sql<number>`count(*) filter (where ${calls.status} = 'answered')::int`,
answered: sql<number>`count(*) filter (where ${answered})::int`,
missed: sql<number>`count(*) filter (where ${missed})::int`,
talk: sql<number>`coalesce(sum(${calls.talkSeconds}), 0)::int`,
talk: sql<number>`coalesce(sum(${calls.talkSeconds}) filter (where ${toExt ? answered : sql`true`}), 0)::int`,
lineIn: sql<number>`round(${lineIn})::int`,
lineOut: sql<number>`${lineOut}::int`,
avgTalk: sql<
number | null
>`round(avg(${calls.talkSeconds}) filter (where ${calls.status} = 'answered'))::int`,
>`round(avg(${calls.talkSeconds}) filter (where ${answered}))::int`,
})
.from(calls)
.where(where),
@@ -113,10 +128,21 @@ export async function reportRoutes(app: FastifyInstance) {
},
hours,
};
}
app.get('/api/reports/summary', admin, (req) => summary(req.query));
app.get('/api/me/reports/summary', { preHandler: requireAuth }, async (req) => {
const [ext] = await db
.select({ id: extensions.id })
.from(extensions)
.where(eq(extensions.userId, req.user!.id));
if (!ext) throw notFound("You don't have an extension yet. Ask your admin.");
return summary(req.query, ext.id);
});
/** Each voip.ms trunk's account balance (owner, 2026-10-21); a trunk without API login says so. */
app.get('/api/reports/balance', async (): Promise<ListResponse<ProviderBalanceDto>> => {
app.get('/api/reports/balance', admin, async (): Promise<ListResponse<ProviderBalanceDto>> => {
const rows = await db
.select()
.from(trunks)
@@ -150,7 +176,7 @@ export async function reportRoutes(app: FastifyInstance) {
return { items, nextCursor: null };
});
app.get('/api/reports/queues', async (req): Promise<ListResponse<QueueReportRow>> => {
app.get('/api/reports/queues', admin, async (req): Promise<ListResponse<QueueReportRow>> => {
const { from, to } = await range(db, req.query);
const qs = await db.select().from(queues).orderBy(asc(queues.number));
return {
+135 -4
View File
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// /api/settings (04 §7.3) and the dashboard warnings. Admin only.
import { eq, inArray } from 'drizzle-orm';
import { randomBytes } from 'node:crypto';
import { eq, inArray, isNotNull } from 'drizzle-orm';
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import {
@@ -11,6 +12,7 @@ import {
OidcInputSchema,
SettingsSchemas,
SmtpInputSchema,
SpamInputSchema,
type AiTestResult,
type AuthMode,
type Destination,
@@ -19,13 +21,16 @@ import {
type OidcTestResult,
type SettingKey,
type SettingsDto,
type SpamInput,
type SpamSettings,
type SpamTestResult,
type Warning,
} from '@selfphoneng/shared';
import type { Db } from '../db/client.ts';
import { dids, extensions, trunks } from '../db/schema.ts';
import { dids, extensions, trunks, users } from '../db/schema.ts';
import { HttpError, badRequest } from '../errors.ts';
import { SLOW, requireAdmin } from '../auth/guards.ts';
import { encrypt } from '../crypto/secrets.ts';
import { decrypt, encrypt } from '../crypto/secrets.ts';
import { audit } from '../services/audit.ts';
import { assertDestinationExists } from '../services/destinations.ts';
import { getAllSettings, getSetting, setSetting } from '../services/settings.ts';
@@ -39,6 +44,10 @@ import { forgetBook } from '../contacts/contrack.ts';
import { assertLdap, testLdap, type LdapSettings } from '../sso/ldap.ts';
import { directoryUserFor } from '../sso/directory.ts';
import { assertIssuer, oidcConfig, oidcError, testOidc, type OidcSettings } from '../sso/oidc.ts';
import { ftcTest } from '../spam/ftc.ts';
import { SpamError } from '../spam/http.ts';
import { ipqsCredits } from '../spam/ipqs.ts';
import { patchSpamState, resumeIpqs } from '../spam/check.ts';
declare module 'fastify' {
interface FastifyInstance {
@@ -52,8 +61,10 @@ const READ_ONLY = new Set<string>([
'system.restartRequired',
'system.transportHash',
'system.directoryWarning',
'spam.state',
'sms.webhookToken',
'setup.completed',
'network.probeKeyEnc',
]);
async function toDto(db: Db): Promise<SettingsDto> {
@@ -62,10 +73,22 @@ async function toDto(db: Db): Promise<SettingsDto> {
'contacts.contrack': ctk,
'auth.oidc': oidc,
'auth.ldap': ldap,
spam,
'network.probeKeyEnc': probeKey,
...rest
} = await getAllSettings(db);
return {
...rest,
'network.hasProbeKey': probeKey !== null,
spam: {
...spam,
ipqs: spam.ipqs && { hasKey: true },
ftc: spam.ftc && {
hasKey: true,
syncHours: spam.ftc.syncHours,
windowDays: spam.ftc.windowDays,
},
},
'auth.ldap': ldap && {
enabled: ldap.enabled,
preset: ldap.preset,
@@ -209,6 +232,28 @@ export async function settingsRoutes(app: FastifyInstance) {
trustEmail: input.trustEmail,
};
}
/** The stored form of `spam` (14 §7): keys are write-only (empty keeps the stored one). */
async function spamFromInput(input: SpamInput): Promise<SpamSettings> {
const v = SpamInputSchema.parse(input);
const old = await getSetting(db, 'spam');
const keyEnc = (typed: string | undefined, stored: string | undefined, name: string) => {
if (typed) return encrypt(typed);
if (stored) return stored;
throw badRequest(`Enter the ${name} key`);
};
const ipqs = v.ipqs && {
keyEnc: keyEnc(v.ipqs.key, old.ipqs?.keyEnc, 'IPQualityScore'),
};
const ftc = v.ftc && {
keyEnc: keyEnc(v.ftc.key, old.ftc?.keyEnc, 'api.data.gov'),
syncHours: v.ftc.syncHours,
windowDays: v.ftc.windowDays,
};
if (v.enabled && !ipqs && !ftc)
throw badRequest('Set up IPQualityScore or the FTC list to turn spam check on');
return { ...v, ipqs, ftc };
}
app.addHook('preHandler', requireAdmin);
app.get('/api/settings', () => toDto(db));
@@ -216,6 +261,7 @@ export async function settingsRoutes(app: FastifyInstance) {
app.patch('/api/settings', async (req) => {
const body = z.record(z.string(), z.unknown()).parse(req.body);
const values: Record<string, unknown> = {};
let contrackMoved = false;
for (const [key, raw] of Object.entries(body)) {
if (!(key in SettingsSchemas) || READ_ONLY.has(key))
throw badRequest(`${key} can't be changed here`);
@@ -246,8 +292,13 @@ export async function settingsRoutes(app: FastifyInstance) {
} catch (err) {
throw badRequest(`Contrack address: ${(err as Error).message}`);
}
// The saved tokens only go back to the address they were typed for (08 §7 #7).
const url = input?.url.replace(/\/+$/, '');
const moved = !!old && !!input && old.url !== url;
if (moved && old.companyTokenEnc && !input.companyToken && !input.clearCompanyToken)
throw badRequest('The Contrack address changed: enter the company token again');
values[key] = input && {
url: input.url.replace(/\/+$/, ''),
url: url!,
publicUrl: input.publicUrl,
companyTokenEnc: input.companyToken
? encrypt(input.companyToken)
@@ -255,9 +306,12 @@ export async function settingsRoutes(app: FastifyInstance) {
? ''
: (old?.companyTokenEnc ?? ''),
};
if (moved) contrackMoved = true;
forgetBook(null);
} else if (key === 'auth.ldap') {
values[key] = await ldapFromInput(raw);
} else if (key === 'spam') {
values[key] = await spamFromInput(raw as SpamInput);
} else if (key === 'auth.oidc') {
values[key] = await oidcFromInput(raw === null ? null : OidcInputSchema.parse(raw));
} else values[key] = SettingsSchemas[key as SettingKey].parse(raw);
@@ -306,8 +360,19 @@ export async function settingsRoutes(app: FastifyInstance) {
const cert = await readTlsCert(app.config.TLS_DIR);
if (!cert.ok) throw badRequest(`Can't turn on TLS. ${cert.error}`);
}
// The probe's key was made for its address: another probe doesn't get it (08 §7 #9).
if ('network.probeUrl' in values && values['network.probeUrl'] !== before['network.probeUrl'])
values['network.probeKeyEnc'] = null;
for (const [key, value] of Object.entries(values))
await setSetting(db, key as SettingKey, value as never);
// People's own Contrack tokens were for the old address: they connect again (08 §7 #7).
if (contrackMoved)
await db
.update(users)
.set({ contrackTokenEnc: null })
.where(isNotNull(users.contrackTokenEnc));
// A new IPQualityScore key may have credits: don't wait for spamQuotaCheck (14 §3).
if ((body.spam as SpamInput | undefined)?.ipqs?.key) await resumeIpqs(db, 'a new key');
const changed = Object.keys(values).filter(
(k) => JSON.stringify(before[k as keyof typeof before]) !== JSON.stringify(values[k]),
);
@@ -403,6 +468,51 @@ export async function settingsRoutes(app: FastifyInstance) {
},
);
/**
* Settings → Spam check → Test (14 §7.1), with the typed key or else the saved one. IPQualityScore
* answers with its credits left, which spends none.
*/
for (const provider of ['ipqs', 'ftc'] as const)
app.post(
`/api/settings/spam/${provider}/test`,
{ config: SLOW },
async (req): Promise<SpamTestResult> => {
const { key } = z
.object({ key: z.string().trim().max(200).optional() })
.parse(req.body ?? {});
const keyEnc = (await getSetting(db, 'spam'))[provider]?.keyEnc;
const k = key || (keyEnc && decrypt(keyEnc));
if (!k) return { ok: false, error: 'Enter the key first' };
try {
if (provider === 'ftc') return { ok: true, detail: await ftcTest(k) };
const credits = await ipqsCredits(k);
return { ok: true, detail: `Works: ${credits} credits left this billing period.` };
} catch (err) {
if (err instanceof SpamError) return { ok: false, error: err.message };
throw err;
}
},
);
/**
* A new key for the network test probe (08 §7 #9): shown this once, for its PROBE_KEY. Made for
* the probe address set now; the old key stops working on the next test.
*/
app.post('/api/settings/network/probe-key', async (req) => {
if (!(await getSetting(db, 'network.probeUrl')))
throw badRequest('Set the probe address first');
const key = randomBytes(32).toString('base64url');
await setSetting(db, 'network.probeKeyEnc', encrypt(key));
await audit(db, {
actorUserId: req.user!.id,
actorIp: req.ip,
action: 'update',
entityType: 'settings',
summary: 'Made a new network test probe key',
});
return { key };
});
/** The server's public IPv4, as an outside service sees it (only sent when asked). */
app.post('/api/settings/network/detect-public-ip', { config: SLOW }, async () => {
const ip = await detectPublicIp();
@@ -488,6 +598,14 @@ export async function settingsRoutes(app: FastifyInstance) {
message: s['system.directoryWarning'],
link: '/admin/settings/sign-in',
});
const st = s['spam.state'];
if (s.spam.enabled && s.spam.ipqs && st.ipqsPausedAt && !st.warningDismissedAt)
items.push({
code: 'spam_quota',
message: `IPQualityScore's lookups are used up. Spam checks ${s.spam.ftc ? 'use the FTC list' : 'are off'} until they come back.`,
link: '/admin/settings/spam',
dismissable: true,
});
if (!s.smtp)
items.push({
code: 'smtp_missing',
@@ -496,4 +614,17 @@ export async function settingsRoutes(app: FastifyInstance) {
});
return { items };
});
/** Settings → Spam check → Download now (14 §2.2): due at the next minute, unless under way. */
app.post('/api/settings/spam/ftc/sync', async () => {
if (!(await getSetting(db, 'spam')).ftc) throw badRequest('Set up the FTC list first');
await patchSpamState(db, { ftcLastSyncAt: null });
return { ok: true };
});
/** Hidden until the next time the credits run out (14 §3). */
app.post('/api/system/warnings/spam_quota/dismiss', async () => {
await patchSpamState(db, { warningDismissedAt: new Date().toISOString() });
return { ok: true };
});
}
+6 -9
View File
@@ -208,21 +208,18 @@ export async function trunkRoutes(app: FastifyInstance) {
if (!t.enabled) throw badRequest('Turn the trunk on first');
if (!app.live.amiConnected)
throw new HttpError(503, 'unavailable', 'Asterisk is not connected');
// No OPTIONS over TLS (see the trunk's aor in generator/pjsip.ts).
const { live, rttMs } = await app.live.testTrunk(id, { qualify: t.transport !== 'tls' });
const { live, rttMs } = await app.live.testTrunk(id);
const reg = t.authMode === 'registration';
const detail =
reg && live.registration === 'registered'
? `Registered with ${t.serverHost}`
: reg && live.registration === 'rejected'
? `${t.serverHost} rejected the username or password`
: !reg && t.transport === 'tls'
? `Can't check a TLS trunk without registration from here: make a test call.`
: !live.reachable
? `${t.serverHost} doesn't answer. Check the server name and that this server can reach it on port ${t.serverPort}.`
: reg
? `${t.serverHost} answers but the registration hasn't completed yet. Try again in a minute.`
: `${t.serverHost} answers (IP authentication: no registration)`;
: !live.reachable
? `${t.serverHost} doesn't answer. Check the server name and that this server can reach it on port ${t.serverPort}.`
: reg
? `${t.serverHost} answers but the registration hasn't completed yet. Try again in a minute.`
: `${t.serverHost} answers (IP authentication: no registration)`;
return { registration: reg ? live.registration : 'unknown', rttMs, detail };
});
+6 -3
View File
@@ -138,6 +138,9 @@ export async function sendCallReports(d: {
aiDoneAt: recordings.aiDoneAt,
out: sql<boolean>`exists (select 1 from ${callExtensions} where ${callExtensions.callId} = ${calls.id}
and ${callExtensions.extensionId} = ${e.id} and ${callExtensions.role} = 'caller')`,
// Made or answered it: only rang means a coworker's conversation (08 §7 #3).
heard: sql<boolean>`exists (select 1 from ${callExtensions} where ${callExtensions.callId} = ${calls.id}
and ${callExtensions.extensionId} = ${e.id} and ${callExtensions.role} in ('caller', 'answered'))`,
})
.from(calls)
.leftJoin(answerer, eq(answerer.id, calls.answeredByExtensionId))
@@ -177,9 +180,9 @@ export async function sendCallReports(d: {
toNumber: r.c.toNumber,
talkSeconds: r.c.talkSeconds,
answeredBy: r.answeredById && r.answeredById !== e.id ? r.answeredBy : null,
recorded: r.c.recordingId !== null,
summary: r.summary,
summaryPending: r.c.recordingId !== null && r.aiDoneAt === null,
recorded: r.heard && r.c.recordingId !== null,
summary: r.heard ? r.summary : null,
summaryPending: r.heard && r.c.recordingId !== null && r.aiDoneAt === null,
}));
const withSummaries =
e.callReportSummaries &&
+20 -4
View File
@@ -9,10 +9,17 @@ import { join } from 'node:path';
import { and, asc, eq, inArray, isNull, lt } from 'drizzle-orm';
import { field, type AmiMessage } from '../ami/events.ts';
import type { Db } from '../db/client.ts';
import { checkNumber, usNumber, type SpamDeps } from '../spam/check.ts';
import { callExtensions, calls, cdr, dids, extensions, recordings } from '../db/schema.ts';
import { durationMs } from '../media/audio.ts';
type CdrRow = typeof cdr.$inferSelect;
/**
* The call_extensions roles of someone who was on the call, not just rung: they may hear its
* recording and read its transcript and summary (08 §7 #3).
*/
export const ON_THE_CALL = ['caller', 'answered'];
type Tx = Parameters<Parameters<Db['transaction']>[0]>[0];
/** cdr_manager.conf has usegmtime = yes: "2026-10-18 19:28:37" is UTC. */
@@ -160,7 +167,7 @@ async function linkRecording(db: Db | Tx, dir: string, s: CallSummary, linkedid:
export async function aggregateCall(
db: Db,
linkedid: string,
opts: { recordingsDir?: string } = {},
opts: { recordingsDir?: string; log?: SpamDeps['log'] } = {},
): Promise<void> {
const rows = await db
.select()
@@ -180,6 +187,13 @@ export async function aggregateCall(
const [d] = s.did
? await db.select({ id: dids.id }).from(dids).where(eq(dids.number, s.did))
: [];
// 14 §6: what the spam check knows now (the cache, the local FTC copy), kept with the call.
const spam =
s.direction === 'inbound' && s.fromNumber
? await checkNumber({ db, log: opts.log ?? { warn() {} } }, usNumber(s.fromNumber), {
cachedOnly: true,
})
: null;
await db.transaction(async (tx) => {
await tx.delete(calls).where(eq(calls.linkedid, linkedid));
const [call] = await tx
@@ -197,6 +211,8 @@ export async function aggregateCall(
didId: d?.id ?? null,
answeredByExtensionId: idOf(s.answeredBy),
talkSeconds: s.talkSeconds,
spamRisk: spam?.risk ?? null,
spamInfo: spam && { provider: spam.provider, info: spam.info },
recordingId: opts.recordingsDir
? await linkRecording(tx, opts.recordingsDir, s, linkedid)
: null,
@@ -218,7 +234,7 @@ export async function aggregateCall(
export type CdrDeps = {
db: Db;
log: { error(o: object, msg: string): void };
log: { error(o: object, msg: string): void; warn(o: object, msg: string): void };
/** How long after a call's last Cdr event to aggregate it (04 §5.4: 5 s). */
settleMs?: number;
/** Where MixMonitor writes (RECORDINGS_DIR); recordings are linked to their calls. */
@@ -233,7 +249,7 @@ export function watchCdr(d: CdrDeps, ami: Pick<EventEmitter, 'on'>) {
linkedid,
setTimeout(() => {
timers.delete(linkedid);
void aggregateCall(d.db, linkedid, { recordingsDir: d.recordingsDir }).catch(
void aggregateCall(d.db, linkedid, { recordingsDir: d.recordingsDir, log: d.log }).catch(
(err: unknown) => d.log.error({ err, linkedid }, 'call aggregation failed'),
);
}, d.settleMs ?? 5000),
@@ -257,7 +273,7 @@ export function watchCdr(d: CdrDeps, ami: Pick<EventEmitter, 'on'>) {
.leftJoin(calls, eq(calls.linkedid, cdr.linkedid))
.where(and(isNull(calls.id), lt(cdr.createdAt, new Date(Date.now() - 60_000))));
for (const o of orphans)
await aggregateCall(d.db, o.linkedid, { recordingsDir: d.recordingsDir });
await aggregateCall(d.db, o.linkedid, { recordingsDir: d.recordingsDir, log: d.log });
},
stop: () => timers.forEach(clearTimeout),
};
+23 -6
View File
@@ -9,6 +9,7 @@ import { BlockList, isIPv4 } from 'node:net';
import type { DiagnosticCheck, NetworkTestDto } from '@selfphoneng/shared';
import type { AmiClient } from '../ami/client.ts';
import type { Config } from '../config.ts';
import { decrypt } from '../crypto/secrets.ts';
import type { Db } from '../db/client.ts';
import { detectPublicIp } from '../jobs.ts';
import { getAllSettings } from './settings.ts';
@@ -221,15 +222,23 @@ export async function insideChecks(d: DiagDeps): Promise<DiagnosticCheck[]> {
);
out.push(
s['network.probeUrl']
? check('probe', 'Network test probe', 'ok', s['network.probeUrl'])
: check(
!s['network.probeUrl']
? check(
'probe',
'Network test probe',
'skip',
'None set, so nothing is tested from outside your network.',
'Run apps/probe on a machine outside this network (deploy/probe), then put its URL in Settings → Network.',
),
)
: s['network.probeKeyEnc']
? check('probe', 'Network test probe', 'ok', `${s['network.probeUrl']}, with its key`)
: check(
'probe',
'Network test probe',
'warn',
`${s['network.probeUrl']} has no key: anyone who finds it can use it.`,
'Settings → Network → Generate key, then set it as PROBE_KEY on the probe and restart it (strongly recommended).',
),
);
return out;
}
@@ -289,14 +298,20 @@ export async function networkTest(
let ip: string | null = null;
let results: ProbeResult[] = [];
let probeError: string | null = null;
let keyRefused = false;
const key = s['network.probeKeyEnc'];
try {
const r = await (d.fetchProbe ?? fetch)(new URL('/v1/check', probe), {
method: 'POST',
headers: { 'content-type': 'application/json' },
headers: {
'content-type': 'application/json',
...(key && { authorization: `Bearer ${decrypt(key)}` }),
},
body: JSON.stringify({ host, tests }),
signal: AbortSignal.timeout(30_000),
});
const body = (await r.json()) as { ip?: string; results?: ProbeResult[]; error?: string };
keyRefused = r.status === 401;
if (!r.ok) probeError = body.error ?? `HTTP ${r.status}`;
else ({ ip = null, results = [] } = body as { ip: string; results: ProbeResult[] });
// Datagrams sent last arrive just after the answer.
@@ -316,7 +331,9 @@ export async function networkTest(
'Network test probe',
'fail',
`${probe} didn't answer: ${probeError}`,
'Check the probe runs and is reachable from this server, then try again.',
keyRefused
? "The probe's PROBE_KEY isn't this server's key. Settings → Network → Generate key, set it as PROBE_KEY on the probe and restart it."
: 'Check the probe runs and is reachable from this server, then try again.',
),
],
};
+239
View File
@@ -0,0 +1,239 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Fraud alerts (08 §8.3): risky calls the PBX placed, voip.ms calls it didn't place, and a
// balance that drops fast. Each is an email to every admin and a warning event.
import { and, eq, gt, gte, isNotNull, like, lte } from 'drizzle-orm';
import { formatNumber } from '@selfphoneng/shared';
import type { Db } from '../db/client.ts';
import { calls, cdr, dids, trunks } from '../db/schema.ts';
import { decrypt } from '../crypto/secrets.ts';
import {
getBalance,
getCdr,
VoipmsError,
type VoipmsCall,
type VoipmsCreds,
} from '../integrations/voipms/client.ts';
import { logEvent } from './events.ts';
import { getSetting } from './settings.ts';
/** NANP area codes outside the US and Canada: dialed like a home call, billed like abroad. (verify) */
const ABROAD_NANP = new Set(
'242 246 264 268 284 345 441 473 649 658 664 721 758 767 784 809 829 849 868 869 876'.split(' '),
);
/** Why a dialed number is worth an email, or null. 10 digits count as 1 + them. */
export function riskyDestination(dialed: string): string | null {
const n = /^[2-9]\d{9}$/.test(dialed) ? `1${dialed}` : dialed;
if (n.startsWith('011')) return 'international';
const m = /^1(\d{3})(\d{3})\d{4}$/.exec(n);
if (!m) return null;
if (m[1] === '900' || m[2] === '976') return 'premium';
if (ABROAD_NANP.has(m[1]!)) return 'international rates (outside the US and Canada)';
return null;
}
/** The number a trunk Dial sent (`PJSIP/<number>@trunk-<id>,120`), or null. */
export const trunkDialNumber = (lastdata: string | null): string | null =>
/^PJSIP\/([^@]+)@trunk-/.exec(lastdata ?? '')?.[1] ?? null;
const tail = (n: string) => n.replace(/\D/g, '').slice(-10);
const MIN = 60_000;
const day = (d: Date) => d.toISOString().slice(0, 10);
export type FraudDeps = {
db: Db;
voipmsUrl: string;
mail: (subject: string, text: string) => Promise<void>;
log: { warn(o: object, m: string): void };
now: () => Date;
};
export function createFraudWatch(d: FraudDeps) {
const when = async (at: Date) =>
new Intl.DateTimeFormat('en-US', {
dateStyle: 'short',
timeStyle: 'short',
timeZone: await getSetting(d.db, 'general.timezone'),
}).format(at);
const warn = (code: string, source: string, message: string) =>
logEvent(d.db, { severity: 'warning', code, source, message });
// Calls are read 10 s behind the clock, so a row whose insert is still committing isn't skipped.
// ponytail: the cursor starts at api start; a call made while the api was down has no row anyway.
let since = new Date(d.now().getTime() - 10_000);
const riskyCallWatch = async () => {
const until = new Date(d.now().getTime() - 10_000);
const rows = await d.db
.select()
.from(calls)
.where(
and(
eq(calls.direction, 'outbound'),
gt(calls.createdAt, since),
lte(calls.createdAt, until),
),
)
.orderBy(calls.createdAt);
since = until;
if (!(await getSetting(d.db, 'alerts.riskyCalls'))) return;
const lines: string[] = [];
for (const c of rows) {
const why = riskyDestination(c.toNumber ?? '');
if (why)
lines.push(
`${await when(c.startedAt)} from ${c.fromNumber ?? '?'} to ${formatNumber(c.toNumber!)}: ` +
`${why}, ${c.status}, ${c.talkSeconds} s talking`,
);
}
if (!lines.length) return;
await warn(
'fraud.risky_call',
'Fraud alerts',
`${lines.length} call(s) to international or premium numbers`,
);
await d.mail(
`Calls to risky numbers (${lines.length})`,
'The phone system placed calls to numbers that can cost a lot:\n\n' +
`${lines.join('\n')}\n\n` +
"If you don't recognise them, someone may have a phone's password: change it " +
'(Extensions → the extension → New SIP password) and check Outbound rules.\n' +
'These emails can be turned off on Settings → Security → Fraud alerts.',
);
};
// voip.ms records already reported, for 3 hours (08 §8.3).
const reported = new Map<string, number>();
async function callCheck(t: { name: string; username: string }, creds: VoipmsCreds) {
const now = d.now().getTime();
for (const [id, at] of reported) if (at < now - 180 * MIN) reported.delete(id);
const from = new Date(now - 120 * MIN);
const to = new Date(now - 10 * MIN);
// uniqueid isn't in voip.ms's examples (06 §5.1), so the time and number make the key too.
const key = (r: VoipmsCall) => `${r.uniqueid}|${r.at.getTime()}|${r.destination}`;
const records = (
await getCdr(d.voipmsUrl, creds, { from: day(from), to: day(to), account: t.username })
).filter((r) => r.at >= from && r.at <= to && !reported.has(key(r)));
if (!records.length) return;
const ours = new Set((await d.db.select({ n: dids.number }).from(dids)).map((x) => tail(x.n)));
// Every call the PBX sent to a trunk, forwards and 911 included (03 §6.12, §6.14).
const sent = (
await d.db
.select({ at: cdr.startAt, data: cdr.lastdata })
.from(cdr)
.where(
and(
like(cdr.lastdata, 'PJSIP/%@trunk-%'),
gte(cdr.startAt, new Date(from.getTime() - 5 * MIN)),
lte(cdr.startAt, new Date(to.getTime() + 5 * MIN)),
),
)
).flatMap((r) => {
const n = trunkDialNumber(r.data);
return n && r.at ? [{ n: tail(n), at: r.at.getTime() }] : [];
});
const strange: VoipmsCall[] = [];
for (const r of records) {
reported.set(key(r), r.at.getTime());
if (r.account && r.account !== t.username) continue;
if (ours.has(tail(r.destination))) continue; // incoming, to one of our numbers
const i = sent.findIndex(
(s) => s.n === tail(r.destination) && Math.abs(s.at - r.at.getTime()) <= 5 * MIN,
);
if (i >= 0) sent.splice(i, 1);
else strange.push(r);
}
if (!strange.length) return;
const lines = await Promise.all(
strange.map(
async (r) =>
`${await when(r.at)} to ${r.destination}: ${r.disposition.toLowerCase() || '?'}, ` +
`${r.seconds} s, cost ${r.total}`,
),
);
await warn(
'fraud.provider_call',
`Trunk ${t.name}`,
`voip.ms has ${strange.length} call(s) on ${t.username} that this phone system didn't make`,
);
await d.mail(
`voip.ms calls this phone system didn't make (${strange.length})`,
`voip.ms billed these calls on sub-account ${t.username} (trunk ${t.name}), and this ` +
'phone system has no record of making them:\n\n' +
`${lines.join('\n')}\n\n` +
'Someone else may have the sub-account password. At voip.ms: change the sub-account ' +
"password (then paste it on Trunks), and turn on its IP restriction with this server's " +
'public IP. Check the voip.ms call records too.\n' +
'A call made while SelfPhoneNG was restarting, or by another device using the same ' +
'sub-account, also shows up here.\n' +
'These emails can be turned off on Settings → Security → Fraud alerts.',
);
}
// Balance samples per API login (one account can have several trunks), the last hour's.
const balances = new Map<string, { at: number; v: number }[]>();
const balanceMailed = new Map<string, number>();
async function balanceCheck(creds: VoipmsCreds, limit: number) {
const now = d.now().getTime();
const v = await getBalance(d.voipmsUrl, creds);
const samples = [
...(balances.get(creds.username) ?? []).filter((s) => s.at >= now - 60 * MIN),
{ at: now, v },
];
balances.set(creds.username, samples);
const high = Math.max(...samples.map((s) => s.v));
if (high - v <= limit || (balanceMailed.get(creds.username) ?? 0) > now - 60 * MIN) return;
balanceMailed.set(creds.username, now);
const drop = (high - v).toFixed(2);
await warn(
'fraud.balance_drop',
'Fraud alerts',
`The voip.ms balance dropped ${drop} in an hour`,
);
await d.mail(
`voip.ms balance dropped ${drop} in an hour`,
`The voip.ms balance of ${creds.username} went from ${high.toFixed(2)} to ${v.toFixed(2)} ` +
`within the last hour, more than the ${limit} set on Settings → Security → Fraud alerts.\n\n` +
"If that isn't your own calling, check the call records at voip.ms, change the " +
'sub-account and API passwords, and turn on IP restriction on the sub-account.',
);
}
const providerWatch = async () => {
const checkCalls = await getSetting(d.db, 'alerts.providerCalls');
const limit = await getSetting(d.db, 'alerts.balanceDropPerHour');
if (!checkCalls && !limit) return;
const rows = await d.db
.select({
name: trunks.name,
username: trunks.username,
apiUser: trunks.voipmsApiUsername,
apiPass: trunks.voipmsApiPasswordEnc,
})
.from(trunks)
.where(
and(
eq(trunks.enabled, true),
eq(trunks.provider, 'voipms'),
isNotNull(trunks.voipmsApiUsername),
isNotNull(trunks.voipmsApiPasswordEnc),
),
);
const balanceAsked = new Set<string>();
for (const t of rows) {
const creds = { username: t.apiUser!, password: decrypt(t.apiPass!) };
try {
if (checkCalls && t.username)
await callCheck({ name: t.name, username: t.username }, creds);
if (limit && !balanceAsked.has(creds.username)) {
balanceAsked.add(creds.username);
await balanceCheck(creds, limit);
}
} catch (e) {
if (!(e instanceof VoipmsError)) throw e;
d.log.warn({ trunk: t.name, status: e.status }, 'voip.ms fraud check failed');
}
}
};
return { riskyCallWatch, providerWatch };
}
+6 -5
View File
@@ -121,11 +121,12 @@ async function transcribeMessage(d: VoicemailDeps, row: Row): Promise<string | n
async function announce(d: VoicemailDeps, box: Box, found: Row) {
const transcript = await transcribeMessage(d, found);
const row = { ...found, transcript };
// Every mailbox, with or without a user (the live message is only for the user's own).
void enqueueWebhook(d.db, 'voicemail.new', {
...toVoicemailDto(row),
extension: box.number,
}).catch((err: unknown) => d.log.error({ err }, 'webhook enqueue failed'));
// Every mailbox, with or without a user (the live message is only for the user's own). Not
// the transcript: webhooks go off this server, and transcripts never do (10 §1, 08 §7 #6).
const { transcript: _words, ...hook } = toVoicemailDto(row);
void enqueueWebhook(d.db, 'voicemail.new', { ...hook, extension: box.number }).catch(
(err: unknown) => d.log.error({ err }, 'webhook enqueue failed'),
);
if (box.userId)
d.live.emit('message', {
type: 'voicemail.new',
+209
View File
@@ -0,0 +1,209 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// The spam check (14 §5): the one way to ask what's known about a caller's number. Information
// only: nothing here changes a call, and a failure only means there's no answer (and one warning
// in the log). The contacts test (14 §1) is the caller's: it knows whose books to look in.
import { and, eq, gt, isNull, sql, type SQL } from 'drizzle-orm';
import {
SpamStateSchema,
normalizeNumber,
type IpqsInfo,
type SpamCheck,
type SpamRisk,
type SpamSettings,
type SpamState,
} from '@selfphoneng/shared';
import { contactNames } from '../contacts/book.ts';
import type { Db } from '../db/client.ts';
import { settings, spamLookups } from '../db/schema.ts';
import { decrypt } from '../crypto/secrets.ts';
import { logEvent } from '../services/events.ts';
import { getSetting } from '../services/settings.ts';
import { ftcLookup } from './ftc.ts';
import { SpamError } from './http.ts';
import { ipqsCredits, ipqsLookup } from './ipqs.ts';
export type SpamDeps = { db: Db; log: { warn(o: object, msg: string): void } };
/** US numbers only (owner, 2026-10-27): 1NXXNXXXXXX after normalizeNumber (00 §8). */
export const isCheckable = (n: string) => /^1[2-9]\d{2}[2-9]\d{6}$/.test(n);
/** `n` as 1NXXNXXXXXX-style digits (00 §8), or '' when it isn't a number. */
export const usNumber = (n: string) => {
try {
return normalizeNumber(n);
} catch {
return '';
}
};
/** A check under way, by number: a ring group ringing five people costs one credit (14 §4). */
const pending = new Map<string, Promise<SpamCheck | null>>();
function warn(d: SpamDeps, provider: string, err: unknown) {
if (err instanceof SpamError) d.log.warn({ provider, reason: err.message }, 'spam check failed');
else d.log.warn({ provider, err }, 'spam check failed');
}
/**
* IPQualityScore first when it's set up, then the FTC list (owner, 2026-10-27). `cachedOnly`:
* the cache and the local FTC copy, never a request out (call history, 14 §6).
*/
export function checkNumber(
d: SpamDeps,
number: string,
opts: { cachedOnly?: boolean } = {},
): Promise<SpamCheck | null> {
if (opts.cachedOnly) return check(d, number, true);
// Shared from the start, before anything is awaited: else a check that finishes between
// another's cache read and its look here would cost a second request.
let p = pending.get(number);
if (!p) {
p = check(d, number, false).finally(() => pending.delete(number));
pending.set(number, p);
}
return p;
}
async function check(d: SpamDeps, number: string, cachedOnly: boolean): Promise<SpamCheck | null> {
if (!isCheckable(number)) return null;
const s = await getSetting(d.db, 'spam');
if (!s.enabled) return null;
if (s.ipqs) {
const [hit] = await d.db
.select()
.from(spamLookups)
.where(and(eq(spamLookups.number, number), gt(spamLookups.expiresAt, sql`now()`)));
if (hit) return { risk: hit.risk as SpamRisk, provider: 'ipqs', info: hit.info as IpqsInfo };
// Paused (14 §3): the cache still answers, IPQualityScore isn't asked.
if (!cachedOnly && !(await getSetting(d.db, 'spam.state')).ipqsPausedAt) {
const answer = await askIpqs(d, s, s.ipqs.keyEnc, number);
if (answer) return answer;
}
}
if (!s.ftc) return null;
try {
return { provider: 'ftc', ...(await ftcLookup(d.db, number, s.ftc.windowDays)) };
} catch (err) {
warn(d, 'ftc', err);
return null;
}
}
/**
* The check for a call ringing `userId` (14 §5): nothing for a number in their books (or when
* the books can't be read), else the answer if it comes within `waitMs`. A later answer still
* fills the cache.
*/
export async function checkCaller(
d: SpamDeps,
userId: string | null,
number: string,
waitMs: number,
): Promise<SpamCheck | null> {
if (!isCheckable(number)) return null;
const known = await contactNames(d.db, userId, [number], { wait: false }).catch(() => null);
if (!known || known.size) return null;
const check = checkNumber(d, number).catch((err: unknown) => {
warn(d, 'check', err);
return null;
});
let timer: NodeJS.Timeout | undefined;
const late = new Promise<null>((r) => (timer = setTimeout(r, waitMs, null)));
try {
return await Promise.race([check, late]);
} finally {
clearTimeout(timer);
}
}
async function askIpqs(
d: SpamDeps,
s: SpamSettings,
keyEnc: string,
number: string,
): Promise<SpamCheck | null> {
try {
const a = await ipqsLookup(decrypt(keyEnc), number);
if (a.kind === 'exhausted') {
await pauseIpqs(d, s);
return null;
}
const days = a.risk === 'low' ? s.cache.cleanDays : s.cache.flaggedDays;
const expiresAt = sql`now() + make_interval(days => ${days})`;
await d.db
.insert(spamLookups)
.values({ number, provider: 'ipqs', risk: a.risk, info: a.info, expiresAt })
.onConflictDoUpdate({
target: spamLookups.number,
set: { risk: a.risk, info: a.info, expiresAt, updatedAt: new Date() },
});
return { risk: a.risk, provider: 'ipqs', info: a.info };
} catch (err) {
warn(d, 'ipqs', err);
return null;
}
}
/**
* Merges `patch` into spam.state in one statement, so the jobs and checks don't overwrite each
* other's fields. `onlyIf`: the condition the stored state must meet; false when it didn't.
*/
export async function patchSpamState(
db: Db,
patch: Partial<SpamState>,
onlyIf?: SQL,
): Promise<boolean> {
await db
.insert(settings)
.values({ key: 'spam.state', value: SpamStateSchema.parse({}) })
.onConflictDoNothing();
const rows = await db
.update(settings)
.set({
value: sql`${settings.value} || ${JSON.stringify(patch)}::jsonb`,
updatedAt: new Date(),
})
.where(and(eq(settings.key, 'spam.state'), onlyIf))
.returning({ key: settings.key });
return rows.length > 0;
}
const paused = sql`${settings.value}->>'ipqsPausedAt'`;
/** Out of credits (14 §3): paused, and the event, warning and log line once per time. */
async function pauseIpqs(d: SpamDeps, s: SpamSettings) {
const first = await patchSpamState(
d.db,
{ ipqsPausedAt: new Date().toISOString(), warningDismissedAt: null },
isNull(paused),
);
if (!first) return;
d.log.warn({ provider: 'ipqs' }, 'spam check: IPQualityScore credits are used up, paused');
await logEvent(d.db, {
severity: 'warning',
code: 'spam.quotaExhausted',
source: 'Spam check',
message: `IPQualityScore's lookups are used up. ${s.ftc ? 'Spam checks use the FTC list' : 'Spam checks are off'} until they come back.`,
});
}
/** Unpaused, by the job or a new key (14 §3). */
export async function resumeIpqs(db: Db, why: string) {
const was = await patchSpamState(db, { ipqsPausedAt: null }, sql`${paused} is not null`);
if (was)
await logEvent(db, {
severity: 'info',
code: 'spam.quotaRestored',
source: 'Spam check',
message: `IPQualityScore lookups are back (${why}).`,
});
}
/** Job spamQuotaCheck (14 §3), every 6 hours: while paused, are credits back? A failure throws
* (the job's log line) and the next run tries again. */
export async function spamQuotaCheck(db: Db) {
const s = await getSetting(db, 'spam');
if (!s.ipqs || !(await getSetting(db, 'spam.state')).ipqsPausedAt) return;
const credits = await ipqsCredits(decrypt(s.ipqs.keyEnc));
if (credits > 0) await resumeIpqs(db, `${credits} credits left`);
}
+171
View File
@@ -0,0 +1,171 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// The FTC's Do Not Call complaints (14 §2.2): a check reads the local copy, so it sends nothing
// out. spamFtcSync keeps the copy.
import { lt, sql } from 'drizzle-orm';
import type { FtcInfo, SpamRisk } from '@selfphoneng/shared';
import type { Db } from '../db/client.ts';
import { ftcComplaints } from '../db/schema.ts';
import { decrypt } from '../crypto/secrets.ts';
import { getSetting } from '../services/settings.ts';
import { todayIn } from '../generator/load.ts';
import { isCheckable, patchSpamState } from './check.ts';
import { SpamError, getJson } from './http.ts';
export const FTC_API = 'https://api.ftc.gov/v0/dnc-complaints';
/** Risk (owner, 2026-10-27): 3 or more complaints in the window high, 1–2 medium. */
export const ftcRisk = (complaints: number): SpamRisk =>
complaints >= 3 ? 'high' : complaints >= 1 ? 'medium' : 'low';
/** The complaints about `number` (1NXXNXXXXXX) in the last `windowDays`. */
export async function ftcLookup(
db: Db,
number: string,
windowDays: number,
): Promise<{ risk: SpamRisk; info: FtcInfo }> {
const r = await db.execute<{
complaints: number;
robocalls: number;
last: Date | string | null;
subject: string | null;
}>(sql`
select count(*)::int as complaints,
(count(*) filter (where robocall))::int as robocalls,
max(reported_at) as last,
mode() within group (order by subject) as subject
from ftc_complaints
where number = ${number}
and reported_at > now() - make_interval(days => ${windowDays})`);
const row = r.rows[0]!;
const info: FtcInfo = {
complaints: row.complaints,
robocalls: row.robocalls,
lastReportedAt: row.last === null ? null : new Date(row.last).toISOString(),
subject: row.subject,
};
return { risk: ftcRisk(info.complaints), info };
}
const OVER_LIMIT = 'api.data.gov says the key is over its hourly limit';
async function ftcGet(key: string, q: Record<string, string>) {
try {
return await getJson(`${FTC_API}?${new URLSearchParams(q)}`, { 'X-Api-Key': key });
} catch (err) {
const m = (err as Error).message;
if (m === 'HTTP 403') throw new SpamError('api.data.gov refused the key');
if (m === 'HTTP 429') throw new SpamError(OVER_LIMIT);
throw err;
}
}
/** Settings → Spam check → Test: one request with the api.data.gov key. */
export async function ftcTest(key: string): Promise<string> {
await ftcGet(key, { items_per_page: '1' });
return 'The FTC answered.';
}
// ponytail: the FTC's dates have no zone; taken as Eastern (it's in Washington). Hours off at
// worst, against a window of weeks.
const FTC_TZ = 'America/New_York';
const PAGE = 50;
const DATE_TIME = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/;
const nextDay = (day: string) =>
new Date(Date.parse(`${day}T00:00:00Z`) + 86_400_000).toISOString().slice(0, 10);
const daysBefore = (day: string, n: number) =>
new Date(Date.parse(`${day}T00:00:00Z`) - n * 86_400_000).toISOString().slice(0, 10);
/** One complaint as stored, or null for one without a usable US number or date. */
function complaint(c: unknown) {
const { id, attributes: a } = (c ?? {}) as { id?: unknown; attributes?: Record<string, unknown> };
const phone = `1${String(a?.['company-phone-number'] ?? '')}`;
const at = a?.['created-date'];
if (
typeof id !== 'string' ||
!isCheckable(phone) ||
typeof at !== 'string' ||
!DATE_TIME.test(at)
)
return null;
const robo = a?.['recorded-message-or-robocall'];
return {
id,
number: phone,
reportedAt: sql`${at}::timestamp at time zone ${FTC_TZ}`,
subject: typeof a?.subject === 'string' && a.subject ? a.subject : null,
robocall: robo === 'Y' ? true : robo === 'N' ? false : null,
};
}
/**
* Job spamFtcSync (14 §2.2), every minute: when a sync is due or under way, read up to `budget`
* pages (15 a minute is 900 an hour, under api.data.gov's 1,000) a day at a time, oldest first,
* saving where it got to. A failure (a 429 among them) is saved for the Settings tab and the next
* minute carries on from the same page. Returns the error, for the job's log line once.
*/
export async function ftcSync(
db: Db,
opts: { now?: Date; budget?: number } = {},
): Promise<string | null> {
const now = opts.now ?? new Date();
const s = await getSetting(db, 'spam');
if (!s.enabled || !s.ftc) return null;
const st = await getSetting(db, 'spam.state');
let cursor = st.ftcCursor;
if (!cursor) {
const due =
!st.ftcLastSyncAt ||
now.getTime() - Date.parse(st.ftcLastSyncAt) >= s.ftc.syncHours * 3_600_000;
if (!due) return null;
// From the newest complaint kept, less a day for ones posted late; at most the window.
const r = await db.execute<{ day: string | null }>(
sql`select to_char(max(reported_at) at time zone ${FTC_TZ}, 'YYYY-MM-DD') as day from ftc_complaints`,
);
const first = daysBefore(todayIn(FTC_TZ, now), s.ftc.windowDays);
const last = r.rows[0]?.day ? daysBefore(r.rows[0].day, 1) : first;
cursor = { day: last > first ? last : first, offset: 0 };
}
const key = decrypt(s.ftc.keyEnc);
const today = todayIn(FTC_TZ, now);
for (let n = 0; n < (opts.budget ?? 15); n++) {
let page: unknown[];
try {
const j = await ftcGet(key, {
created_date_from: `"${cursor.day} 00:00:00"`,
created_date_to: `"${cursor.day} 23:59:59"`,
sort_order: 'ASC',
items_per_page: String(PAGE),
offset: String(cursor.offset),
});
if (!Array.isArray(j.data)) throw new SpamError('the answer had no complaints list');
page = j.data;
} catch (err) {
const error = err instanceof SpamError ? err.message : 'the sync failed';
await patchSpamState(db, { ftcCursor: cursor, ftcLastError: error });
if (!(err instanceof SpamError)) throw err;
return error === st.ftcLastError ? null : error;
}
const rows = page.map(complaint).filter((c) => c !== null);
if (rows.length) await db.insert(ftcComplaints).values(rows).onConflictDoNothing();
if (page.length === PAGE) cursor = { day: cursor.day, offset: cursor.offset + PAGE };
else if (cursor.day < today) cursor = { day: nextDay(cursor.day), offset: 0 };
else {
await db
.delete(ftcComplaints)
.where(
lt(
ftcComplaints.reportedAt,
sql`${now.toISOString()}::timestamptz - make_interval(days => ${s.ftc.windowDays})`,
),
);
await patchSpamState(db, {
ftcCursor: null,
ftcLastSyncAt: now.toISOString(),
ftcLastError: null,
});
return null;
}
}
await patchSpamState(db, { ftcCursor: cursor, ftcLastError: null });
return null;
}
+26
View File
@@ -0,0 +1,26 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// The spam check's providers over HTTPS (14 §2). Errors carry only these texts: never the URL
// or fetch's own error, which can quote it (IPQualityScore's key is in the path).
/** A failure whose message is safe to log and show. */
export class SpamError extends Error {}
export async function getJson(
url: string,
headers: Record<string, string> = {},
): Promise<Record<string, unknown>> {
let r: Response;
try {
r = await fetch(url, { headers, signal: AbortSignal.timeout(4000) });
} catch (err) {
throw new SpamError(
(err as Error).name === 'TimeoutError' ? 'no answer in 4 s' : "couldn't connect",
);
}
if (!r.ok) throw new SpamError(`HTTP ${r.status}`);
try {
return (await r.json()) as Record<string, unknown>;
} catch {
throw new SpamError('the answer was not JSON');
}
}
+60
View File
@@ -0,0 +1,60 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// IPQualityScore (14 §2.1). Its key goes in the URL's path: see http.ts.
import type { IpqsInfo, SpamRisk } from '@selfphoneng/shared';
import { SpamError, getJson } from './http.ts';
const BASE = 'https://www.ipqualityscore.com/api/json';
export type IpqsAnswer = { kind: 'ok'; risk: SpamRisk; info: IpqsInfo } | { kind: 'exhausted' };
const get = (path: string) => getJson(`${BASE}/${path}`);
const str = (v: unknown) => (typeof v === 'string' && v.trim() && v !== 'N/A' ? v.trim() : null);
const used = (msg: unknown) => typeof msg === 'string' && /insufficient credits/i.test(msg);
/** The provider's own message, cut short and without the key in case it quotes it. */
const said = (msg: unknown, key: string) =>
new SpamError(
typeof msg === 'string' && msg
? msg.replaceAll(key, '***').slice(0, 200)
: 'the request failed',
);
/** Risk (owner, 2026-10-27): IPQualityScore's own advice, 75 suspicious and 85 high risk. */
export function ipqsRisk(a: {
fraudScore: number;
spammer: boolean;
recentAbuse: boolean;
risky: boolean;
}): SpamRisk {
if (a.spammer || a.fraudScore >= 85) return 'high';
if (a.fraudScore >= 75 || a.recentAbuse || a.risky) return 'medium';
return 'low';
}
/** One phone lookup: `number` is 1NXXNXXXXXX. */
export async function ipqsLookup(key: string, number: string): Promise<IpqsAnswer> {
const j = await get(`phone/${encodeURIComponent(key)}/${number}?country[]=US`);
if (j.success !== true) {
if (used(j.message)) return { kind: 'exhausted' };
throw said(j.message, key);
}
const info: IpqsInfo = {
fraudScore: typeof j.fraud_score === 'number' ? j.fraud_score : 0,
spammer: j.spammer === true,
recentAbuse: j.recent_abuse === true,
lineType: str(j.line_type),
carrier: str(j.carrier),
name: str(j.name),
city: str(j.city),
region: str(j.region),
};
return { kind: 'ok', risk: ipqsRisk({ ...info, risky: j.risky === true }), info };
}
/** The Credit Usage API: credits left in this billing period (Test, and 14 §3). */
export async function ipqsCredits(key: string): Promise<number> {
const j = await get(`account/${encodeURIComponent(key)}`);
if (j.success === false) throw said(j.message, key);
const credits = Number(j.credits);
if (!Number.isFinite(credits)) throw new SpamError('the answer had no credit count');
return credits;
}
+27 -8
View File
@@ -1,7 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// A directory entry → its SelfPhoneNG user (12 §4.3): connected before, or by the directory's
// email. Never a new user (owner, 2026-10-25).
import { and, eq } from 'drizzle-orm';
import { and, eq, notExists } from 'drizzle-orm';
import { deleteUserSessions } from '../auth/sessions.ts';
import type { Db } from '../db/client.ts';
import { userIdentities, users } from '../db/schema.ts';
@@ -22,7 +22,21 @@ export async function directoryUserFor(db: Db, e: DirectoryEntry): Promise<UserR
.where(and(eq(userIdentities.kind, 'ldap'), eq(userIdentities.subject, e.id)));
if (byId) return byId.u;
if (!e.email) return null;
const [byEmail] = await db.select().from(users).where(eq(users.email, e.email));
// Nobody with another way in either: an OpenID user's account is theirs to connect (08 §7 #1).
const [byEmail] = await db
.select()
.from(users)
.where(
and(
eq(users.email, e.email),
notExists(
db
.select({ id: userIdentities.id })
.from(userIdentities)
.where(eq(userIdentities.userId, users.id)),
),
),
);
return byEmail && byEmailOk(byEmail) ? byEmail : null;
}
@@ -35,19 +49,27 @@ export async function directoryUserFor(db: Db, e: DirectoryEntry): Promise<UserR
const byEmailOk = (u: UserRow) =>
u.role !== 'admin' && (u.passwordHash === null || u.mustChangePassword);
/** Connect a directory account the person proved (My settings, 12 §4.3). */
/**
* Connect a directory account the person proved (My settings, 12 §4.3). Not another one in place
* of the one they have: the leaver check follows it, so swapping is an admin's job (08 §7 #5).
*/
export async function connectDirectory(
db: Db,
log: { info(o: object, m: string): void },
userId: string,
e: DirectoryEntry,
ip: string,
): Promise<'connected' | 'taken'> {
): Promise<'connected' | 'taken' | 'connected_other'> {
const [owner] = await db
.select({ userId: userIdentities.userId })
.from(userIdentities)
.where(and(eq(userIdentities.kind, 'ldap'), eq(userIdentities.subject, e.id)));
if (owner && owner.userId !== userId) return 'taken';
const [mine] = await db
.select({ subject: userIdentities.subject })
.from(userIdentities)
.where(and(eq(userIdentities.userId, userId), eq(userIdentities.kind, 'ldap')));
if (mine && mine.subject !== e.id) return 'connected_other';
await db.transaction(async (tx) => {
await tx
.delete(userIdentities)
@@ -101,11 +123,8 @@ export async function directorySignIn(
entityId: u.id,
summary: `${u.email} connected directory account ${e.dn}`,
});
} else if (mine.subject !== e.id) {
// Their email matches, but another directory account is already theirs: not this one.
log.info({ userId: u.id }, `directory entry ${e.dn} matches by email but isn't theirs`);
return null;
} else
// Found by this account's id (directoryUserFor never matches by email someone connected).
await db
.update(userIdentities)
.set({ email: e.email, lastUsedAt: new Date() })
+28
View File
@@ -0,0 +1,28 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// A new Asterisk in asterisk/Dockerfile must go through the upgrade check first (verified.ts).
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
import { VERIFIED_ASTERISK, pinnedAsterisk } from './asterisk/verified.ts';
describe('the pinned Asterisk', () => {
it('is the version the upgrade check last passed on', () => {
expect(
pinnedAsterisk(),
`asterisk/Dockerfile pins ${pinnedAsterisk()}, the upgrade check last passed on ` +
`${VERIFIED_ASTERISK}. Build the image, run \`pnpm test:asterisk-upgrade\` and ` +
'`pnpm test:asterisk`, act on what moved, then set VERIFIED_ASTERISK in ' +
'apps/api/test/asterisk/verified.ts',
).toBe(VERIFIED_ASTERISK);
});
it('is the one spec 01 §2 names, and its copy of the Dockerfile is the file', () => {
const spec = readFileSync(
new URL('../../../docs/spec/01-infrastructure.md', import.meta.url),
'utf8',
);
expect(spec).toContain(`**Pinned: \`${pinnedAsterisk()}\``);
const copy = /^## 2\. [\s\S]*?^```dockerfile\n([\s\S]*?)^```$/m.exec(spec)?.[1];
const file = readFileSync(new URL('../../../asterisk/Dockerfile', import.meta.url), 'utf8');
expect(copy, 'spec 01 §2 has a copy of asterisk/Dockerfile: keep it the same').toBe(file);
});
});
+3 -5
View File
@@ -13,6 +13,7 @@ import { SettingsSchemas, type SettingKey } from '@selfphoneng/shared';
import { AmiClient } from '../../src/ami/client.ts';
import { GENERATED_FILES, render } from '../../src/generator/index.ts';
import type { Snapshot } from '../../src/generator/load.ts';
import { testManagerConf } from './manager-conf.ts';
const IMAGE = process.env.ASTERISK_IMAGE ?? 'selfphoneng-asterisk';
const CALL_SECONDS = Number(process.env.CALL_SECONDS ?? 30);
@@ -126,11 +127,8 @@ async function main() {
spawnSync('mkdir', ['-p', conf]);
const files = render(snapshot());
for (const f of GENERATED_FILES) writeFileSync(join(conf, f), files[f]);
// Test-only manager.conf: reachable through the published port (the real one is 127.0.0.1-only).
writeFileSync(
join(dir, 'manager.conf.template'),
'[general]\nenabled = yes\nport = 5038\nbindaddr = 0.0.0.0\n\n[selfphoneng]\nsecret = ${AMI_SECRET}\npermit = 0.0.0.0/0.0.0.0\nread = all\nwrite = all\n',
);
// The real manager.conf, reachable through the published port (test/asterisk/manager-conf.ts).
writeFileSync(join(dir, 'manager.conf.template'), testManagerConf());
phoneConfig(dir, '101', 5070, 440);
phoneConfig(dir, '102', 5072, 880);
+2 -4
View File
@@ -18,6 +18,7 @@ import type { AmiMessage } from '../../src/ami/events.ts';
import { GENERATED_FILES, render } from '../../src/generator/index.ts';
import type { Snapshot, SnapExtension } from '../../src/generator/load.ts';
import { providerDialplan, providerPjsip } from './provider.ts';
import { testManagerConf } from './manager-conf.ts';
const IMAGE = process.env.ASTERISK_IMAGE ?? 'selfphoneng-asterisk';
const OUT = fileURLToPath(new URL('../fixtures/cdr/', import.meta.url));
@@ -243,10 +244,7 @@ async function main() {
);
}
const runPhones = phoneFiles(dir);
writeFileSync(
join(dir, 'manager.conf.template'),
'[general]\nenabled = yes\nport = 5038\nbindaddr = 0.0.0.0\n\n[selfphoneng]\nsecret = ${AMI_SECRET}\npermit = 0.0.0.0/0.0.0.0\nread = all\nwrite = all\n',
);
writeFileSync(join(dir, 'manager.conf.template'), testManagerConf());
const ami = new AmiClient({
host: '127.0.0.1',
port: AMI_PORT,
+2 -4
View File
@@ -17,6 +17,7 @@ import { AmiClient } from '../../src/ami/client.ts';
import { GENERATED_FILES, render } from '../../src/generator/index.ts';
import type { Snapshot } from '../../src/generator/load.ts';
import { providerPjsip } from './provider.ts';
import { testManagerConf } from './manager-conf.ts';
const IMAGE = process.env.ASTERISK_IMAGE ?? 'selfphoneng-asterisk';
const NET = 'sp-cnam';
@@ -223,10 +224,7 @@ async function main() {
);
}
phoneFiles(dir);
writeFileSync(
join(dir, 'manager.conf.template'),
'[general]\nenabled = yes\nport = 5038\nbindaddr = 0.0.0.0\n\n[selfphoneng]\nsecret = ${AMI_SECRET}\npermit = 0.0.0.0/0.0.0.0\nread = all\nwrite = all\n',
);
writeFileSync(join(dir, 'manager.conf.template'), testManagerConf());
const problems: string[] = [];
const ami = new AmiClient({
host: '127.0.0.1',
+18
View File
@@ -0,0 +1,18 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// The real manager.conf.template, read/write classes included, so a class the api needs and the
// template lacks fails the tests. Only opened up for the host to reach AMI through the published
// port: the real one is 127.0.0.1-only.
import { readFileSync } from 'node:fs';
export function testManagerConf(): string {
const real = readFileSync(
new URL('../../../../asterisk/conf/manager.conf.template', import.meta.url),
'utf8',
);
const open = real
.replace(/^bindaddr = 127\.0\.0\.1$/m, 'bindaddr = 0.0.0.0')
.replace(/^permit = 127\.0\.0\.1\/255\.255\.255\.255$/m, 'permit = 0.0.0.0/0.0.0.0');
if (open.includes('127.0.0.1'))
throw new Error('manager.conf.template changed: update manager-conf.ts');
return open;
}
+2 -4
View File
@@ -17,6 +17,7 @@ import { GENERATED_FILES, render } from '../../src/generator/index.ts';
import type { Snapshot } from '../../src/generator/load.ts';
import { providerDialplan, providerPjsip } from './provider.ts';
import { testManagerConf } from './manager-conf.ts';
const IMAGE = process.env.ASTERISK_IMAGE ?? 'selfphoneng-asterisk';
const RECORD = process.argv.includes('--record')
@@ -200,10 +201,7 @@ async function main() {
);
}
phoneFiles(dir);
writeFileSync(
join(dir, 'manager.conf.template'),
'[general]\nenabled = yes\nport = 5038\nbindaddr = 0.0.0.0\n\n[selfphoneng]\nsecret = ${AMI_SECRET}\npermit = 0.0.0.0/0.0.0.0\nread = all\nwrite = all\n',
);
writeFileSync(join(dir, 'manager.conf.template'), testManagerConf());
const problems: string[] = [];
let recorder: Socket | undefined;
+309
View File
@@ -0,0 +1,309 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Asterisk upgrade check (01 §2): boot the pinned image and measure each behaviour in verified.ts,
// then list every one that moved and what depends on it. A second Asterisk plays a provider on
// TLS with a certificate from our own CA for "provider.test", so the qualify check sees a real
// host name. Needs Docker and the image built from asterisk/Dockerfile (ASTERISK_IMAGE, default
// "selfphoneng-asterisk").
// Usage: pnpm test:asterisk-upgrade
import { spawnSync } from 'node:child_process';
import { cpSync, mkdtempSync, readdirSync, rmSync, writeFileSync, appendFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { AmiClient } from '../../src/ami/client.ts';
import { field, type AmiMessage } from '../../src/ami/events.ts';
import { FACTS, VERIFIED_ASTERISK, pinnedAsterisk, type Fact } from './verified.ts';
import { testManagerConf } from './manager-conf.ts';
const IMAGE = process.env.ASTERISK_IMAGE ?? 'selfphoneng-asterisk';
const GOLDEN = fileURLToPath(
new URL('../../src/generator/__golden__/one-user/expected', import.meta.url),
);
const NET = 'sp-upgrade';
const PBX = 'sp-upgrade-pbx';
const PROVIDER = 'sp-upgrade-provider';
const HOST = 'provider.test';
const AMI_PORT = 15040;
const AMI_SECRET = 'upgrade-test-secret';
const SUB = { user: 'upgrade', pass: 'UpgradeSecret42' };
const RISKY = ['app_system', 'func_shell', 'app_disa', 'res_agi', 'app_externalivr'];
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
const docker = (...args: string[]) => {
const r = spawnSync('docker', args, { encoding: 'utf8' });
return { ok: r.status === 0, out: `${r.stdout ?? ''}${r.stderr ?? ''}` };
};
const log = (...a: unknown[]) => console.log('[upgrade-test]', ...a);
async function waitFor(what: string, fn: () => Promise<boolean>, seconds = 60) {
for (let i = 0; i < seconds; i++) {
if (await fn()) return;
await sleep(1000);
}
throw new Error(`timed out waiting for ${what}`);
}
/** A CA, and a certificate from it for HOST: what the PBX trusts and the provider shows. */
function certificates(dir: string) {
const r = docker(
'run',
'--rm',
'-v',
`${dir}:/out`,
'--entrypoint',
'sh',
IMAGE,
'-c',
[
'cd /out',
'openssl req -x509 -newkey rsa:2048 -nodes -days 1 -subj /CN=sp-upgrade-ca -keyout ca.key -out ca.pem',
`openssl req -newkey rsa:2048 -nodes -subj /CN=${HOST} -keyout privkey.pem -out host.csr`,
`printf 'subjectAltName=DNS:${HOST}' > host.ext`,
'openssl x509 -req -in host.csr -CA ca.pem -CAkey ca.key -CAcreateserial -days 1 -extfile host.ext -out fullchain.pem',
'chmod 644 *',
].join(' && '),
);
if (!r.ok) throw new Error(`certificates: ${r.out}`);
}
const tlsTransport = (name: string, port: number, client: boolean) => `
[${name}]
type = transport
protocol = tls
bind = 0.0.0.0:${port}
cert_file = /tls/fullchain.pem
priv_key_file = /tls/privkey.pem
method = tlsv1_2
${client ? 'verify_server = yes\nca_list_file = /tls/ca.pem\n' : ''}`;
/** The provider: TLS only, one account that may register. */
const providerPjsip = `${tlsTransport('transport-tls', 5061, false)}
[${SUB.user}]
type = endpoint
context = default
disallow = all
allow = ulaw
auth = ${SUB.user}-auth
aors = ${SUB.user}
[${SUB.user}-auth]
type = auth
auth_type = userpass
username = ${SUB.user}
password = ${SUB.pass}
[${SUB.user}]
type = aor
max_contacts = 1
remove_existing = yes
`;
/**
* Added to the one-user golden config: a trunk to the provider by name over TLS. The registration
* is the control (REGISTER verifies the name on every version we know); the AOR's qualify is the
* fact being measured.
*/
const pbxPjsip = `${tlsTransport('sp-upgrade-tls', 5062, true)}
[sp-upgrade-auth]
type = auth
auth_type = userpass
username = ${SUB.user}
password = ${SUB.pass}
[sp-upgrade-reg]
type = registration
transport = sp-upgrade-tls
outbound_auth = sp-upgrade-auth
server_uri = sip:${HOST}:5061;transport=tls
client_uri = sip:${SUB.user}@${HOST}:5061;transport=tls
contact_user = ${SUB.user}
retry_interval = 5
[sp-upgrade-trunk]
type = aor
contact = sip:${HOST}:5061;transport=tls
qualify_frequency = 5
[sp-upgrade-trunk]
type = endpoint
transport = sp-upgrade-tls
outbound_auth = sp-upgrade-auth
aors = sp-upgrade-trunk
context = sp-upgrade-test
`;
const pbxDialplan = `
[sp-upgrade-test]
exten => s,1,Answer()
same => n,Hangup()
`;
async function main() {
const pinned = pinnedAsterisk();
const tls = mkdtempSync(join(tmpdir(), 'sp-upgrade-tls-'));
const pbxConf = mkdtempSync(join(tmpdir(), 'sp-upgrade-pbx-'));
const provConf = mkdtempSync(join(tmpdir(), 'sp-upgrade-prov-'));
cpSync(GOLDEN, pbxConf, { recursive: true });
appendFileSync(join(pbxConf, 'pjsip_selfphoneng.conf'), pbxPjsip);
appendFileSync(join(pbxConf, 'extensions_selfphoneng.conf'), pbxDialplan);
for (const f of readdirSync(GOLDEN))
writeFileSync(join(provConf, f), f === 'pjsip_selfphoneng.conf' ? providerPjsip : '');
writeFileSync(join(pbxConf, 'manager.conf.template'), testManagerConf());
const ami = new AmiClient({
host: '127.0.0.1',
port: AMI_PORT,
username: 'selfphoneng',
secret: AMI_SECRET,
logger: { info() {}, warn() {}, error() {}, debug() {} },
backoffMs: [500],
});
const asterisk = (name: string, conf: string, extra: string[]) =>
docker(
'run',
'-d',
'--rm',
'--name',
name,
'--network',
NET,
...extra,
'-e',
`AMI_SECRET=${AMI_SECRET}`,
'-v',
`${conf}:/etc/asterisk/selfphoneng`,
'-v',
`${tls}:/tls:ro`,
IMAGE,
);
const booted = (name: string) =>
waitFor(
`${name} to boot`,
async () => docker('exec', name, 'asterisk', '-rx', 'core waitfullybooted').ok,
);
const seen = {} as Record<Fact, boolean>;
const problems: string[] = [];
try {
docker('rm', '-f', PBX, PROVIDER);
docker('network', 'create', NET);
certificates(tls);
const prov = asterisk(PROVIDER, provConf, ['--network-alias', HOST]);
if (!prov.ok) throw new Error(`provider: ${prov.out}`);
await booted(PROVIDER);
const pbx = asterisk(PBX, pbxConf, [
'-p',
`127.0.0.1:${AMI_PORT}:5038`,
'-v',
`${join(pbxConf, 'manager.conf.template')}:/etc/asterisk/manager.conf.template:ro`,
]);
if (!pbx.ok) throw new Error(`pbx: ${pbx.out}`);
await booted(PBX);
const running = docker('exec', PBX, 'asterisk', '-V').out.trim();
if (running !== `Asterisk ${pinned}`)
throw new Error(
`the image runs "${running}" but asterisk/Dockerfile pins ${pinned}: rebuild it ` +
`(docker build -t ${IMAGE} asterisk)`,
);
log(`${running}, last verified on ${VERIFIED_ASTERISK}`);
ami.start();
await new Promise((r) => ami.once('connected', r));
const cli = (cmd: string) => ami.command(cmd);
const modules = await Promise.all(RISKY.map((m) => cli(`module show like ${m}`)));
seen.riskyModulesGone = modules.every((o) => /\b0 modules loaded/.test(o));
const ep101 = await cli('pjsip show endpoint 101');
seen.mwiSubscribeReplaces = /mwi_subscribe_replaces_unsolicited\s*:\s*yes/.test(ep101);
// set_var lines show as their own rows: " __SP_DIRECTION : internal".
seen.setVarKeepsUnderscores = /^\s*__SP_DIRECTION\s*:\s*internal\s*$/m.test(ep101);
const commands = await cli('manager show commands');
seen.voicemailMoveRemove =
/\bVoicemailMove\b/.test(commands) && /\bVoicemailRemove\b/.test(commands);
// CDR: one Local call through the test context.
const cdr = new Promise<AmiMessage>((r) => ami.once('event:Cdr', r));
await cli('channel originate Local/s@sp-upgrade-test application Wait 1');
const m = await Promise.race([
cdr,
sleep(15_000).then(() => {
throw new Error('no Cdr event within 15 s of a call');
}),
]);
seen.cdrLinkedId = !!field(m, 'LinkedID');
const start = field(m, 'StartTime') ?? '';
seen.cdrTimesUtcNoZone =
/^\d{4}-\d\d-\d\d \d\d:\d\d:\d\d$/.test(start) &&
Math.abs(Date.parse(`${start.replace(' ', 'T')}Z`) - Date.now()) < 120_000;
// TLS: the registration must work first, or the qualify answer says nothing.
await waitFor(
'the TLS registration to the provider (the control)',
async () => /sp-upgrade-reg\S*\s.*Registered/.test(await cli('pjsip show registrations')),
45,
).catch(async (e: Error) => {
throw new Error(`${e.message}:\n${await cli('pjsip show registrations')}`);
});
let contacts = '';
for (let i = 0; i < 20; i++) {
contacts = await cli('pjsip show contacts');
if (/sp-upgrade-trunk\/\S+\s.*\b(Avail|Unavail)/.test(contacts)) break;
await sleep(1000);
}
seen.tlsQualifyByHostname = /sp-upgrade-trunk\/\S+\s.*\bAvail\b/.test(contacts);
if (!seen.tlsQualifyByHostname) {
const why = docker('logs', PBX)
// eslint-disable-next-line no-control-regex -- strips Asterisk's ANSI colors
.out.replace(/\x1b\[[0-9;]*m/g, '')
.split('\n');
const line = contacts.split('\n').find((l) => l.includes('sp-upgrade-trunk'));
log('qualify over TLS failed:', line);
for (const l of why
.filter((x) => /sp-upgrade-trunk|identity|EUNSUP|qualif/i.test(x))
.slice(-4))
log(l.trim());
}
const listed = await ami.actionList({ Action: 'PJSIPShowContacts' });
seen.staticContactsInShowContacts = listed.some((x) => x.Aor === 'sp-upgrade-trunk');
const detail = await ami.actionList({
Action: 'PJSIPShowEndpoint',
Endpoint: 'sp-upgrade-trunk',
});
seen.endpointShowsContactStatus = detail.some(
(x) => x.Event === 'ContactStatusDetail' && x.AOR === 'sp-upgrade-trunk',
);
} catch (e) {
problems.push((e as Error).message);
} finally {
ami.stop();
docker('rm', '-f', PBX, PROVIDER);
docker('network', 'rm', NET);
for (const d of [tls, pbxConf, provConf]) rmSync(d, { recursive: true, force: true });
}
const moved = (Object.keys(FACTS) as Fact[]).filter(
(k) => k in seen && seen[k] !== FACTS[k].expect,
);
for (const k of Object.keys(FACTS) as Fact[])
console.log(
`${!(k in seen) ? '? ' : moved.includes(k) ? 'MOVED' : 'ok '} ${k}: ${k in seen ? seen[k] : 'not measured'}`,
);
for (const k of moved)
problems.push(
`${k} was ${FACTS[k].expect} on ${VERIFIED_ASTERISK}, is ${seen[k]} on ${pinned}: ` +
`${FACTS[k].what}. Depends on it: ${FACTS[k].where}`,
);
if (problems.length) {
console.log('FAIL asterisk upgrade');
for (const p of problems) console.log(` ${p}`);
process.exit(1);
}
console.log(
pinned === VERIFIED_ASTERISK
? `ok asterisk ${pinned}: every fact as recorded`
: `ok asterisk ${pinned}: nothing moved since ${VERIFIED_ASTERISK}; set VERIFIED_ASTERISK in verified.ts`,
);
}
await main();
+68
View File
@@ -0,0 +1,68 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// What the code relies on Asterisk doing, as last measured by upgrade-test.ts, and on which
// version. asterisk-version.test.ts fails when asterisk/Dockerfile pins another version: run
// `pnpm test:asterisk-upgrade` on the new image, act on each change it lists, then update this.
import { readFileSync } from 'node:fs';
export const VERIFIED_ASTERISK = '24.0.0-rc3';
/** The version asterisk/Dockerfile builds. */
export function pinnedAsterisk(): string {
const file = readFileSync(new URL('../../../../asterisk/Dockerfile', import.meta.url), 'utf8');
const v = /^ARG ASTERISK_VERSION=(\S+)$/m.exec(file)?.[1];
if (!v) throw new Error('asterisk/Dockerfile has no ARG ASTERISK_VERSION');
return v;
}
/** `expect`: the answer on VERIFIED_ASTERISK. `where`: what depends on it, to change if it moves. */
export const FACTS = {
riskyModulesGone: {
expect: true,
what: 'app_system, func_shell, app_disa, res_agi and app_externalivr are not loaded',
where: 'asterisk/conf/modules.conf noload lines (08 §6): a renamed module would load again',
},
mwiSubscribeReplaces: {
expect: true,
what: 'endpoints take mwi_subscribe_replaces_unsolicited',
where: 'generator/pjsip.ts: without it a phone that SUBSCRIBEs for MWI gets a 500 (03 §5.2)',
},
setVarKeepsUnderscores: {
expect: true,
what: 'set_var = __SP_DIRECTION=… loads as "__SP_DIRECTION"',
where: 'generator/pjsip.ts set_var lines; dialplan reads SP_DIRECTION on dialed legs (03 §5.2)',
},
staticContactsInShowContacts: {
expect: false,
what: 'PJSIPShowContacts lists static (contact =) AOR contacts',
where: 'live/state.ts reads trunk reachability from PJSIPShowEndpoint instead (04 §5.1)',
},
endpointShowsContactStatus: {
expect: true,
what: 'PJSIPShowEndpoint has a ContactStatusDetail for a static contact',
where: 'live/state.ts trunk reachability (04 §5.1)',
},
voicemailMoveRemove: {
expect: true,
what: 'the AMI actions VoicemailMove and VoicemailRemove exist',
where: 'services/voicemail.ts mark read and delete (04 §5.6)',
},
cdrLinkedId: {
expect: true,
what: 'Cdr AMI events carry LinkedID (cdr_manager.conf mapping)',
where: 'services/cdr.ts groups a call by it (01 §3.15, 04 §5.4)',
},
cdrTimesUtcNoZone: {
expect: true,
what: 'Cdr StartTime is "YYYY-MM-DD HH:MM:SS" in UTC, no zone',
where: 'services/cdr.ts utc() adds the Z itself',
},
tlsQualifyByHostname: {
expect: true,
what: 'a qualify OPTIONS over TLS checks the certificate against the host name, not the IP',
where:
'generator/pjsip.ts qualifies TLS trunks and Test sends PJSIPQualify (08 §2.1). False on ' +
'24.0.0-rc2: a TLS trunk then showed as unreachable, so it had qualify_frequency = 0',
},
} as const;
export type Fact = keyof typeof FACTS;
+2 -4
View File
@@ -11,6 +11,7 @@ import { SettingsSchemas, type SettingKey } from '@selfphoneng/shared';
import { AmiClient } from '../../src/ami/client.ts';
import { GENERATED_FILES, render } from '../../src/generator/index.ts';
import type { Snapshot } from '../../src/generator/load.ts';
import { testManagerConf } from './manager-conf.ts';
const IMAGE = process.env.ASTERISK_IMAGE ?? 'selfphoneng-asterisk';
const NET = 'sp-vm';
@@ -111,10 +112,7 @@ async function main() {
spawnSync('mkdir', ['-p', conf]);
const files = render(snapshot());
for (const f of GENERATED_FILES) writeFileSync(join(conf, f), files[f]);
writeFileSync(
join(dir, 'manager.conf.template'),
'[general]\nenabled = yes\nport = 5038\nbindaddr = 0.0.0.0\n\n[selfphoneng]\nsecret = ${AMI_SECRET}\npermit = 0.0.0.0/0.0.0.0\nread = all\nwrite = all\n',
);
writeFileSync(join(dir, 'manager.conf.template'), testManagerConf());
phoneConfig(dir, '101', 5070);
phoneConfig(dir, '102', 5072);
+69 -4
View File
@@ -1,25 +1,32 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// M6-T8: /api/blocked-numbers and the AstDB mirror (04 §5.5).
// M6-T8: /api/blocked-numbers and the AstDB mirror (04 §5.5); users' Report spam (05 §4).
import type { LightMyRequestResponse } from 'fastify';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { callExtensions, calls, extensions } from '../src/db/schema.ts';
import { encrypt } from '../src/crypto/secrets.ts';
import { syncAstDb } from '../src/services/astdb.ts';
import { hasTestDb } from './db.ts';
import { ADMIN, H, createTestApp } from './app.ts';
describe.skipIf(!hasTestDb)('/api/blocked-numbers', () => {
let t: Awaited<ReturnType<typeof createTestApp>>;
let req: (method: string, url: string, payload?: unknown) => Promise<LightMyRequestResponse>;
let req: (
method: string,
url: string,
payload?: unknown,
as?: string,
) => Promise<LightMyRequestResponse>;
beforeAll(async () => {
t = await createTestApp();
await t.clearMustChange(ADMIN.email);
const cookie = await t.login(ADMIN.email, ADMIN.password);
req = (method, url, payload) =>
req = (method, url, payload, as = cookie) =>
t.app.inject({
method: method as 'GET',
url,
payload: payload as object,
headers: { ...H, cookie },
headers: { ...H, cookie: as },
});
});
afterAll(() => t?.close());
@@ -59,4 +66,62 @@ describe.skipIf(!hasTestDb)('/api/blocked-numbers', () => {
});
expect((await req('GET', '/api/blocked-numbers')).json().items).toEqual([]);
});
it('Report spam: only on an outside call that rang me; Block clears it, Dismiss drops it', async () => {
const sam = await t.addUser('sam@example.com', 'sam-password-123');
await t.addUser('pat@example.com', 'pat-password-123');
const [ext] = await t.db
.insert(extensions)
.values({
number: '103',
name: 'Sam',
sipPasswordEnc: encrypt('x'),
voicemailPinEnc: encrypt('4826'),
userId: sam.id,
})
.returning();
const call = async (linkedid: string, from: string | null, role: string) => {
const [c] = await t.db
.insert(calls)
.values({
linkedid,
direction: 'inbound',
status: 'answered',
fromNumber: from,
startedAt: new Date(),
endedAt: new Date(),
})
.returning();
await t.db.insert(callExtensions).values({ callId: c!.id, extensionId: ext!.id, role });
return c!.id;
};
const robo = await call('r1', '13125550142', 'rang');
const hidden = await call('r2', null, 'answered');
const samC = await t.login('sam@example.com', 'sam-password-123');
const patC = await t.login('pat@example.com', 'pat-password-123');
const report = (callId: string, as: string, reason = 'Car warranty robocall') =>
req('POST', '/api/me/spam-reports', { callId, reason }, as);
expect((await report(robo, patC)).statusCode).toBe(404); // not a call that rang Pat
expect((await report(robo, samC, ' ')).statusCode).toBe(400); // no reason
expect((await report(hidden, samC)).statusCode).toBe(400); // no number to block
expect((await report(robo, samC)).statusCode).toBe(201);
expect((await report(robo, samC)).statusCode).toBe(409); // once each
expect((await req('GET', '/api/spam-reports', undefined, samC)).statusCode).toBe(403);
const [r] = (await req('GET', '/api/spam-reports')).json().items;
expect(r).toMatchObject({
number: '13125550142',
reason: 'Car warranty robocall',
reportedBy: 'sam@example.com',
});
expect((await req('DELETE', `/api/spam-reports/${r.id}`)).statusCode).toBe(200);
expect((await req('GET', '/api/spam-reports')).json().items).toEqual([]);
expect((await report(robo, samC)).statusCode).toBe(201); // dismissed: may report again
const b = await req('POST', '/api/blocked-numbers', { number: r.number, reason: r.reason });
expect(b.statusCode).toBe(201);
expect((await req('GET', '/api/spam-reports')).json().items).toEqual([]);
expect((await report(robo, samC)).statusCode).toBe(409); // already blocked
});
});
+39
View File
@@ -181,4 +181,43 @@ describe.skipIf(!hasTestDb)('call report email', () => {
await run('2026-10-23T13:06:00Z');
expect(t.mails[1]!.text).not.toContain('quote');
});
it('no recording or summary of a call that only rang my phone (08 §7 #3)', async () => {
await dan('PATCH', '/api/me', { callReportSummaries: true });
const [rec] = await t.db
.insert(recordings)
.values({
linkedid: 'r4',
filePath: '2026/10/24/r4.wav',
startedAt: new Date('2026-10-24T15:00:00Z'),
summary: 'Secret pricing for Acme',
aiDoneAt: new Date(),
})
.returning();
const [c] = await t.db
.insert(calls)
.values({
linkedid: 'c4', // the ring group rang Dan; a coworker answered and talked
direction: 'inbound',
status: 'answered',
startedAt: new Date('2026-10-24T15:00:00Z'),
endedAt: new Date('2026-10-24T15:05:00Z'),
fromNumber: '13125557777',
talkSeconds: 300,
recordingId: rec!.id,
})
.returning();
await t.db.insert(callExtensions).values({ callId: c!.id, extensionId: extId, role: 'rang' });
await t.db
.update(extensions)
.set({ callReportSentAt: new Date('2026-10-24T13:00:00Z') })
.where(eq(extensions.id, extId));
t.mails.length = 0;
expect(await run('2026-10-25T13:06:00Z')).toBe(1);
const text = t.mails[0]!.text;
expect(text).toContain('Incoming from (312) 555-7777');
expect(text).not.toContain('Secret pricing');
expect(text).not.toContain('recorded');
expect(text).not.toContain('Summary not ready');
});
});
+44
View File
@@ -460,6 +460,19 @@ describe.skipIf(!hasTestDb)('contacts in Contrack', () => {
fake.state.readOnly.clear();
expect(await run()).toBe(0);
expect(jane).toHaveLength(8);
// Only rang Dan (a coworker took it): on his timeline, but not its recording or summary.
const [k12] = await t.db
.insert(calls)
.values(call('k12', 1.5, { recordingId: done!.id }))
.returning();
await t.db
.insert(callExtensions)
.values({ callId: k12!.id, extensionId: ext!.id, role: 'rang' });
forgetBook(danId);
expect(await run()).toBe(1);
expect(jane.at(-1)!.content).not.toMatch(/recorded|Acme/);
expect(jane.at(-1)).not.toHaveProperty('actionItem');
});
it('contacts from before Contrack are copied once, the builtin ones kept (C6)', async () => {
@@ -521,4 +534,35 @@ describe.skipIf(!hasTestDb)('contacts in Contrack', () => {
]);
expect((await dan('GET', '/api/me')).json().contrack).toBeNull();
});
it('the saved tokens only go to the address they were saved for (08 §7 #7)', async () => {
// Another machine on the network that answers like Contrack, and notes what it's sent.
const heard: string[] = [];
const other = createServer((req, res) => {
heard.push(String(req.headers.authorization ?? ''));
res.setHeader('content-type', 'application/json');
res.end('[]');
});
await new Promise<void>((ok) => other.listen(0, '127.0.0.1', ok));
const otherUrl = `http://127.0.0.1:${(other.address() as AddressInfo).port}`;
try {
await admin('POST', '/api/contacts/contrack/test', { url: otherUrl });
expect(heard.join(' ')).not.toContain('ctk_company');
// A new address needs its token typed again…
const kept = await admin('PATCH', '/api/settings', {
'contacts.contrack': { url: otherUrl },
});
expect(kept.statusCode).toBe(400);
expect(kept.json().error.message).toMatch(/token again/);
// …and people's own tokens are for the old one: they connect again.
const moved = await admin('PATCH', '/api/settings', {
'contacts.contrack': { url: otherUrl, companyToken: 'ctk_new_place' },
});
expect(moved.statusCode, moved.body).toBe(200);
const [d] = await t.db.select().from(users).where(eq(users.id, danId));
expect(d!.contrackTokenEnc).toBeNull();
} finally {
other.close();
}
});
});
+51
View File
@@ -154,4 +154,55 @@ describe.skipIf(!hasTestDb)('diagnostics', () => {
401,
);
});
it('the probe key: made here, shown once, sent to the probe, never in settings (08 §7 #9)', async () => {
const cookie = await t.login(ADMIN.email, ADMIN.password);
const as = (method: string, url: string, payload?: object) =>
t.app.inject({ method: method as 'GET', url, payload, headers: { ...H, cookie } });
await setSetting(t.db, 'network.probeUrl', 'https://probe.test');
// No key yet: the inside checks say to make one.
expect(byId(await insideChecks(deps)).probe).toMatchObject({ status: 'warn' });
const g = await as('POST', '/api/settings/network/probe-key');
expect(g.statusCode, g.body).toBe(200);
const { key } = g.json() as { key: string };
expect(key).toMatch(/^[A-Za-z0-9_-]{43}$/);
const s = await as('GET', '/api/settings');
expect(s.json()['network.hasProbeKey']).toBe(true);
expect(s.body).not.toContain(key);
expect(s.json()).not.toHaveProperty('network.probeKeyEnc');
expect((await as('PATCH', '/api/settings', { 'network.probeKeyEnc': 'x' })).statusCode).toBe(
400,
);
expect(byId(await insideChecks(deps)).probe).toMatchObject({ status: 'ok' });
let auth: string | null = null;
await networkTest(
{
...deps,
fetchProbe: (async (_u: URL, init: RequestInit) => {
auth = new Headers(init.headers).get('authorization');
return new Response(JSON.stringify({ ip: '203.0.113.10', results: [] }));
}) as unknown as typeof fetch,
},
'quick',
);
expect(auth).toBe(`Bearer ${key}`);
// The probe refusing it says what to do.
const refused = await networkTest(
{
...deps,
fetchProbe: (async () =>
new Response(JSON.stringify({ error: 'a key is needed' }), {
status: 401,
})) as unknown as typeof fetch,
},
'quick',
);
expect(byId(refused.checks).probe!.fix).toContain('PROBE_KEY');
// Another address: the key was for the old probe, so it's gone.
await as('PATCH', '/api/settings', { 'network.probeUrl': 'https://other-probe.test' });
expect((await as('GET', '/api/settings')).json()['network.hasProbeKey']).toBe(false);
});
});
+35 -2
View File
@@ -1,12 +1,13 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// 12 §4.3: who a directory entry may connect to by email, who may disconnect it (§4.5), and a
// directory that's down counting as a failed sign-in (§4.2).
// directory that's down counting as a failed sign-in (§4.2). 08 §7: no OpenID user by email, no
// swapping the connected account.
import { eq } from 'drizzle-orm';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { resetLoginLimiter } from '../src/auth/login-limiter.ts';
import { userIdentities, users } from '../src/db/schema.ts';
import { setSetting } from '../src/services/settings.ts';
import { directorySignIn } from '../src/sso/directory.ts';
import { connectDirectory, directorySignIn } from '../src/sso/directory.ts';
import type { DirectoryEntry } from '../src/sso/ldap.ts';
import { hasTestDb } from './db.ts';
import { ADMIN, H, createTestApp } from './app.ts';
@@ -52,6 +53,38 @@ describe.skipIf(!hasTestDb)('directory accounts by email (12 §4.3)', () => {
}
});
it('never connects by email someone who signs in with OpenID (08 §7 #1)', async () => {
// A directory that lets people edit their own mail: Mallory's entry, the victim's email.
const victim = await t.addUser('oidc-only@example.com', 'unused-pw-2');
await t.db.update(users).set({ passwordHash: null }).where(eq(users.id, victim.id));
await t.db
.insert(userIdentities)
.values({ userId: victim.id, kind: 'oidc', subject: 'https://idp victim-sub' });
expect(await directorySignIn(t.db, log, entry(victim.email), '127.0.0.1')).toBeNull();
expect(await connected(victim.id)).toBe(1); // the OpenID account only
});
it('a connected directory account is only swapped by an admin (08 §7 #5)', async () => {
const u = await t.addUser('swapper@example.com', 'swapper-password-1');
expect(await connectDirectory(t.db, log, u.id, entry(u.email), '127.0.0.1')).toBe('connected');
const [first] = await t.db.select().from(userIdentities).where(eq(userIdentities.userId, u.id));
// The same account again is fine; another one (a shared mailbox) is not.
expect(
await connectDirectory(
t.db,
log,
u.id,
{ ...entry(u.email), id: first!.subject },
'127.0.0.1',
),
).toBe('connected');
expect(await connectDirectory(t.db, log, u.id, entry('shared@example.com'), '127.0.0.1')).toBe(
'connected_other',
);
const now = await t.db.select().from(userIdentities).where(eq(userIdentities.userId, u.id));
expect(now.map((i) => i.subject)).toEqual([first!.subject]);
});
it('only an admin can disconnect a directory account (a leaver must stay checked)', async () => {
const u = await t.addUser('leaver@example.com', 'leaver-password-1');
await t.db.insert(userIdentities).values({ userId: u.id, kind: 'ldap', subject: 'leaver-id' });
+231
View File
@@ -0,0 +1,231 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// M11-T10: fraud alerts (08 §8.3): risky calls, voip.ms calls the PBX didn't make, balance drop.
import { createServer } from 'node:http';
import type { AddressInfo } from 'node:net';
import type { LightMyRequestResponse } from 'fastify';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { calls, cdr, dids } from '../src/db/schema.ts';
import { createFraudWatch, riskyDestination, trunkDialNumber } from '../src/services/fraud.ts';
import { setSetting } from '../src/services/settings.ts';
import { hasTestDb } from './db.ts';
import { ADMIN, H, createTestApp } from './app.ts';
describe('riskyDestination', () => {
it('flags international, premium and NANP numbers billed like abroad', () => {
expect(riskyDestination('01144207946095')).toBe('international');
expect(riskyDestination('19005551234')).toBe('premium');
expect(riskyDestination('13129765555')).toBe('premium'); // exchange 976
expect(riskyDestination('18765551234')).toMatch(/international rates/); // Jamaica
expect(riskyDestination('8095551234')).toMatch(/international rates/); // 10 digits
expect(riskyDestination('13125551234')).toBeNull();
expect(riskyDestination('18005551234')).toBeNull();
expect(riskyDestination('101')).toBeNull();
});
it('reads the number out of a trunk Dial', () => {
expect(trunkDialNumber('PJSIP/13125550000@trunk-7d3e9a52,120')).toBe('13125550000');
expect(trunkDialNumber('PJSIP/911@trunk-7d3e9a52')).toBe('911');
expect(trunkDialNumber('PJSIP/101,30')).toBeNull();
expect(trunkDialNumber(null)).toBeNull();
});
});
describe.skipIf(!hasTestDb)('fraud watches', () => {
const at = (iso: string) => new Date(iso);
let clock = Date.parse('2026-10-29T12:00:00Z');
let balance = '100.00';
const cdrs: Record<string, unknown>[] = [];
const seen: URLSearchParams[] = [];
const fake = createServer((req, res) => {
const q = new URL(req.url!, 'http://x').searchParams;
seen.push(q);
res.setHeader('content-type', 'application/json');
const m = q.get('method');
res.end(
JSON.stringify(
m === 'getBalance'
? { status: 'success', balance: { current_balance: balance } }
: m === 'getCDR'
? cdrs.length
? { status: 'success', cdr: cdrs }
: { status: 'no_cdr' }
: { status: 'invalid_method' },
),
);
});
let t: Awaited<ReturnType<typeof createTestApp>>;
let req: (m: string, url: string, payload?: unknown) => Promise<LightMyRequestResponse>;
let trunkId: string;
let url: string;
const mails: { subject: string; text: string }[] = [];
const watch = () =>
createFraudWatch({
db: t.db,
voipmsUrl: url,
mail: async (subject, text) => void mails.push({ subject, text }),
log: { warn() {} },
now: () => new Date(clock),
});
beforeAll(async () => {
await new Promise<void>((r) => fake.listen(0, '127.0.0.1', r));
url = `http://127.0.0.1:${(fake.address() as AddressInfo).port}/api/v1/rest.php`;
t = await createTestApp();
await t.clearMustChange(ADMIN.email);
const cookie = await t.login(ADMIN.email, ADMIN.password);
req = (method, u, payload) =>
t.app.inject({
method: method as 'GET',
url: u,
payload: payload as object,
headers: { ...H, cookie },
});
const r = await req('POST', '/api/trunks', {
name: 'voip.ms',
serverHost: '127.0.0.1',
username: '123456_pbx',
password: 'Sub-account pw!',
});
trunkId = r.json().id;
await req('PATCH', `/api/trunks/${trunkId}`, {
voipmsApiUsername: 'me@example.com',
voipmsApiPassword: 'api-pw',
});
await t.db.insert(dids).values({
number: '13125551000',
trunkId,
destination: { type: 'hangup' },
} as typeof dids.$inferInsert);
});
afterAll(async () => {
fake.close();
await t.close();
});
it('riskyCallWatch emails each risky outside call once, and not when turned off', async () => {
const w = watch();
mails.length = 0;
const call = (linkedid: string, to: string, created: string) => ({
linkedid,
direction: 'outbound' as const,
status: 'answered' as const,
startedAt: at(created),
endedAt: at(created),
fromNumber: '101',
toNumber: to,
talkSeconds: 125,
createdAt: at(created),
});
await t.db
.insert(calls)
.values([
call('r1', '01144207946095', '2026-10-29T12:00:20Z'),
call('r2', '18765551234', '2026-10-29T12:00:30Z'),
call('r3', '13125551234', '2026-10-29T12:00:40Z'),
]);
clock = Date.parse('2026-10-29T12:01:00Z');
await w.riskyCallWatch();
expect(mails.map((m) => m.subject)).toEqual(['Calls to risky numbers (2)']);
expect(mails[0]!.text).toMatch(/from 101 to 01144207946095: international, answered/);
expect(mails[0]!.text).toMatch(/\(876\) 555-1234: international rates/);
expect(mails[0]!.text).not.toMatch(/\(312\) 555-1234/);
clock += 60_000;
await w.riskyCallWatch(); // nothing new
expect(mails).toHaveLength(1);
await setSetting(t.db, 'alerts.riskyCalls', false);
await t.db.insert(calls).values(call('r4', '19005551234', '2026-10-29T12:01:30Z'));
clock += 60_000;
await w.riskyCallWatch();
expect(mails).toHaveLength(1);
await setSetting(t.db, 'alerts.riskyCalls', true);
});
it('providerWatch reports voip.ms calls the PBX never sent to the trunk, once', async () => {
await setSetting(t.db, 'alerts.balanceDropPerHour', 0);
const w = watch();
mails.length = 0;
clock = Date.parse('2026-10-29T14:00:00Z');
// The PBX sent one call to the trunk at 13:00 (a forward would look the same).
await t.db.insert(cdr).values({
uniqueid: 'u1',
linkedid: 'u1',
startAt: at('2026-10-29T13:00:05Z'),
lastdata: `PJSIP/13125550000@trunk-${trunkId},120`,
raw: {},
});
const rec = (uniqueid: string, date: string, destination: string) => ({
date,
callerid: '"Office" <3125551000>',
destination,
description: 'Outgoing',
account: '123456_pbx',
disposition: 'ANSWERED',
duration: '00:03:00',
seconds: '180',
rate: '0.25',
total: '0.7500',
uniqueid,
});
cdrs.push(
rec('v1', '2026-10-29 13:00:09', '3125550000'), // ours
rec('v2', '2026-10-29 13:10:00', '3125551000'), // incoming, to our DID
rec('v3', '2026-10-29 13:20:00', '01144207946095'), // not ours
rec('v4', '2026-10-29 11:00:00', '01144207946096'), // before the window
{ ...rec('v5', '2026-10-29 13:30:00', '01144207946097'), account: '123456_other' },
);
await w.providerWatch();
const q = seen.findLast((s) => s.get('method') === 'getCDR')!;
expect(
Object.fromEntries(['date_from', 'date_to', 'timezone', 'account'].map((k) => [k, q.get(k)])),
).toEqual({
date_from: '2026-10-29',
date_to: '2026-10-29',
timezone: '0',
account: '123456_pbx',
});
expect(mails.map((m) => m.subject)).toEqual([
"voip.ms calls this phone system didn't make (1)",
]);
expect(mails[0]!.text).toMatch(/to 01144207946095: answered, 180 s, cost 0\.7500/);
expect(mails[0]!.text).not.toMatch(/3125550000|3125551000|946096|946097/);
clock += 15 * 60_000;
await w.providerWatch(); // v3 again: already reported
expect(mails).toHaveLength(1);
await setSetting(t.db, 'alerts.providerCalls', false);
cdrs.push(rec('v6', '2026-10-29 14:00:00', '01144207946098'));
clock += 15 * 60_000;
await w.providerWatch();
expect(mails).toHaveLength(1);
await setSetting(t.db, 'alerts.providerCalls', true);
cdrs.length = 0;
});
it('balanceWatch emails once an hour when the balance drops more than the limit', async () => {
await setSetting(t.db, 'alerts.providerCalls', false);
await setSetting(t.db, 'alerts.balanceDropPerHour', 10);
const w = watch();
mails.length = 0;
clock = Date.parse('2026-10-29T16:00:00Z');
balance = '100.00';
await w.providerWatch();
clock += 15 * 60_000;
balance = '95.00';
await w.providerWatch();
expect(mails).toEqual([]);
clock += 15 * 60_000;
balance = '85.00';
await w.providerWatch();
expect(mails.map((m) => m.subject)).toEqual(['voip.ms balance dropped 15.00 in an hour']);
clock += 15 * 60_000;
balance = '70.00';
await w.providerWatch(); // within the hour of the last email
expect(mails).toHaveLength(1);
clock += 60 * 60_000;
balance = '200.00'; // a deposit
await w.providerWatch();
expect(mails).toHaveLength(1);
await setSetting(t.db, 'alerts.providerCalls', true);
});
});
+177
View File
@@ -0,0 +1,177 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// SD3: the FTC complaint download (14 §2.2) against a fake api.ftc.gov with three days of data.
import { randomBytes } from 'node:crypto';
import { eq, sql } from 'drizzle-orm';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { SPAM_DEFAULTS } from '@selfphoneng/shared';
import { encrypt, setSecretKey } from '../src/crypto/secrets.ts';
import type { Db } from '../src/db/client.ts';
import { ftcComplaints, settings } from '../src/db/schema.ts';
import { ftcSync } from '../src/spam/ftc.ts';
import { getSetting, setSetting } from '../src/services/settings.ts';
import { createTestDb, hasTestDb } from './db.ts';
const KEY = 'datagov-secret-key-456';
const NOW = new Date('2026-10-27T18:00:00Z'); // 14:00 in Washington
const hour = 3_600_000;
const at = (day: string, i: number) =>
`${day} ${String(Math.floor(i / 60)).padStart(2, '0')}:${String(i % 60).padStart(2, '0')}:00`;
const ftc = (id: string, phone: string, created: string, robo = 'Y') => ({
type: 'dnc_complaint',
id,
attributes: {
'company-phone-number': phone,
'created-date': created,
subject: 'Warranties',
'recorded-message-or-robocall': robo,
},
});
/** 120 complaints on the 25th (three pages), 3 on the 26th, 3 today (one usable). */
const DATA = [
...Array.from({ length: 120 }, (_, i) => ftc(`a${i}`, '3125550100', at('2026-10-25', i))),
...[0, 1, 2].map((i) => ftc(`b${i}`, '3125550101', at('2026-10-26', i), 'N')),
ftc('c0', '3125550102', '2026-10-27 09:00:00', ''),
ftc('c1', '', '2026-10-27 09:01:00'), // no number
ftc('c2', '3125550102', '2026-10-27'), // no time
];
describe.skipIf(!hasTestDb)('FTC complaint download', () => {
let db: Db;
let drop: () => Promise<void>;
/** Each request's day and offset. */
const asked: string[] = [];
let fail: { at: number; status: number } | null = null;
const turnOn = (keyEnc = encrypt(KEY)) =>
setSetting(db, 'spam', {
...SPAM_DEFAULTS,
enabled: true,
ftc: { keyEnc, syncHours: 24, windowDays: 7 },
});
const stored = async () => (await db.select().from(ftcComplaints)).length;
const reset = async () => {
await db.delete(ftcComplaints);
await db.delete(settings).where(eq(settings.key, 'spam.state'));
};
beforeAll(async () => {
({ db, drop } = await createTestDb());
setSecretKey(randomBytes(32).toString('base64'));
});
afterAll(() => drop?.());
beforeEach(() => {
asked.length = 0;
fail = null;
vi.stubGlobal('fetch', async (url: string, init?: RequestInit) => {
const json = (j: unknown, status = 200) => new Response(JSON.stringify(j), { status });
if ((init?.headers as Record<string, string>)['X-Api-Key'] !== KEY) return json({}, 403);
const q = new URL(url).searchParams;
const day = /^"(\d{4}-\d{2}-\d{2}) 00:00:00"$/.exec(q.get('created_date_from')!)![1]!;
expect(q.get('created_date_to')).toBe(`"${day} 23:59:59"`);
expect(q.get('sort_order')).toBe('ASC');
const offset = Number(q.get('offset'));
asked.push(`${day}+${offset}`);
if (fail && asked.length === fail.at) return json({}, fail.status);
const all = DATA.filter((c) => c.attributes['created-date'].startsWith(day));
return json({ data: all.slice(offset, offset + 50), meta: { 'record-total': all.length } });
});
});
afterEach(async () => {
vi.unstubAllGlobals();
await reset();
});
it('downloads nothing while spam check or the FTC list is off', async () => {
await setSetting(db, 'spam', SPAM_DEFAULTS);
expect(await ftcSync(db, { now: NOW })).toBeNull();
await setSetting(db, 'spam', { ...SPAM_DEFAULTS, enabled: true });
expect(await ftcSync(db, { now: NOW })).toBeNull();
expect(asked).toEqual([]);
});
it('the first download fills the window a day at a time and drops old complaints', async () => {
await turnOn();
await db.insert(ftcComplaints).values({
id: 'old',
number: '13125550109',
reportedAt: sql`${NOW.toISOString()}::timestamptz - interval '8 days'`,
});
expect(await ftcSync(db, { now: NOW, budget: 100 })).toBeNull();
expect(asked).toEqual([
'2026-10-20+0',
'2026-10-21+0',
'2026-10-22+0',
'2026-10-23+0',
'2026-10-24+0',
'2026-10-25+0',
'2026-10-25+50',
'2026-10-25+100',
'2026-10-26+0',
'2026-10-27+0',
]);
expect(await stored()).toBe(124);
const row = async (id: string) =>
(await db.select().from(ftcComplaints).where(eq(ftcComplaints.id, id)))[0];
expect(await row('b0')).toMatchObject({
number: '13125550101',
subject: 'Warranties',
robocall: false,
reportedAt: new Date('2026-10-26T04:00:00Z'), // midnight in Washington
});
expect((await row('c0'))?.robocall).toBeNull();
expect(await getSetting(db, 'spam.state')).toMatchObject({
ftcCursor: null,
ftcLastSyncAt: NOW.toISOString(),
ftcLastError: null,
});
});
it('not again until syncHours; then from the newest day kept, less one', async () => {
await turnOn();
await ftcSync(db, { now: NOW, budget: 100 });
asked.length = 0;
await ftcSync(db, { now: new Date(NOW.getTime() + 23 * hour) });
expect(asked).toEqual([]);
await ftcSync(db, { now: new Date(NOW.getTime() + 24 * hour) });
expect(asked).toEqual(['2026-10-26+0', '2026-10-27+0', '2026-10-28+0']);
});
it('15 pages a minute, the place saved; a 429 stops there and the next minute carries on', async () => {
await turnOn();
await ftcSync(db, { now: NOW });
expect(asked).toHaveLength(10);
await reset();
asked.length = 0;
await ftcSync(db, { now: NOW, budget: 2 });
expect((await getSetting(db, 'spam.state')).ftcCursor).toEqual({
day: '2026-10-22',
offset: 0,
});
fail = { at: 7, status: 429 }; // the 25th's second page
const said = 'api.data.gov says the key is over its hourly limit';
expect(await ftcSync(db, { now: NOW })).toBe(said);
expect(await getSetting(db, 'spam.state')).toMatchObject({
ftcCursor: { day: '2026-10-25', offset: 50 },
ftcLastError: said,
ftcLastSyncAt: null,
});
fail = { at: 8, status: 429 }; // the same failure isn't returned (logged) twice
expect(await ftcSync(db, { now: NOW })).toBeNull();
fail = null;
expect(await ftcSync(db, { now: NOW })).toBeNull();
expect(await stored()).toBe(124);
expect(await getSetting(db, 'spam.state')).toMatchObject({
ftcCursor: null,
ftcLastError: null,
});
});
it('a refused key is saved for the Settings tab', async () => {
await turnOn(encrypt('wrong'));
expect(await ftcSync(db, { now: NOW })).toBe('api.data.gov refused the key');
expect((await getSetting(db, 'spam.state')).ftcLastError).toBe('api.data.gov refused the key');
});
});
+158
View File
@@ -0,0 +1,158 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// MB1: pairing the mobile app and its device tokens (13 §2, §4).
import { eq } from 'drizzle-orm';
import type { LightMyRequestResponse } from 'fastify';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { auditLog, mobileDevices, users } from '../src/db/schema.ts';
import { hasTestDb } from './db.ts';
import { ADMIN, H, createTestApp } from './app.ts';
describe.skipIf(!hasTestDb)('mobile app pairing', () => {
let t: Awaited<ReturnType<typeof createTestApp>>;
type Req = (method: string, url: string, payload?: unknown) => Promise<LightMyRequestResponse>;
let admin: Req;
let dan: Req;
let danId: string;
const withHeaders =
(headers: Record<string, string>): Req =>
(method, url, payload) =>
t.app.inject({ method: method as 'GET', url, payload: payload as object, headers });
const bearer = (token: string) => withHeaders({ authorization: `Bearer ${token}` });
// The app sends X-Requested-With on its one call without a token (CSRF hook, 04 §2.2).
const anon = withHeaders(H);
const pair = (code: string, name = 'Pixel 9') =>
anon('POST', '/api/mobile/pair', { code, name, platform: 'android' });
beforeAll(async () => {
t = await createTestApp();
await t.clearMustChange(ADMIN.email);
admin = withHeaders({ ...H, cookie: await t.login(ADMIN.email, ADMIN.password) });
danId = (await t.addUser('dan@example.com', 'dan-password-123')).id;
dan = withHeaders({ ...H, cookie: await t.login('dan@example.com', 'dan-password-123') });
// GET /api/me needs an extension.
for (const [number, userId] of [
['101', danId],
['102', (await t.db.select().from(users).where(eq(users.email, ADMIN.email)))[0]!.id],
])
await admin('POST', '/api/extensions', { number, name: `Ext ${number}`, userId });
});
afterAll(() => t?.close());
let token: string;
let deviceId: string;
it('pairs a phone once with a code from the web page', async () => {
const r = await dan('POST', '/api/me/mobile-devices/pair');
expect(r.statusCode).toBe(200);
const c = r.json();
expect(c.code).toMatch(/^[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{12}$/);
expect(c.server).toBe('https://pbx.test');
expect(c.link).toBe(
`selfphoneng://pair?server=${encodeURIComponent('https://pbx.test')}&code=${c.code}`,
);
expect(c.qr).toMatch(/^data:image\/png;base64,/);
expect(new Date(c.expiresAt).getTime() - Date.now()).toBeGreaterThan(9 * 60_000);
// Typed by hand: lower case and spaces are fine.
const typed = `${c.code.slice(0, 4)} ${c.code.slice(4)}`.toLowerCase();
const p = await pair(typed);
expect(p.statusCode).toBe(200);
expect(p.json().user).toEqual({ name: expect.any(String), email: 'dan@example.com' });
token = p.json().token;
expect(token).toMatch(/^spm_[A-Za-z0-9]{40}$/);
expect((await pair(c.code)).json().error.code).toBe('pair_code_invalid'); // used
const rows = await t.db.select().from(mobileDevices);
expect(rows).toHaveLength(1);
expect(rows[0]!.tokenHash).not.toContain(token.slice(4)); // stored hashed
expect(rows[0]!.pairCodeHash).toBeNull();
deviceId = rows[0]!.id;
});
it('the device token acts as its user', async () => {
const me = bearer(token);
expect((await me('GET', '/api/me')).statusCode).toBe(200);
// No X-Requested-With needed with a Bearer token.
expect((await me('PATCH', '/api/me', { dnd: true })).statusCode).toBe(200);
const list = (await me('GET', '/api/me/mobile-devices')).json().items;
expect(list).toEqual([
{
id: deviceId,
name: 'Pixel 9',
platform: 'android',
tokenPrefix: token.slice(0, 8),
createdAt: expect.any(String),
lastUsedAt: expect.any(String),
current: true,
},
]);
expect((await dan('GET', '/api/me/mobile-devices')).json().items[0].current).toBe(false);
// A phone can't make itself a spare.
expect((await me('POST', '/api/me/mobile-devices/pair')).statusCode).toBe(403);
expect((await bearer(`spm_${'x'.repeat(40)}`)('GET', '/api/me')).statusCode).toBe(401);
});
it("an admin's phone is not an admin", async () => {
const code = (await admin('POST', '/api/me/mobile-devices/pair')).json().code;
const phone = bearer((await pair(code, 'iPhone')).json().token);
expect((await phone('GET', '/api/me')).statusCode).toBe(200);
expect((await phone('GET', '/api/users')).statusCode).toBe(403);
expect((await phone('GET', '/api/api-tokens')).statusCode).toBe(403);
expect((await admin('GET', '/api/users')).statusCode).toBe(200);
});
it('refuses expired codes, bad input, and replaces an unused code', async () => {
const first = (await dan('POST', '/api/me/mobile-devices/pair')).json().code;
const second = (await dan('POST', '/api/me/mobile-devices/pair')).json().code;
expect((await pair(first)).statusCode).toBe(404); // replaced
await t.db
.update(mobileDevices)
.set({ pairExpiresAt: new Date(Date.now() - 1000) })
.where(eq(mobileDevices.userId, danId));
expect((await pair(second)).json().error.code).toBe('pair_code_invalid');
const bad = await anon('POST', '/api/mobile/pair', {
code: second,
name: 'x',
platform: 'windows',
});
expect(bad.statusCode).toBe(400);
});
it('a disabled user loses their phones and can’t pair one', async () => {
const code = (await dan('POST', '/api/me/mobile-devices/pair')).json().code;
await t.db.update(users).set({ isActive: false }).where(eq(users.id, danId));
expect((await bearer(token)('GET', '/api/me')).statusCode).toBe(401);
expect((await pair(code)).statusCode).toBe(404);
await t.db.update(users).set({ isActive: true }).where(eq(users.id, danId));
expect((await bearer(token)('GET', '/api/me')).statusCode).toBe(200);
});
it('removing a device ends its token; only your own', async () => {
const adminPhone = (await t.db.select().from(mobileDevices)).find((d) => d.name === 'iPhone')!;
expect(
(await bearer(token)('DELETE', `/api/me/mobile-devices/${adminPhone.id}`)).statusCode,
).toBe(404);
expect((await bearer(token)('DELETE', `/api/me/mobile-devices/${deviceId}`)).statusCode).toBe(
200,
);
expect((await bearer(token)('GET', '/api/me')).statusCode).toBe(401);
const actions = (await t.db.select().from(auditLog))
.filter((a) => a.entityType === 'mobile_device')
.map((a) => a.action);
expect(actions).toContain('create');
expect(actions).toContain('delete');
});
it('changing the password removes every paired phone', async () => {
const code = (await dan('POST', '/api/me/mobile-devices/pair')).json().code;
const phone = bearer((await pair(code)).json().token);
expect((await phone('GET', '/api/me')).statusCode).toBe(200);
const r = await dan('POST', '/api/auth/change-password', {
currentPassword: 'dan-password-123',
newPassword: 'a-new-dan-password-456',
});
expect(r.statusCode).toBe(200);
expect((await phone('GET', '/api/me')).statusCode).toBe(401);
expect((await dan('GET', '/api/me/mobile-devices')).json().items).toEqual([]);
});
});
+50
View File
@@ -207,6 +207,56 @@ for (const alg of ['RS256', 'ES256'] as const)
expect(ids).toMatchObject([{ kind: 'oidc', subject: `${idp.issuer} u-cara` }]);
});
it('never matches by email an admin, or someone with a directory account (08 §7 #2)', async () => {
const [boss, dir] = await t.db
.insert(users)
.values([
{ email: 'diradmin@example.com', displayName: 'Boss', passwordHash: null, role: 'admin' },
{ email: 'diruser@example.com', displayName: 'Dir', passwordHash: null },
])
.returning();
await t.db
.insert(userIdentities)
.values({ userId: dir!.id, kind: 'ldap', subject: 'dir-user-id' });
for (const u of [boss!, dir!]) {
idp.st.user = { sub: `u-${u.email}`, email: u.email, email_verified: true };
const { callback } = await flow();
expect(errorOf(callback!)).toBe('not_connected');
expect(session(callback!)).toBeNull();
}
});
it('connect finishes only in the browser whose session asked (08 §7 #8)', async () => {
// Mallory starts Connect, then plants her sp_oidc cookie in Vic's browser and sends him on.
await t.addUser('mallory@example.com', 'mallory-password-1');
const [m] = await t.db.select().from(users).where(eq(users.email, 'mallory@example.com'));
await t.addUser('vic@example.com', 'vic-password-123');
const mallory = await t.login('mallory@example.com', 'mallory-password-1');
const vic = await t.login('vic@example.com', 'vic-password-123');
idp.st.user = { sub: 'u-vic', email: 'vic@example.com', email_verified: true };
for (const theirs of [vic, '']) {
const start = await t.app.inject({
method: 'POST',
url: '/api/auth/oidc/connect',
headers: { ...H, cookie: mallory },
payload: {},
});
const pending = start.cookies.find((c) => c.name === 'sp_oidc')!;
const atIdp = await fetch((start.json() as { url: string }).url, { redirect: 'manual' });
const back = new URL(atIdp.headers.get('location')!);
const callback = await t.app.inject({
url: back.pathname + back.search,
headers: { cookie: [`sp_oidc=${pending.value}`, theirs].filter(Boolean).join('; ') },
});
expect(errorOf(callback)).toBe('expired');
const ids = await t.db
.select()
.from(userIdentities)
.where(eq(userIdentities.userId, m!.id));
expect(ids).toEqual([]);
}
});
it('trustEmail matches an unverified email', async () => {
await t.db
.insert(users)
+24 -2
View File
@@ -1,13 +1,13 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// M7-T3: a recorded call (sp_recording) is linked to its calls row and playable by admins and the
// people on the call; delete and retention remove file and row.
// people on the call (not a phone that only rang, 08 §7 #3); delete and retention remove file and row.
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { eq } from 'drizzle-orm';
import type { LightMyRequestResponse } from 'fastify';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import type { AmiMessage } from '../src/ami/events.ts';
import { calls, cdr, extensions, recordings } from '../src/db/schema.ts';
import { callExtensions, calls, cdr, extensions, recordings, users } from '../src/db/schema.ts';
import { encrypt } from '../src/crypto/secrets.ts';
import { cleanupRecordings } from '../src/routes/recordings.ts';
import { aggregateCall, toCdrRow } from '../src/services/cdr.ts';
@@ -56,6 +56,8 @@ describe.skipIf(!hasTestDb)('call recordings', () => {
};
});
afterAll(() => t?.close());
const bobId = async () =>
(await t.db.select().from(users).where(eq(users.email, 'bob@example.com')))[0]!.id;
it('the recording is linked to its call, with size and length', async () => {
const [c] = await t.db.select().from(calls);
@@ -91,6 +93,26 @@ describe.skipIf(!hasTestDb)('call recordings', () => {
expect((await as.alice('GET', '/api/recordings')).statusCode).toBe(403);
});
it('a phone that only rang (ring group, queue) gets neither audio nor transcript (08 §7 #3)', async () => {
const [bob] = await t.db
.insert(extensions)
.values({
number: '102',
name: 'Bob',
userId: await bobId(),
sipPasswordEnc: encrypt('x'),
voicemailPinEnc: encrypt('4826'),
})
.returning();
const [c] = await t.db.select().from(calls);
await t.db.insert(callExtensions).values({ callId: c!.id, extensionId: bob!.id, role: 'rang' });
expect((await as.bob('GET', `/api/recordings/${recId}/audio`)).statusCode).toBe(403);
expect((await as.bob('GET', `/api/recordings/${recId}/transcript`)).statusCode).toBe(403);
const mine = (await as.bob('GET', '/api/me/calls')).json();
expect(mine.items).toEqual([expect.objectContaining({ id: c!.id, recordingId: null })]);
expect((await as.alice('GET', `/api/recordings/${recId}/transcript`)).statusCode).toBe(200);
});
it('retention deletes recordings past their days; delete removes file and link', async () => {
expect(await cleanupRecordings(t.db, dir(), 3650)).toBe(0);
expect((await as.admin('DELETE', `/api/recordings/${recId}`)).statusCode).toBe(200);
+74 -2
View File
@@ -1,8 +1,11 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// M11-T6: /api/reports/summary and /api/reports/queues (04 §7.3), days in the company time zone.
// M11-T6: /api/reports/summary and /api/reports/queues (04 §7.3), days in the company time zone;
// /api/me/reports/summary, the calls made to me.
import type { LightMyRequestResponse } from 'fastify';
import { inArray } from 'drizzle-orm';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { calls, queueCalls, queues } from '../src/db/schema.ts';
import { callExtensions, calls, extensions, queueCalls, queues } from '../src/db/schema.ts';
import { encrypt } from '../src/crypto/secrets.ts';
import { setSetting } from '../src/services/settings.ts';
import { hasTestDb } from './db.ts';
import { ADMIN, H, createTestApp } from './app.ts';
@@ -125,5 +128,74 @@ describe.skipIf(!hasTestDb)('/api/reports', () => {
await t.addUser('pat@example.com', 'pat-password-123');
const pat = await t.login('pat@example.com', 'pat-password-123');
expect((await get('/api/reports/queues', pat)).statusCode).toBe(403);
expect((await get('/api/me/reports/summary', pat)).statusCode).toBe(404); // no extension
});
it('mine: only calls made to me; answered and talk time are the ones I picked up', async () => {
const sam = await t.addUser('sam@example.com', 'sam-password-123');
const [mine, other] = await t.db
.insert(extensions)
.values(
(['103', '104'] as const).map((number) => ({
number,
name: number,
sipPasswordEnc: encrypt('x'),
voicemailPinEnc: encrypt('4826'),
userId: number === '103' ? sam.id : null,
})),
)
.returning();
await t.db.insert(calls).values({
linkedid: 'g',
direction: 'internal',
status: 'answered',
startedAt: new Date('2026-05-05T15:00:00Z'), // Tue 10:00
endedAt: new Date('2026-05-05T15:00:45Z'),
talkSeconds: 40,
answeredByExtensionId: mine!.id,
});
const ids = Object.fromEntries(
(
await t.db
.select({ id: calls.id, linkedid: calls.linkedid })
.from(calls)
.where(inArray(calls.linkedid, ['a', 'b', 'c', 'g']))
).map((c) => [c.linkedid, c.id]),
);
// a: rang me and a coworker, the coworker answered. b: rang me, missed. c: I made it. g: mine.
await t.db
.update(calls)
.set({ answeredByExtensionId: other!.id })
.where(inArray(calls.id, [ids.a!]));
await t.db.insert(callExtensions).values([
{ callId: ids.a!, extensionId: mine!.id, role: 'rang' },
{ callId: ids.a!, extensionId: other!.id, role: 'answered' },
{ callId: ids.b!, extensionId: mine!.id, role: 'rang' },
{ callId: ids.c!, extensionId: mine!.id, role: 'caller' },
{ callId: ids.g!, extensionId: mine!.id, role: 'rang' },
{ callId: ids.g!, extensionId: mine!.id, role: 'answered' },
]);
const cookie = await t.login('sam@example.com', 'sam-password-123');
const r = await get('/api/me/reports/summary?from=2026-05-04&to=2026-05-06', cookie);
expect(r.statusCode, r.body).toBe(200);
const s = r.json();
expect(s.days).toEqual([
{ date: '2026-05-04', inbound: 2, outbound: 0, internal: 0, missed: 1, outsideMinutes: 2 },
{ date: '2026-05-05', inbound: 0, outbound: 0, internal: 1, missed: 0, outsideMinutes: 0 },
{ date: '2026-05-06', inbound: 0, outbound: 0, internal: 0, missed: 0, outsideMinutes: 0 },
]);
expect(s.totals).toEqual({
calls: 3,
answered: 1,
missed: 1,
avgTalkSeconds: 40,
talkSeconds: 40,
outsideSeconds: { inbound: 95, outbound: 0 },
});
expect(s.hours.flat().reduce((a: number, b: number) => a + b)).toBe(3);
// The company summary is unchanged by who rang.
expect(
(await get('/api/reports/summary?from=2026-05-04&to=2026-05-04')).json().totals.answered,
).toBe(2);
});
});
+4 -2
View File
@@ -94,6 +94,8 @@ const EXPECTED: Record<string, Record<string, string>> = {
answered_by_extension_id: 'uuid|YES|',
talk_seconds: 'integer|NO|0',
recording_id: 'uuid|YES|',
spam_risk: 'text|YES|',
spam_info: 'jsonb|YES|',
...ts,
},
audit_log: {
@@ -115,11 +117,11 @@ describe.skipIf(!hasTestDb)('schema matches spec 02', () => {
beforeAll(async () => ({ db, drop } = await createTestDb()));
afterAll(() => drop?.());
it('creates all 44 tables', async () => {
it('creates all 48 tables', async () => {
const r = await db.execute(
sql`select count(*)::int as n from information_schema.tables where table_schema = 'public'`,
);
expect(r.rows[0]).toEqual({ n: 44 });
expect(r.rows[0]).toEqual({ n: 48 });
});
it.each(Object.keys(EXPECTED))('%s columns', async (table) => {
+506
View File
@@ -0,0 +1,506 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// SD1, SD2, SD4, SD5: the spam check (14): IPQualityScore first, then the local FTC copy, against a fake fetch.
import { readFileSync } from 'node:fs';
import { eq, like, sql } from 'drizzle-orm';
import type { LightMyRequestResponse } from 'fastify';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { SPAM_DEFAULTS, type SpamSettings } from '@selfphoneng/shared';
import { encrypt } from '../src/crypto/secrets.ts';
import {
auditLog,
cdr,
ftcComplaints,
settings as settingsTable,
spamLookups,
systemEvents,
users,
} from '../src/db/schema.ts';
import type { ActiveCall } from '@selfphoneng/shared';
import type { AmiMessage } from '../src/ami/events.ts';
import { callerNameToken } from '../src/contacts/callername.ts';
import { aggregateCall, toCdrRow } from '../src/services/cdr.ts';
import { checkNumber, spamQuotaCheck } from '../src/spam/check.ts';
import { getSetting, setSetting } from '../src/services/settings.ts';
import { hasTestDb } from './db.ts';
import { ADMIN, H, createTestApp } from './app.ts';
const IPQS_KEY = 'ipqs-secret-key-123';
const FTC_KEY = 'datagov-secret-key-456';
const SPAMMER = '13125550001';
const SHADY = '13125550002';
const CLEAN = '13125550003';
const REPORTED = '13125550004';
/** IPQualityScore's phone answers by number; anything else is a valid, clean line. */
const ANSWERS: Record<string, Record<string, unknown>> = {
[SPAMMER]: { fraud_score: 40, spammer: true, line_type: 'VOIP', carrier: 'Acme Tel' },
[SHADY]: { fraud_score: 78, city: 'Austin', region: 'TX', name: 'N/A' },
[CLEAN]: { fraud_score: 10, line_type: 'Wireless', carrier: 'Verizon' },
};
describe.skipIf(!hasTestDb)('spam check', () => {
let t: Awaited<ReturnType<typeof createTestApp>>;
type Req = (method: string, url: string, payload?: unknown) => Promise<LightMyRequestResponse>;
let admin: Req;
const urls: string[] = [];
/** What the fake IPQualityScore does: answer, fail, or say the credits are used up. */
let ipqs: 'ok' | 'down' | 'exhausted' | 'slow' | 'late' = 'ok';
let credits = 912;
const log = { warn: vi.fn() };
const d = () => ({ db: t.db, log });
const settings = (s: Partial<SpamSettings>) =>
setSetting(t.db, 'spam', { ...SPAM_DEFAULTS, enabled: true, ...s });
// Getters: the secret key is only set once createTestApp has run.
const keys = {
get withIpqs() {
return { ipqs: { keyEnc: encrypt(IPQS_KEY) } };
},
get withFtc() {
return { ftc: { keyEnc: encrypt(FTC_KEY), syncHours: 24, windowDays: 30 } };
},
};
beforeAll(async () => {
t = await createTestApp();
await t.clearMustChange(ADMIN.email);
const cookie = await t.login(ADMIN.email, ADMIN.password);
admin = (method, url, payload) =>
t.app.inject({
method: method as 'GET',
url,
payload: payload as object,
headers: { ...H, cookie },
});
const day = (n: number) => sql`now() - make_interval(days => ${n})`;
await t.db.insert(ftcComplaints).values([
{ id: 'a1', number: REPORTED, reportedAt: day(1), subject: 'Warranties', robocall: true },
{ id: 'a2', number: REPORTED, reportedAt: day(2), subject: 'Warranties', robocall: true },
{ id: 'a3', number: REPORTED, reportedAt: day(3), subject: 'Debt', robocall: false },
{ id: 'a4', number: REPORTED, reportedAt: day(40), subject: 'Debt', robocall: true },
{ id: 'b1', number: SHADY, reportedAt: day(5), subject: null, robocall: null },
]);
});
afterAll(() => t?.close());
beforeEach(() => {
urls.length = 0;
log.warn.mockClear();
ipqs = 'ok';
credits = 912;
vi.stubGlobal('fetch', async (url: string, init?: RequestInit) => {
urls.push(url);
const json = (j: unknown, status = 200) => new Response(JSON.stringify(j), { status });
if (url.startsWith('https://api.ftc.gov/')) {
const key = (init?.headers as Record<string, string>)['X-Api-Key'];
return key === FTC_KEY ? json({ data: [] }) : json({ error: {} }, 403);
}
const account = /\/api\/json\/account\/([^/]+)$/.exec(url);
if (account)
return account[1] === IPQS_KEY
? json({ success: true, credits, usage: 88 })
: json({ success: false, message: `Invalid or unauthorized key: ${account[1]}` });
const phone = /\/api\/json\/phone\/[^/]+\/(\d+)\?country\[\]=US$/.exec(url);
if (!phone) return json({}, 404);
if (ipqs === 'down') return json({}, 503);
if (ipqs === 'slow') throw Object.assign(new Error('aborted'), { name: 'TimeoutError' });
if (ipqs === 'exhausted')
return json({
success: false,
message: 'You have insufficient credits to make this query.',
});
await new Promise((r) => setTimeout(r, ipqs === 'late' ? 1500 : 20));
return json({ success: true, valid: true, ...(ANSWERS[phone[1]!] ?? { fraud_score: 0 }) });
});
});
afterEach(async () => {
vi.unstubAllGlobals();
await t.db.delete(spamLookups);
await t.db.delete(settingsTable).where(eq(settingsTable.key, 'spam.state'));
await t.db.delete(systemEvents).where(like(systemEvents.code, 'spam.%'));
});
it('checks nothing while off, and only US numbers', async () => {
await setSetting(t.db, 'spam', { ...SPAM_DEFAULTS, ...keys.withIpqs });
expect(await checkNumber(d(), SPAMMER)).toBeNull();
await settings(keys.withIpqs);
for (const n of ['442079460958', '911', '101', '13125550', '10005550001'])
expect(await checkNumber(d(), n)).toBeNull();
expect(urls).toEqual([]);
});
it('IPQualityScore: risk from its flags and score, only the kept fields', async () => {
await settings(keys.withIpqs);
expect(await checkNumber(d(), SPAMMER)).toEqual({
risk: 'high',
provider: 'ipqs',
info: {
fraudScore: 40,
spammer: true,
recentAbuse: false,
lineType: 'VOIP',
carrier: 'Acme Tel',
name: null,
city: null,
region: null,
},
});
expect((await checkNumber(d(), SHADY))?.risk).toBe('medium');
expect(await checkNumber(d(), CLEAN)).toMatchObject({
risk: 'low',
info: { carrier: 'Verizon' },
});
expect(urls).toHaveLength(3);
});
it('caches answers: flagged for flaggedDays, the rest for cleanDays', async () => {
await settings({ ...keys.withIpqs, cache: { flaggedDays: 14, cleanDays: 3 } });
await checkNumber(d(), SPAMMER);
await checkNumber(d(), CLEAN);
await checkNumber(d(), SPAMMER);
await checkNumber(d(), CLEAN);
expect(urls).toHaveLength(2);
const days = await t.db.execute<{ number: string; days: number }>(
sql`select number, round(extract(epoch from expires_at - now()) / 86400)::int as days from spam_lookups order by number`,
);
expect(days.rows).toEqual([
{ number: SPAMMER, days: 14 },
{ number: CLEAN, days: 3 },
]);
// An expired answer is asked again.
await t.db.update(spamLookups).set({ expiresAt: sql`now() - interval '1 second'` });
await checkNumber(d(), CLEAN);
expect(urls).toHaveLength(3);
});
it('a check under way is shared: five at once cost one request', async () => {
await settings(keys.withIpqs);
const all = await Promise.all([1, 2, 3, 4, 5].map(() => checkNumber(d(), SPAMMER)));
expect(new Set(all.map((a) => a?.risk))).toEqual(new Set(['high']));
expect(urls).toHaveLength(1);
});
it('IPQualityScore failing: one warning without the key, nothing cached, the FTC answers', async () => {
await settings({ ...keys.withIpqs, ...keys.withFtc });
for (const mode of ['down', 'slow'] as const) {
ipqs = mode;
expect(await checkNumber(d(), REPORTED)).toEqual({
risk: 'high',
provider: 'ftc',
info: {
complaints: 3,
robocalls: 2,
lastReportedAt: expect.any(String),
subject: 'Warranties',
},
});
}
expect(log.warn.mock.calls.map((c) => c[0])).toEqual([
{ provider: 'ipqs', reason: 'HTTP 503' },
{ provider: 'ipqs', reason: 'no answer in 4 s' },
]);
expect(JSON.stringify(log.warn.mock.calls)).not.toContain(IPQS_KEY);
expect(await t.db.select().from(spamLookups)).toEqual([]);
});
it('without IPQualityScore the FTC list answers; without either, nothing', async () => {
ipqs = 'down';
await settings(keys.withFtc);
expect((await checkNumber(d(), SHADY))?.info).toEqual({
complaints: 1,
robocalls: 0,
lastReportedAt: expect.any(String),
subject: null,
});
expect((await checkNumber(d(), SHADY))?.risk).toBe('medium');
expect(await checkNumber(d(), CLEAN)).toMatchObject({ risk: 'low', provider: 'ftc' });
expect(urls).toEqual([]);
await settings({});
expect(await checkNumber(d(), REPORTED)).toBeNull();
});
it('out of credits: paused once, the FTC answers, the warning, Dismiss, and back', async () => {
await settings({ ...keys.withIpqs, ...keys.withFtc });
const warning = async () =>
(await admin('GET', '/api/system/warnings'))
.json()
.items.find((w: { code: string }) => w.code === 'spam_quota');
const events = async () =>
(
await t.db
.select({ code: systemEvents.code })
.from(systemEvents)
.where(like(systemEvents.code, 'spam.%'))
.orderBy(systemEvents.createdAt)
).map((e) => e.code);
ipqs = 'exhausted';
const both = await Promise.all([checkNumber(d(), REPORTED), checkNumber(d(), SHADY)]);
expect(both.map((a) => a?.provider)).toEqual(['ftc', 'ftc']);
expect((await getSetting(t.db, 'spam.state')).ipqsPausedAt).not.toBeNull();
expect(log.warn).toHaveBeenCalledTimes(1);
expect(await events()).toEqual(['spam.quotaExhausted']);
expect(await warning()).toEqual({
code: 'spam_quota',
message:
"IPQualityScore's lookups are used up. Spam checks use the FTC list until they come back.",
link: '/admin/settings/spam',
dismissable: true,
});
// Paused: IPQualityScore isn't asked and nothing is logged.
urls.length = 0;
expect((await checkNumber(d(), CLEAN))?.provider).toBe('ftc');
expect(urls).toEqual([]);
expect(log.warn).toHaveBeenCalledTimes(1);
expect((await admin('POST', '/api/system/warnings/spam_quota/dismiss')).statusCode).toBe(200);
expect(await warning()).toBeUndefined();
credits = 0;
await spamQuotaCheck(t.db);
expect((await getSetting(t.db, 'spam.state')).ipqsPausedAt).not.toBeNull();
credits = 5;
await spamQuotaCheck(t.db);
expect((await getSetting(t.db, 'spam.state')).ipqsPausedAt).toBeNull();
expect(await events()).toEqual(['spam.quotaExhausted', 'spam.quotaRestored']);
ipqs = 'ok';
expect((await checkNumber(d(), SPAMMER))?.provider).toBe('ipqs');
// Out again: the warning comes back though it was dismissed; without the FTC list, "off".
await settings(keys.withIpqs);
ipqs = 'exhausted';
expect(await checkNumber(d(), SHADY)).toBeNull();
expect((await warning())?.message).toBe(
"IPQualityScore's lookups are used up. Spam checks are off until they come back.",
);
// A new key doesn't wait for the job.
const r = await admin('PATCH', '/api/settings', {
spam: {
...SPAM_DEFAULTS,
enabled: true,
ipqs: { key: IPQS_KEY },
},
});
expect(r.statusCode).toBe(200);
expect((await getSetting(t.db, 'spam.state')).ipqsPausedAt).toBeNull();
expect(await warning()).toBeUndefined();
});
it('spamQuotaCheck asks nothing unless paused, and a failure throws without the key', async () => {
await settings(keys.withIpqs);
await spamQuotaCheck(t.db);
expect(urls).toEqual([]);
await setSetting(t.db, 'spam.state', {
...(await getSetting(t.db, 'spam.state')),
ipqsPausedAt: new Date().toISOString(),
});
await settings({ ipqs: { keyEnc: encrypt('wrong-key') } });
const err = await spamQuotaCheck(t.db).catch((e: unknown) => e as Error);
expect(err).toBeInstanceOf(Error);
expect(err!.message).toBe('Invalid or unauthorized key: ***');
expect((await getSetting(t.db, 'spam.state')).ipqsPausedAt).not.toBeNull();
});
it('cachedOnly never asks IPQualityScore', async () => {
await settings({ ...keys.withIpqs, ...keys.withFtc });
expect((await checkNumber(d(), REPORTED, { cachedOnly: true }))?.provider).toBe('ftc');
await checkNumber(d(), SPAMMER);
expect((await checkNumber(d(), SPAMMER, { cachedOnly: true }))?.provider).toBe('ipqs');
expect(urls).toHaveLength(1);
});
describe('while it rings', () => {
let ringing: ActiveCall[] = [];
const call = (from: string, ext: string): ActiveCall => ({
linkedid: `l-${from}`,
startedAt: new Date().toISOString(),
direction: 'inbound',
from: { number: from },
to: { number: '13125559999' },
state: 'ringing',
extensions: [ext],
channels: [],
recording: false,
});
const check = async (number: string) =>
(await admin('GET', `/api/me/caller-check?number=${number}`)).json().check;
const deskName = async (number: string, ext = '101') =>
(
await t.app.inject({
method: 'GET',
url: `/api/internal/caller-name?ext=${ext}&number=${number}&token=${callerNameToken(t.app.config.APP_SECRET)}`,
})
).body;
beforeAll(async () => {
const [me] = await t.db.select().from(users).where(eq(users.email, ADMIN.email));
expect(
(await admin('POST', '/api/extensions', { number: '101', name: 'Admin', userId: me!.id }))
.statusCode,
).toBe(201);
await admin('POST', '/api/me/contacts', {
name: 'Pizza Place',
numbers: [{ number: SHADY.slice(1) }],
});
t.app.live.calls.list = () => ringing;
});
beforeEach(() => settings({ ...keys.withIpqs, ...keys.withFtc }));
it('the softphone: only callers ringing me now, never my contacts', async () => {
ringing = [call(SPAMMER.slice(1), '101'), call(CLEAN, '102'), call(SHADY, '101')];
expect(await check(SPAMMER)).toMatchObject({ risk: 'high', provider: 'ipqs' });
expect(await check(`+1 ${SPAMMER.slice(1)}`)).toMatchObject({ risk: 'high' });
expect(await check(CLEAN)).toBeNull(); // ringing someone else
expect(await check(REPORTED)).toBeNull(); // not ringing
expect(await check(SHADY)).toBeNull(); // a contact
expect(urls).toHaveLength(1);
ringing = [call(CLEAN, '101')];
expect(await check(CLEAN)).toMatchObject({ risk: 'low' }); // low: the softphone hides it
expect((await admin('GET', '/api/me/caller-check?number=nonsense')).json().check).toBeNull();
});
it('desk phones: the label in time, nothing for a contact, low risk or a late answer', async () => {
expect(await deskName(SPAMMER)).toBe('SPAM LIKELY');
expect(await deskName(SHADY)).toBe('Pizza Place');
expect(await deskName(SHADY, '999')).toBe('Spam risk'); // nobody's contact there
expect(await deskName(CLEAN)).toBe('');
await t.db.delete(spamLookups);
ipqs = 'late';
expect(await deskName(SPAMMER)).toBe('');
await vi.waitFor(async () => expect(await t.db.select().from(spamLookups)).toHaveLength(1));
expect(await deskName(SPAMMER)).toBe('SPAM LIKELY'); // from the cache
await settings({ ...keys.withFtc, deskPhoneLabels: { high: 'JUNK', medium: 'Maybe junk' } });
expect(await deskName(REPORTED)).toBe('JUNK');
});
it('call history keeps what the check knew, after the cache expires; contacts first', async () => {
const fixture = readFileSync(
new URL('./fixtures/cdr/inbound-user.json', import.meta.url),
'utf8',
);
const callFrom = async (number: string) => {
const events = (JSON.parse(fixture.replaceAll('13125559999', number)) as AmiMessage[]).map(
(e) => ({ ...e, LinkedID: `${number}-${String(e.LinkedID)}` }),
);
for (const e of events) await t.db.insert(cdr).values(toCdrRow(e));
await aggregateCall(t.db, String(events[0]!.LinkedID));
};
await checkNumber(d(), SPAMMER);
urls.length = 0;
for (const n of [SPAMMER, REPORTED, SHADY, CLEAN]) await callFrom(n);
expect(urls).toEqual([]); // cachedOnly: CLEAN isn't asked
await t.db.delete(spamLookups);
const spamOf = async (n: string) =>
(await admin('GET', '/api/calls?direction=inbound'))
.json()
.items.find((c: { fromNumber: string }) => c.fromNumber === n)?.spam;
expect(await spamOf(SPAMMER)).toMatchObject({
risk: 'high',
provider: 'ipqs',
info: { spammer: true },
});
expect(await spamOf(REPORTED)).toMatchObject({
risk: 'high',
provider: 'ftc',
info: { complaints: 3 },
});
expect(await spamOf(SHADY)).toBeNull(); // the admin's contact
// Not in the cache, never reported: low (the page shows nothing for it).
expect(await spamOf(CLEAN)).toMatchObject({ risk: 'low', provider: 'ftc' });
});
});
describe('Settings → Spam check', () => {
const body = (s: Record<string, unknown> = {}) => ({
spam: {
enabled: true,
ipqs: { key: IPQS_KEY },
ftc: null,
cache: { flaggedDays: 14, cleanDays: 3 },
deskPhoneLabels: { high: 'SPAM LIKELY', medium: 'Spam risk' },
...s,
},
});
it('Download now: due at the next minute; refused without the FTC list', async () => {
await settings(keys.withIpqs);
const r = await admin('POST', '/api/settings/spam/ftc/sync');
expect(r.statusCode).toBe(400);
expect(r.body).toMatch('Set up the FTC list first');
await settings(keys.withFtc);
await setSetting(t.db, 'spam.state', {
...(await getSetting(t.db, 'spam.state')),
ftcLastSyncAt: new Date().toISOString(),
});
expect((await admin('POST', '/api/settings/spam/ftc/sync')).statusCode).toBe(200);
expect((await getSetting(t.db, 'spam.state')).ftcLastSyncAt).toBeNull();
});
it('keys are write-only, and empty keeps the saved one', async () => {
await setSetting(t.db, 'spam', SPAM_DEFAULTS);
const r = await admin('PATCH', '/api/settings', body());
expect(r.statusCode).toBe(200);
expect(r.json().spam).toEqual({
...SPAM_DEFAULTS,
enabled: true,
ipqs: { hasKey: true },
});
expect(r.body).not.toContain(IPQS_KEY);
const saved = (await getSetting(t.db, 'spam')).ipqs!.keyEnc;
expect((await admin('PATCH', '/api/settings', body({ ipqs: {} }))).statusCode).toBe(200);
expect((await getSetting(t.db, 'spam')).ipqs!.keyEnc).toBe(saved);
const [a] = await t.db
.select()
.from(auditLog)
.where(eq(auditLog.summary, 'Changed settings: spam'));
expect(JSON.stringify(a!.diff)).not.toContain(saved);
});
it('refuses a provider without a key, nothing to check with, and odd labels', async () => {
await setSetting(t.db, 'spam', SPAM_DEFAULTS);
const no = async (b: unknown, error: RegExp) => {
const r = await admin('PATCH', '/api/settings', b);
expect(r.statusCode).toBe(400);
expect(r.body).toMatch(error);
};
await no(body({ ipqs: {} }), /Enter the IPQualityScore key/);
await no(
body({ ipqs: null, ftc: { syncHours: 24, windowDays: 30 } }),
/Enter the api.data.gov key/,
);
await no(body({ ipqs: null }), /Set up IPQualityScore or the FTC list/);
await no(body({ deskPhoneLabels: { high: 'Spam?', medium: 'Spam risk' } }), /Letters/);
await no(
body({ ftc: { key: FTC_KEY, syncHours: 0, windowDays: 30 } }),
/syncHours|too small|>=1/i,
);
await no({ 'spam.state': { ipqsPausedAt: null } }, /can't be changed/);
// Off with no provider is fine.
expect(
(await admin('PATCH', '/api/settings', body({ enabled: false, ipqs: null }))).statusCode,
).toBe(200);
});
it('Test uses the typed key, else the saved one, and never shows it', async () => {
await setSetting(t.db, 'spam', SPAM_DEFAULTS);
const test = async (provider: string, key?: string) =>
(await admin('POST', `/api/settings/spam/${provider}/test`, { key })).json();
expect(await test('ipqs')).toEqual({ ok: false, error: 'Enter the key first' });
expect(await test('ipqs', IPQS_KEY)).toEqual({
ok: true,
detail: 'Works: 912 credits left this billing period.',
});
const bad = await test('ipqs', 'wrong-key-789');
expect(bad.ok).toBe(false);
expect(JSON.stringify(bad)).not.toContain('wrong-key-789');
await settings({ ...keys.withIpqs, ...keys.withFtc });
expect((await test('ipqs')).ok).toBe(true);
expect(await test('ftc')).toEqual({ ok: true, detail: 'The FTC answered.' });
expect(await test('ftc', 'nope')).toEqual({
ok: false,
error: 'api.data.gov refused the key',
});
});
});
});
+5 -3
View File
@@ -105,7 +105,7 @@ describe.skipIf(!hasTestDb || !hasOpenssl)('SIP TLS setting', () => {
expect(await warningCodes()).not.toContain('tls_cert');
});
it('a TLS trunk: no qualify, Test does not send OPTIONS, TLS stays on while it exists', async () => {
it('a TLS trunk: qualified like the others (24.0.0-rc3), TLS stays on while it exists', async () => {
const trunk = await req('POST', '/api/trunks', {
name: 'voip.ms TLS',
serverHost: '127.0.0.1',
@@ -119,13 +119,15 @@ describe.skipIf(!hasTestDb || !hasOpenssl)('SIP TLS setting', () => {
await t.app.generator.apply({ trigger: 'test' });
const { readFile } = await import('node:fs/promises');
const conf = await readFile(join(t.app.generator.dir, 'pjsip_selfphoneng.conf'), 'utf8');
expect(conf).toMatch(/contact = sip:127\.0\.0\.1:5061;transport=tls\nqualify_frequency = 0\n/);
expect(conf).toMatch(/contact = sip:127\.0\.0\.1:5061;transport=tls\nqualify_frequency = 60\n/);
expect(conf).toMatch(/media_encryption = sdes/);
expect(conf).toMatch(/verify_server = yes/);
t.live.amiConnected = true;
const test = await req('POST', `/api/trunks/${trunk.json().id}/test`);
expect(test.statusCode).toBe(200);
expect(t.ami.actions.filter((a) => a.Action === 'PJSIPQualify')).toEqual([]);
expect(t.ami.actions.filter((a) => a.Action === 'PJSIPQualify')).toEqual([
expect.objectContaining({ Endpoint: `trunk-${trunk.json().id}` }),
]);
const off = await req('PATCH', '/api/settings', { 'sip.tlsEnabled': false });
expect(off.statusCode).toBe(400);
expect(off.json().error.message).toMatch(/uses TLS/);
+11 -1
View File
@@ -7,7 +7,7 @@ import { join } from 'node:path';
import { eq } from 'drizzle-orm';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import type { LiveMessage } from '@selfphoneng/shared';
import { extensions, voicemailMessages } from '../src/db/schema.ts';
import { extensions, voicemailMessages, webhookDeliveries, webhooks } from '../src/db/schema.ts';
import { parseCallerId, type watchVoicemail } from '../src/services/voicemail.ts';
import { setSetting } from '../src/services/settings.ts';
import { hasTestDb } from './db.ts';
@@ -175,11 +175,21 @@ describe.skipIf(!hasTestDb)('voicemail watcher', () => {
const url = `http://127.0.0.1:${(stt.address() as AddressInfo).port}/v1`;
await setSetting(t.db, 'ai.stt', { url, model: 'whisper' });
await setSetting(t.db, 'ai.voicemailTranscripts', true);
await t.db
.insert(webhooks)
.values({ url: 'https://hooks.example.com/x', secretEnc: 'x', events: ['voicemail.new'] });
try {
leave('INBOX', 3, '1792300700-00000004', '<104>');
await w.scan('101');
const r = (await rows()).find((x) => x.msgId === '1792300700-00000004')!;
expect(r.transcript).toBe('Please call me.');
// A webhook goes off this server: the words stay here (10 §1, 08 §7 #6).
const [hook] = await t.db
.select()
.from(webhookDeliveries)
.where(eq(webhookDeliveries.event, 'voicemail.new'));
expect((hook!.payload as { data: object }).data).toMatchObject({ id: r.id });
expect((hook!.payload as { data: object }).data).not.toHaveProperty('transcript');
expect(t.mails.at(-1)!.text).toContain(
'What they said (automatic, may have mistakes):\nPlease call me.',
);
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@selfphoneng/probe",
"version": "0.0.8",
"version": "0.0.13",
"private": true,
"license": "AGPL-3.0-or-later",
"type": "module",
+14 -3
View File
@@ -1,10 +1,21 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// The network test probe (08 §2.6): run it outside the network SelfPhoneNG is in, and put its URL
// in Settings → Network. `node src/index.ts` (Node 24 runs TypeScript as is).
// PORT (default 8080), TRUST_PROXY=true behind a reverse proxy that sets X-Forwarded-For.
// PORT (default 8080), TRUST_PROXY=true behind a reverse proxy on this machine that sets
// X-Forwarded-For. HOST: where to listen; with TRUST_PROXY=true 127.0.0.1 (only the proxy may
// reach it), else every address. PROBE_KEY: the key from SelfPhoneNG's Settings → Network;
// without one anyone who finds the probe can use it.
import { createProbe } from './server.ts';
const port = Number(process.env.PORT ?? 8080);
const probe = createProbe({ trustProxy: process.env.TRUST_PROXY === 'true' });
probe.listen(port, () => console.log(`selfphoneng-probe listening on ${port}`));
const trustProxy = process.env.TRUST_PROXY === 'true';
const host = process.env.HOST ?? (trustProxy ? '127.0.0.1' : '::');
const key = process.env.PROBE_KEY || undefined;
const probe = createProbe({ trustProxy, key });
probe.listen(port, host, () => console.log(`selfphoneng-probe listening on ${host} ${port}`));
if (!key)
console.warn(
'No PROBE_KEY: anyone who finds this probe can use it. Make one in SelfPhoneNG ' +
'(Settings → Network → Generate key) and set PROBE_KEY.',
);
for (const sig of ['SIGINT', 'SIGTERM']) process.on(sig, () => probe.close(() => process.exit(0)));
+44 -3
View File
@@ -1,12 +1,18 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// The probe against stand-ins on 127.0.0.1: TCP, HTTP and a WebSocket upgrade, SIP over UDP and
// TCP, raw UDP; and the server: it only tests the address that asked, validates, rate-limits.
// TCP, raw UDP; and the server: it only tests the address that asked, validates, rate-limits,
// believes X-Forwarded-For only from a proxy on this machine (08 §7 #4), and with PROBE_KEY only
// answers who sends it (#9).
import { createSocket, type Socket as UdpSocket } from 'node:dgram';
import { createServer as httpServer, type Server as HttpServer } from 'node:http';
import {
createServer as httpServer,
type IncomingMessage,
type Server as HttpServer,
} from 'node:http';
import { createServer as tcpServer, type AddressInfo, type Server } from 'node:net';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { runTest } from './checks.ts';
import { createProbe, parseCheck } from './server.ts';
import { clientIp, createProbe, parseCheck } from './server.ts';
const port = (s: Server | HttpServer) => (s.address() as AddressInfo).port;
const listen = <T extends Server | HttpServer>(s: T) =>
@@ -165,3 +171,38 @@ describe('probe server', () => {
target.close();
});
});
describe('the probe key', () => {
it('with a key, only a request that sends it gets tested (08 §7 #9)', async () => {
const key = 'k3y'.repeat(15);
const probe = await listen(createProbe({ key }));
const post = (authorization?: string) =>
fetch(`http://127.0.0.1:${port(probe)}/v1/check`, {
method: 'POST',
headers: { 'content-type': 'application/json', ...(authorization && { authorization }) },
body: JSON.stringify({ host: 'pbx.test', tests: [{ id: 'a', kind: 'tcp', port: 1 }] }),
});
expect((await post()).status).toBe(401);
expect((await post('Bearer wrong')).status).toBe(401);
expect((await post(`Bearer ${key}x`)).status).toBe(401);
expect((await post(`Bearer ${key}`)).status).toBe(200);
expect((await fetch(`http://127.0.0.1:${port(probe)}/healthz`)).status).toBe(200);
probe.close();
});
});
describe('the address that asked', () => {
const req = (peer: string, xff?: string) =>
({
headers: xff ? { 'x-forwarded-for': xff } : {},
socket: { remoteAddress: peer },
}) as unknown as IncomingMessage;
it('takes X-Forwarded-For only from the local proxy (08 §7 #4)', () => {
// Reached straight from the internet: the header is whatever the caller wrote.
expect(clientIp(req('203.0.113.5', '198.51.100.9'), true)).toBe('203.0.113.5');
for (const proxy of ['127.0.0.1', '::ffff:127.0.0.1', '::1'])
expect(clientIp(req(proxy, '10.0.0.1, 198.51.100.9'), true)).toBe('198.51.100.9');
expect(clientIp(req('::ffff:203.0.113.5', '198.51.100.9'), false)).toBe('203.0.113.5');
});
});
+26 -8
View File
@@ -1,7 +1,9 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// POST /v1/check {host, tests}: runs the tests against the address the request came from and
// answers {ip, results}. GET /healthz. No other target can be given, which is what keeps the probe
// from being aimed at someone else; each address gets a few checks per 10 minutes.
// from being aimed at someone else; each address gets a few checks per 10 minutes. With a key
// (PROBE_KEY, made in SelfPhoneNG's Settings → Network), only who sends it is tested (08 §7 #9).
import { createHash, timingSafeEqual } from 'node:crypto';
import { createServer, type IncomingMessage, type Server } from 'node:http';
import { isIP } from 'node:net';
import { runTest, type Result, type Test } from './checks.ts';
@@ -9,6 +11,8 @@ import { runTest, type Result, type Test } from './checks.ts';
export type ProbeOptions = {
/** Behind a reverse proxy: the address is the last X-Forwarded-For entry (the proxy adds it). */
trustProxy?: boolean;
/** Only requests with `Authorization: Bearer <key>` are tested. None: anyone may ask. */
key?: string;
/** Checks per address per window. */
maxChecks?: number;
windowMs?: number;
@@ -48,15 +52,27 @@ export function parseCheck(body: unknown): { host: string; tests: Test[] } | str
return { host: b.host, tests };
}
const clientIp = (req: IncomingMessage, trustProxy: boolean) => {
/**
* The address to test. X-Forwarded-For only counts when the request came from a proxy on this
* machine: reached directly, the header is whatever the caller wrote (08 §7 #4).
*/
export const clientIp = (req: IncomingMessage, trustProxy: boolean) => {
const peer = req.socket.remoteAddress!.replace(/^::ffff:/, '');
const xff = req.headers['x-forwarded-for'];
const raw =
trustProxy && typeof xff === 'string'
? xff.split(',').at(-1)!.trim()
: req.socket.remoteAddress!;
return raw.replace(/^::ffff:/, '');
const local = peer === '127.0.0.1' || peer === '::1';
return trustProxy && local && typeof xff === 'string'
? xff
.split(',')
.at(-1)!
.trim()
.replace(/^::ffff:/, '')
: peer;
};
/** Equal, in the same time whatever was sent (hashes, so lengths don't show either). */
const sameKey = (a: string, b: string) =>
timingSafeEqual(createHash('sha256').update(a).digest(), createHash('sha256').update(b).digest());
function readJson(req: IncomingMessage): Promise<unknown> {
return new Promise((resolve, reject) => {
let size = 0;
@@ -78,7 +94,7 @@ function readJson(req: IncomingMessage): Promise<unknown> {
}
export function createProbe(opts: ProbeOptions = {}): Server {
const { trustProxy = false, maxChecks = 10, windowMs = 10 * 60_000 } = opts;
const { trustProxy = false, key, maxChecks = 10, windowMs = 10 * 60_000 } = opts;
// ponytail: in-process memory; addresses past the window are swept on each check.
const seen = new Map<string, number[]>();
const allow = (ip: string, now = Date.now()) => {
@@ -95,6 +111,8 @@ export function createProbe(opts: ProbeOptions = {}): Server {
if (req.method === 'GET' && req.url === '/healthz') return send(res, 200, { ok: true });
if (req.method !== 'POST' || req.url !== '/v1/check')
return send(res, 404, { error: 'not found' });
if (key && !sameKey(req.headers.authorization ?? '', `Bearer ${key}`))
return send(res, 401, { error: 'this probe needs its key' });
const ip = clientIp(req, trustProxy);
void readJson(req)
.then(async (body) => {
+14 -14
View File
@@ -5,19 +5,19 @@ proxy (`https://<your host>/me`). You need: a user with an extension that has th
softphone on (say 101), a second phone (desk phone or phone app, say 102), and a third extension
to transfer to (say 103, any phone). Headphones help with echo.
| # | Do this | Pass when |
| --- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| 1 | Sign in as the user, click **Enable softphone**, allow the microphone. | Status turns **Ready**; Admin → Dashboard shows 101 online. |
| 2 | Reload the page. | It comes back **Ready** without asking again. |
| 3 | Open `/me` in a second tab. | The second tab says **Active in another tab**. Click **Use here**: it becomes **Ready** and the first tab says **Active in another tab**. Close the second tab and click **Use here** in the first. |
| 4 | Open **Keypad**, type `102` (also try typing on the keyboard and pressing Enter), call. | 102 rings; when answered both sides hear each other; the card shows a running timer. |
| 5 | Press **Mute**, talk, then **Unmute**. | 102 hears nothing while muted, then hears you again. |
| 6 | Press **Hold**, then **Resume**. | 102 hears hold music, then you again; the card says **On hold** meanwhile. |
| 7 | During the call open the call's **Keypad** and press `1 2 3`. If 102 is a voicemail menu or an IVR, it reacts; otherwise call `*97` instead and enter your PIN. | The tones are recognized. |
| 8 | Hang up. From 102 call 101. | The **Incoming call** dialog shows 102's name, the ringtone plays; **Answer** connects with two-way audio. Hang up from 102: the card goes away. |
| 9 | Switch to another tab or app, then have 102 call 101 again (first allow notifications with **Notify me of calls**). | A desktop notification appears; clicking it brings the tab back. **Decline** sends 102 to busy/voicemail. |
| 10 | While on a call with 102, have 103 call 101. | A second, smaller **Incoming call** card appears (no dialog). **Answer** puts 102 on hold (hears music) and connects 103. Hang up both. |
| 11 | Call 102, **Transfer** → type `103` → **Transfer now**. | 102 is connected to 103; the browser call ends. |
| 12 | Call 102, **Transfer** → `103` → **Talk first**; talk to 103; **Complete transfer**. Repeat with **Cancel** instead. | Complete: 102 and 103 are connected, the browser is out. Cancel: 103's call ends and you're back with 102. Also check **Microphone/Speaker** selects list your devices and a change takes effect on the next call. |
| # | Do this | Pass when |
| --- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| 1 | Sign in as the user, click **Enable softphone**, allow the microphone. | Status turns **Ready**; Admin → Dashboard shows 101 online. |
| 2 | Reload the page. | It comes back **Ready** without asking again. |
| 3 | Open `/me` in a second tab. | The second tab says **Active in another tab**. Click **Use here**: it becomes **Ready** and the first tab says **Active in another tab**. Close the second tab and click **Use here** in the first. |
| 4 | Open **Keypad**, type `102` (also try typing on the keyboard and pressing Enter), call. | 102 rings; when answered both sides hear each other; the card shows a running timer. |
| 5 | Press **Mute**, talk, then **Unmute**. | 102 hears nothing while muted, then hears you again. |
| 6 | Press **Hold**, then **Resume**. | 102 hears hold music, then you again; the card says **On hold** meanwhile. |
| 7 | During the call open the call's **Keypad** and press `1 2 3`. If 102 is a voicemail menu or an IVR, it reacts; otherwise call `*97` instead and enter your PIN. | The tones are recognized. |
| 8 | Hang up. From 102 call 101. | The **Incoming call** dialog shows 102's name, the ringtone plays; **Answer** connects with two-way audio. Hang up from 102: the card goes away. |
| 9 | Switch to another tab or app, then have 102 call 101 again (first allow notifications with **Notify me of calls**). | A desktop notification appears; clicking it brings the tab back. In Chrome/Edge, its **Answer** button answers and brings the tab back, its **Decline** button declines; it closes by itself if 102 hangs up first. **Decline** sends 102 to busy/voicemail. |
| 10 | While on a call with 102, have 103 call 101. | A second, smaller **Incoming call** card appears (no dialog). **Answer** puts 102 on hold (hears music) and connects 103. Hang up both. |
| 11 | Call 102, **Transfer** → type `103` → **Transfer now**. | 102 is connected to 103; the browser call ends. |
| 12 | Call 102, **Transfer** → `103` → **Talk first**; talk to 103; **Complete transfer**. Repeat with **Cancel** instead. | Complete: 102 and 103 are connected, the browser is out. Cancel: 103's call ends and you're back with 102. Also check **Microphone/Speaker** selects list your devices and a change takes effect on the next call. |
Record the result (browser, version, pass/fail per step) in the PR or issue for M5-T2.
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@selfphoneng/web",
"version": "0.0.8",
"version": "0.0.13",
"private": true,
"license": "AGPL-3.0-or-later",
"type": "module",
@@ -13,6 +13,7 @@
"@hookform/resolvers": "^5.9.1",
"@selfphoneng/shared": "workspace:*",
"@tanstack/react-query": "^5.104.3",
"lucide-react": "^1.56.0",
"react": "^19.3.1",
"react-dom": "^19.3.1",
"react-hook-form": "^7.90.0",
+34
View File
@@ -0,0 +1,34 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// The softphone's incoming call notification (05 §5). Only a service worker's notifications can
// carry buttons. The softphone tab asks for one here; Answer/Decline go back to that tab only.
/* global self */
self.addEventListener('message', (e) => {
const call = e.data?.notifyCall;
if (!call) return;
e.waitUntil(
self.registration.showNotification('Incoming call', {
body: call.body,
tag: call.id,
requireInteraction: true,
actions: [
{ action: 'answer', title: 'Answer' },
{ action: 'decline', title: 'Decline' },
],
data: { id: call.id, tab: e.source.id },
}),
);
});
self.addEventListener('notificationclick', (e) => {
e.notification.close();
const { id, tab } = e.notification.data;
e.waitUntil(
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then(async (all) => {
const client = all.find((c) => c.id === tab);
if (!client) return;
if (e.action) client.postMessage({ softphone: e.action, id });
// Declining needs nothing from the tab; otherwise bring it back.
if (e.action !== 'decline') await client.focus();
}),
);
});
+30
View File
@@ -3,6 +3,8 @@
import type {
LdapInput,
LdapTestResult,
SpamCheck,
SpamTestResult,
AuthMethods,
OidcInput,
OidcTestResult,
@@ -24,6 +26,8 @@ import type {
AnnouncementUpdate,
BlockedCreate,
BlockedDto,
SpamReportCreate,
SpamReportDto,
CallDetailDto,
CallDto,
CallFilter,
@@ -36,6 +40,8 @@ import type {
ContactShare,
ContactShareDto,
MyCallDto,
MobileDeviceDto,
MobilePairCode,
RecordingDto,
AuthUser,
DidCreate,
@@ -253,10 +259,16 @@ export const settingsApi = {
'/api/settings/emergency/test',
),
warnings: () => api<{ items: Warning[] }>('GET', '/api/system/warnings').then((r) => r.items),
dismissWarning: (code: string) =>
api<{ ok: true }>('POST', `/api/system/warnings/${code}/dismiss`),
testOidc: (v: OidcInput) => api<OidcTestResult>('POST', '/api/settings/auth/oidc/test', v),
/** 12 §4.1 */
testLdap: (v: LdapInput & { testLogin?: string; testPassword?: string }) =>
api<LdapTestResult>('POST', '/api/settings/auth/ldap/test', v),
/** 14 §7.1: the typed key, else the saved one. */
syncFtc: () => api<{ ok: true }>('POST', '/api/settings/spam/ftc/sync'),
testSpam: (provider: 'ipqs' | 'ftc', key?: string) =>
api<SpamTestResult>('POST', `/api/settings/spam/${provider}/test`, { key }),
};
/** Sign out, and when the session came from OpenID, at the provider too (12 §3.5). */
@@ -283,6 +295,9 @@ export const systemApi = {
api<{ ok: true }>('POST', `/api/system/asterisk/restart?now=${now}`),
detectPublicIp: () =>
api<{ ip: string }>('POST', '/api/settings/network/detect-public-ip').then((r) => r.ip),
/** A new key for the network test probe, shown this once (08 §7 #9). */
probeKey: () =>
api<{ key: string }>('POST', '/api/settings/network/probe-key').then((r) => r.key),
tls: () => api<TlsCert>('GET', '/api/system/tls'),
diagnostics: () => api<DiagnosticsDto>('GET', '/api/system/diagnostics'),
networkTest: (mode: 'quick' | 'complete') =>
@@ -296,13 +311,26 @@ export const systemApi = {
export const meApi = {
calls: (cursor?: string) =>
api<ListResponse<MyCallDto>>('GET', `/api/me/calls${cursor ? `?cursor=${cursor}` : ''}`),
reportSpam: (r: SpamReportCreate) => api<{ ok: true }>('POST', '/api/me/spam-reports', r),
/** The summary of the calls made to me. */
report: (r: ReportRange) => api<ReportSummary>('GET', `/api/me/reports/summary?${callQuery(r)}`),
get: () => api<MeDto>('GET', '/api/me'),
/** 14 §5.1: only for a caller ringing me now. */
callerCheck: (number: string) =>
api<{ check: SpamCheck | null }>(
'GET',
`/api/me/caller-check?number=${encodeURIComponent(number)}`,
).then((r) => r.check),
softphoneQr: () => api<SoftphoneQr>('GET', '/api/me/softphone-qr'),
update: (m: MeUpdate) => api<MeDto>('PATCH', '/api/me', m),
connectContrack: (token: string) => api<MeDto>('PUT', '/api/me/contrack', { token }),
disconnectContrack: () => api<MeDto>('DELETE', '/api/me/contrack'),
contrackOptions: (o: ContrackOptions) => api<MeDto>('PATCH', '/api/me/contrack', o),
webrtc: () => api<WebRtcCreds>('GET', '/api/me/webrtc'),
pairPhone: () => api<MobilePairCode>('POST', '/api/me/mobile-devices/pair'),
phones: () =>
api<ListResponse<MobileDeviceDto>>('GET', '/api/me/mobile-devices').then((r) => r.items),
removePhone: (id: string) => api<{ ok: true }>('DELETE', `/api/me/mobile-devices/${id}`),
/** Log in to (unpause) or out of (pause) a queue I'm an agent of. */
setQueuePaused: (queueId: string, paused: boolean) =>
api<{ ok: true }>('POST', `/api/me/queues/${queueId}/${paused ? 'pause' : 'unpause'}`),
@@ -485,6 +513,8 @@ export const blockedApi = {
list: () => api<ListResponse<BlockedDto>>('GET', '/api/blocked-numbers').then((r) => r.items),
create: (b: BlockedCreate) => api<BlockedDto>('POST', '/api/blocked-numbers', b),
remove: (id: string) => api<{ ok: true }>('DELETE', `/api/blocked-numbers/${id}`),
reports: () => api<ListResponse<SpamReportDto>>('GET', '/api/spam-reports').then((r) => r.items),
dismiss: (id: string) => api<{ ok: true }>('DELETE', `/api/spam-reports/${id}`),
};
/** Filters as a query string (empty values left out). */
+1 -1
View File
@@ -26,7 +26,7 @@ export function RequireAuth({
}) {
const me = useMe();
const loc = useLocation();
if (me.isPending) return <p className="p-6 text-sm text-zinc-500">Loading…</p>;
if (me.isPending) return <p className="p-6 text-sm text-muted">Loading…</p>;
if (me.isError) {
if (me.error instanceof ApiError && me.error.status === 401)
return <Navigate to={`/login?next=${encodeURIComponent(loc.pathname)}`} replace />;
@@ -143,7 +143,7 @@ export function DestinationPicker({
</p>
)}
{isIdKind(kind) && all[kind] && targets(kind).length === 0 && (
<p className="text-xs text-zinc-500">Nothing to pick yet ({what}).</p>
<p className="text-xs text-muted">Nothing to pick yet ({what}).</p>
)}
</fieldset>
);
+6 -5
View File
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Native <dialog> (focus trap + Esc for free) as a centered modal or a right-hand drawer.
import { X } from 'lucide-react';
import { useEffect, useRef, type ReactNode } from 'react';
import { Button } from './ui.tsx';
@@ -28,27 +29,27 @@ export function Dialog({
const shape = drawer
? 'ml-auto mr-0 h-dvh max-h-dvh w-full max-w-md'
: 'm-auto w-[calc(100%-2rem)] max-w-md rounded-lg';
: 'm-auto w-[calc(100%-2rem)] max-w-md rounded-xl';
return (
<dialog
ref={ref}
onClose={onClose}
onClick={(e) => e.target === ref.current && onClose()}
aria-labelledby="dialog-title"
className={`${shape} bg-white p-0 text-zinc-900 shadow-xl backdrop:bg-black/40 dark:bg-zinc-900 dark:text-zinc-100`}
className={`${shape} bg-surface p-0 text-fg shadow-xl backdrop:bg-black/40`}
>
<div className="flex h-full flex-col">
<header className="flex items-center justify-between border-b border-zinc-200 px-4 py-3 dark:border-zinc-800">
<header className="flex items-center justify-between border-b border-line px-4 py-3">
<h2 id="dialog-title" className="text-base font-semibold">
{title}
</h2>
<Button variant="ghost" onClick={onClose} aria-label="Close" className="px-2 py-1">
✕
<X className="size-4" />
</Button>
</header>
<div className="flex-1 overflow-y-auto p-4">{children}</div>
{footer && (
<footer className="flex justify-end gap-2 border-t border-zinc-200 px-4 py-3 dark:border-zinc-800">
<footer className="flex justify-end gap-2 border-t border-line px-4 py-3">
{footer}
</footer>
)}
+5 -5
View File
@@ -15,13 +15,13 @@ export function ParkedCalls({ onPickUp }: { onPickUp?: (slot: string) => void })
Parked calls
</h2>
{parked.length === 0 ? (
<p className="text-sm text-zinc-500">
<p className="text-sm text-muted">
None. Transfer a call to 700 to park it; dial the slot to pick it up.
</p>
) : (
<ul
aria-label="Parked calls"
className="divide-y divide-zinc-100 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800"
className="divide-y divide-line rounded-xl border border-line bg-surface shadow-sm"
>
{parked.map((p) => (
<li
@@ -31,14 +31,14 @@ export function ParkedCalls({ onPickUp }: { onPickUp?: (slot: string) => void })
<span className="w-10 font-mono font-semibold">{p.slot}</span>
<span className="min-w-0 flex-1 truncate">
{p.callerName ? `${p.callerName} ` : ''}
<span className="text-zinc-500">{fmtNumber(p.callerNumber)}</span>
{p.parkedBy && <span className="text-zinc-500"> · parked by {p.parkedBy}</span>}
<span className="text-muted">{fmtNumber(p.callerNumber)}</span>
{p.parkedBy && <span className="text-muted"> · parked by {p.parkedBy}</span>}
</span>
<span className="font-mono tabular-nums">{since(p.since, now)}</span>
{onPickUp && (
<button
type="button"
className="rounded-md border border-zinc-300 px-2 py-1 dark:border-zinc-700"
className="rounded-md border border-line-strong bg-surface px-2 py-1"
onClick={() => onPickUp(p.slot)}
>
Pick up {p.slot}
+3 -3
View File
@@ -14,7 +14,7 @@ export function PhoneAppSetup({ load }: { load: () => Promise<SoftphoneQr> }) {
code: in the app, choose <em>Fetch remote configuration</em> (or <em>Scan QR code</em>) and
point the camera at the code below. On a computer, paste the link instead.
</p>
<p className="text-zinc-600 dark:text-zinc-400">
<p className="text-muted">
<strong>MicroSIP</strong> (Windows) and <strong>Baresip</strong> (Linux) work too, set up by
hand: an admin can see the server, user name and password.
</p>
@@ -32,14 +32,14 @@ export function PhoneAppSetup({ load }: { load: () => Promise<SoftphoneQr> }) {
className="rounded bg-white"
/>
<p className="font-mono text-xs break-all">{qr.data.url}</p>
<p className="text-xs text-zinc-500">
<p className="text-xs text-muted">
Works until {new Date(qr.data.expiresAt).toLocaleString()}. It carries your phone
password: don't share it.
</p>
</div>
)}
{qr.error && <Alert>{qr.error.message}</Alert>}
<p className="text-xs text-zinc-500">
<p className="text-xs text-muted">
On a mobile phone, apps can miss calls while they're in the background (iPhones and
battery-saving Android phones stop them). For calls you can't miss, use a desk phone or this
browser, or forward unanswered calls to your mobile number.
+4 -4
View File
@@ -56,11 +56,11 @@ export function SignInCard() {
<div className="flex flex-col gap-1 text-sm">
<h3 className="font-medium">Password</h3>
{ways.password ? (
<Link className="text-sky-700 underline dark:text-sky-400" to="/change-password">
<Link className="text-link underline" to="/change-password">
Change password
</Link>
) : (
<p className="text-zinc-500">
<p className="text-muted">
You have no password here. An admin can give you one with Reset password.
</p>
)}
@@ -92,7 +92,7 @@ export function SignInCard() {
</>
) : (
<>
<p className="text-zinc-500">
<p className="text-muted">
Connect your {oidc.label} account to sign in with it as well.
</p>
<div>
@@ -133,7 +133,7 @@ function DirectoryConnect() {
}}
>
<h3 className="font-medium">Company directory</h3>
<p className="text-zinc-500">
<p className="text-muted">
Connect your directory account to sign in with its password as well.
</p>
<Field label="Directory username or email">
+2 -1
View File
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// SoundPicker (05 §3): pick a recorded prompt, play it, or upload a new one right here.
import { Play } from 'lucide-react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useId, useRef } from 'react';
import { soundsApi } from '../api/client.ts';
@@ -63,7 +64,7 @@ export function SoundPicker({
void a.play().catch(() => undefined);
}}
>
▶
<Play className="size-4" />
</Button>
<Button onClick={() => file.current?.click()} disabled={upload.isPending}>
{upload.isPending ? 'Uploading…' : 'Upload new'}
+32
View File
@@ -0,0 +1,32 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import { expect, it } from 'vitest';
import { spamLine } from './SpamNote.tsx';
it('says what the spam check knows, in one line (14 §5.1)', () => {
expect(
spamLine({
risk: 'high',
provider: 'ipqs',
info: {
fraudScore: 92,
spammer: true,
recentAbuse: false,
lineType: 'VOIP',
carrier: 'Verizon',
name: null,
city: 'Austin',
region: 'TX',
},
}),
).toBe('Fraud score 92 · spammer · VOIP · Verizon · Austin, TX');
const ftc = (complaints: number, robocalls: number, subject: string | null) =>
spamLine({
risk: 'high',
provider: 'ftc',
info: { complaints, robocalls, lastReportedAt: '2026-10-20T15:00:00Z', subject },
});
expect(ftc(4, 3, 'Warranties')).toMatch(
/^Reported to the FTC 4 times \(3 robocalls\), last on .+: Warranties$/,
);
expect(ftc(1, 0, null)).toMatch(/^Reported to the FTC once, last on [^:]+$/);
});
+50
View File
@@ -0,0 +1,50 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// What the spam check said about a caller (14 §5.1): a badge and a line, for high and medium
// risk only. Shown in the softphone and call history.
import type { SpamCheck } from '@selfphoneng/shared';
const day = (iso: string) =>
new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
/** "Fraud score 92 · spammer · VoIP · Verizon · Austin, TX", or "Reported to the FTC 4 times…". */
export function spamLine(c: SpamCheck): string {
if (c.provider === 'ftc') {
const i = c.info;
const times = i.complaints === 1 ? 'once' : `${i.complaints} times`;
const robo = i.robocalls ? ` (${i.robocalls} robocall${i.robocalls === 1 ? '' : 's'})` : '';
const last = i.lastReportedAt ? `, last on ${day(i.lastReportedAt)}` : '';
return `Reported to the FTC ${times}${robo}${last}${i.subject ? `: ${i.subject}` : ''}`;
}
const i = c.info;
const place = [i.city, i.region].filter(Boolean).join(', ');
return [
i.name,
`Fraud score ${i.fraudScore}`,
i.spammer && 'spammer',
i.recentAbuse && 'recent abuse',
i.lineType,
i.carrier,
place,
]
.filter(Boolean)
.join(' · ');
}
export function SpamNote({ check }: { check: SpamCheck | null | undefined }) {
if (!check || check.risk === 'low') return null;
const high = check.risk === 'high';
return (
<span className="flex flex-wrap items-center gap-x-2 text-sm">
<span
className={`rounded px-1.5 py-0.5 text-xs font-semibold ${
high
? 'bg-red-100 text-red-800 dark:bg-red-950 dark:text-red-200'
: 'bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-200'
}`}
>
{high ? 'Likely spam' : 'Possible spam'}
</span>
<span className="text-muted">{spamLine(check)}</span>
</span>
);
}
+2 -1
View File
@@ -1,4 +1,5 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import { Moon, Sun } from 'lucide-react';
import { useState } from 'react';
import { toggleTheme } from '../theme.ts';
import { Button } from './ui.tsx';
@@ -12,7 +13,7 @@ export function ThemeToggle() {
aria-label={dark ? 'Switch to light mode' : 'Switch to dark mode'}
onClick={() => setDark(toggleTheme())}
>
{dark ? '☀' : '☾'}
{dark ? <Sun className="size-4" /> : <Moon className="size-4" />}
</Button>
);
}
+35 -9
View File
@@ -3,15 +3,14 @@
import { useId, type ComponentProps, type ReactNode } from 'react';
import { APP_NAME } from '@selfphoneng/shared';
const focus =
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-500';
export const focusRing =
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus';
const variants = {
primary: 'bg-sky-600 text-white hover:bg-sky-700 disabled:bg-sky-600/50',
secondary:
'border border-zinc-300 bg-white hover:bg-zinc-100 dark:border-zinc-700 dark:bg-zinc-900 dark:hover:bg-zinc-800',
primary: 'bg-primary text-white hover:bg-primary-hover disabled:bg-primary/50',
secondary: 'border border-line-strong bg-surface hover:bg-subtle',
danger: 'bg-red-600 text-white hover:bg-red-700 disabled:bg-red-600/50',
ghost: 'hover:bg-zinc-100 dark:hover:bg-zinc-800',
ghost: 'hover:bg-subtle',
};
/** The icon (public/icon-192.png, cut from public/logo.png) and the product name. */
@@ -32,13 +31,40 @@ export function Button({
return (
<button
type="button"
className={`inline-flex items-center justify-center gap-2 rounded-md px-3 py-2 text-sm font-medium disabled:cursor-not-allowed ${variants[variant]} ${focus} ${className}`}
className={`inline-flex items-center justify-center gap-2 rounded-md px-3 py-2 text-sm font-medium disabled:cursor-not-allowed pointer-coarse:min-h-11 pointer-coarse:min-w-11 ${variants[variant]} ${focusRing} ${className}`}
{...props}
/>
);
}
export const inputClass = `w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm dark:border-zinc-700 dark:bg-zinc-900 ${focus} aria-invalid:border-red-500`;
/** A titled card: heading, an optional link or button on the right, then the body. */
export function Panel({
title,
action,
children,
}: {
title: string;
action?: ReactNode;
children: ReactNode;
}) {
const id = useId();
return (
<section
aria-labelledby={id}
className="flex flex-col gap-3 rounded-xl border border-line bg-surface p-4 shadow-sm"
>
<div className="flex items-center justify-between gap-2">
<h2 id={id} className="text-base font-semibold">
{title}
</h2>
{action}
</div>
{children}
</section>
);
}
export const inputClass = `w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm ${focusRing} aria-invalid:border-red-500`;
/** Label + control + error text. `children` gets the id and aria props to spread on the control. */
export function Field({
@@ -67,7 +93,7 @@ export function Field({
{(error || hint) && (
<p
id={msgId}
className={`text-xs ${error ? 'text-red-600 dark:text-red-400' : 'text-zinc-500'}`}
className={`text-xs ${error ? 'text-red-600 dark:text-red-400' : 'text-muted'}`}
>
{error ?? hint}
</p>
+64
View File
@@ -1,2 +1,66 @@
@import 'tailwindcss';
@custom-variant dark (&:where(.dark, .dark *));
/* Theme colors: the logo's blue and navy. Pages use these names (bg-surface, text-muted,
border-line...), not palette colors, so light and dark are paired here once. Every text pair
is at least 4.5:1 in both themes. */
:root {
color-scheme: light;
--sp-page: #f8fafc;
--sp-surface: #ffffff;
--sp-subtle: #f1f5f9;
--sp-line: #e2e8f0;
--sp-line-strong: #cbd5e1;
--sp-fg: #0f172a;
--sp-muted: #475569;
--sp-primary: #2563eb;
--sp-primary-hover: #1d4ed8;
--sp-primary-soft: #eff6ff;
--sp-primary-line: #bfdbfe;
--sp-primary-ink: #1e3a8a;
--sp-link: #1d4ed8;
--sp-focus: #2563eb;
}
.dark {
color-scheme: dark;
--sp-page: #0b1120;
--sp-surface: #131c2e;
--sp-subtle: #1e293b;
--sp-line: #253349;
--sp-line-strong: #334155;
--sp-fg: #f1f5f9;
--sp-muted: #94a3b8;
--sp-primary: #2563eb;
--sp-primary-hover: #1d4ed8;
--sp-primary-soft: #172554;
--sp-primary-line: #1e3a8a;
--sp-primary-ink: #dbeafe;
--sp-link: #60a5fa;
--sp-focus: #60a5fa;
}
@theme inline {
--color-page: var(--sp-page);
--color-surface: var(--sp-surface);
--color-subtle: var(--sp-subtle);
--color-line: var(--sp-line);
--color-line-strong: var(--sp-line-strong);
--color-fg: var(--sp-fg);
--color-muted: var(--sp-muted);
--color-primary: var(--sp-primary);
--color-primary-hover: var(--sp-primary-hover);
--color-primary-soft: var(--sp-primary-soft);
--color-primary-line: var(--sp-primary-line);
--color-primary-ink: var(--sp-primary-ink);
--color-link: var(--sp-link);
--color-focus: var(--sp-focus);
}
/* iOS zooms into any field under 16px on focus and stays zoomed, which left the incoming call
dialog off screen. Unlayered, so it beats text-sm. */
@media (pointer: coarse) {
input,
select,
textarea {
font-size: 16px;
}
}
+1 -1
View File
@@ -104,7 +104,7 @@ const router = createBrowserRouter([
const queryClient = new QueryClient();
document.body.className = 'bg-white text-zinc-900 dark:bg-zinc-950 dark:text-zinc-100';
document.body.className = 'bg-page text-fg';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
+4 -4
View File
@@ -6,7 +6,7 @@ import { Link } from 'react-router';
import { aboutApi } from '../api/client.ts';
import { Alert, Brand } from '../components/ui.tsx';
const linkClass = 'text-sky-700 underline dark:text-sky-400';
const linkClass = 'text-link underline';
export function AboutPage() {
const about = useQuery({ queryKey: ['about'], queryFn: aboutApi.get });
@@ -44,18 +44,18 @@ export function AboutPage() {
<a
href="/api/about/source"
download="selfphone.tar.gz"
className="rounded-md bg-sky-600 px-4 py-2 font-medium text-white hover:bg-sky-700"
className="rounded-md bg-primary px-4 py-2 font-medium text-white hover:bg-primary-hover"
>
Code v{a.version}
</a>
<span className="text-zinc-500">
<span className="text-muted">
selfphone.tar.gz, {Math.max(1, Math.round(a.source.bytes / 1e6))} MB, built{' '}
{new Date(a.source.builtAt).toLocaleString()}
</span>
</div>
) : (
a && (
<p className="text-zinc-500">
<p className="text-muted">
This is a development build: the source archive is only in builds made with Docker.
The project&apos;s repository is below.
</p>
+1 -1
View File
@@ -44,7 +44,7 @@ export function ChangePasswordPage() {
return (
<AuthCard title="Change your password">
{forced && (
<p className="mb-4 text-sm text-zinc-600 dark:text-zinc-400">
<p className="mb-4 text-sm text-muted">
You signed in with a temporary password. Choose a new one to continue.
</p>
)}
+5 -5
View File
@@ -13,12 +13,12 @@ import { Alert, Button, Field, inputClass } from '../components/ui.tsx';
export function AuthCard({ title, children }: { title: string; children: React.ReactNode }) {
return (
<main className="flex min-h-dvh flex-col items-center justify-center p-4">
<div className="w-full max-w-sm rounded-lg border border-zinc-200 bg-white p-6 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<div className="w-full max-w-sm rounded-xl border border-line bg-surface p-6 shadow-sm">
<img src="/logo-512.png" alt={APP_NAME} className="mx-auto mb-4 size-32 rounded-xl" />
<h1 className="mb-5 text-xl font-semibold">{title}</h1>
{children}
</div>
<Link to="/about" className="mt-3 text-xs text-zinc-500 hover:underline">
<Link to="/about" className="mt-3 text-xs text-muted hover:underline">
About SelfPhoneNG · source code
</Link>
</main>
@@ -73,12 +73,12 @@ export function LoginPage() {
{/* A full page load: the browser goes to the provider and comes back signed in. */}
<a
href={`/api/auth/oidc/start${next ? `?next=${encodeURIComponent(next)}` : ''}`}
className="rounded-md bg-sky-700 px-4 py-2 text-center text-sm font-medium text-white hover:bg-sky-800"
className="rounded-md bg-primary px-4 py-2 text-center text-sm font-medium text-white hover:bg-primary-hover"
>
Sign in with {oidc.label}
</a>
{showForm && (
<p className="text-center text-xs text-zinc-500" aria-hidden>
<p className="text-center text-xs text-muted" aria-hidden>
or with your password
</p>
)}
@@ -87,7 +87,7 @@ export function LoginPage() {
{!showForm && (
<button
type="button"
className="w-full text-center text-xs text-zinc-500 hover:underline"
className="w-full text-center text-xs text-muted hover:underline"
onClick={() => setAdminForm(true)}
>
Admin sign-in
+23 -18
View File
@@ -2,6 +2,7 @@
// First-run setup wizard (05 §2.1). Every step saves before moving on; some can be skipped.
// M3 also brings steps 7 (users) and 8 (main number routing) because "calls work using only the
// wizard" needs them; M6 adds "Ring everyone" to step 8 (an IVR needs a recorded greeting first).
import { Check } from 'lucide-react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useState, type ReactNode } from 'react';
import { useNavigate, useSearchParams } from 'react-router';
@@ -70,7 +71,7 @@ export function SetupPage() {
type="button"
onClick={() => go(i + 1)}
aria-current={step === i + 1 ? 'step' : undefined}
className={`rounded-full px-2 py-1 ${step === i + 1 ? 'bg-sky-600 text-white' : 'bg-zinc-100 dark:bg-zinc-800'}`}
className={`rounded-full px-2 py-1 ${step === i + 1 ? 'bg-primary text-white' : 'bg-subtle'}`}
>
{i + 1}. {s}
</button>
@@ -109,7 +110,7 @@ function Nav({
hint?: string;
}) {
return (
<div className="flex flex-wrap items-center gap-2 border-t border-zinc-200 pt-4 dark:border-zinc-800">
<div className="flex flex-wrap items-center gap-2 border-t border-line pt-4">
<Button variant="primary" onClick={next} disabled={!canNext}>
Next
</Button>
@@ -118,7 +119,7 @@ function Nav({
{skip}
</Button>
)}
{hint && <span className="text-sm text-zinc-500">{hint}</span>}
{hint && <span className="text-sm text-muted">{hint}</span>}
</div>
);
}
@@ -127,7 +128,10 @@ function PasswordStep({ next }: { next: () => void }) {
// Signing in with the initial password already forced a change (RequireAuth).
return (
<>
<p className="text-sm">✓ The initial admin password has been changed.</p>
<p className="text-sm">
<Check className="mr-1 inline size-4 align-[-3px] text-green-700 dark:text-green-400" />
The initial admin password has been changed.
</p>
<Nav next={next} />
</>
);
@@ -196,7 +200,7 @@ function CompanyForm({
</select>
)}
</Field>
<div className="border-t border-zinc-200 pt-4 dark:border-zinc-800">
<div className="border-t border-line pt-4">
<Button variant="primary" type="submit" disabled={save.isPending || !name.trim()}>
Save and continue
</Button>
@@ -343,7 +347,7 @@ function NetworkForm(p: {
I&apos;ve done this
</label>
</div>
<div className="border-t border-zinc-200 pt-4 dark:border-zinc-800">
<div className="border-t border-line pt-4">
<Button variant="primary" type="submit" disabled={save.isPending || !done}>
{save.isPending ? 'Saving…' : 'Save and continue'}
</Button>
@@ -399,7 +403,7 @@ function TrunkStep({ next }: { next: () => void }) {
const ok = status?.label === 'Registered' || status?.label === 'Reachable';
return (
<>
<p className="text-sm text-zinc-500">
<p className="text-sm text-muted">
Connect your voip.ms sub-account. Follow the checklist, save, and wait for{' '}
<strong>Registered</strong>.
</p>
@@ -457,7 +461,7 @@ function NumbersStep({ next }: { next: () => void }) {
);
return (
<>
<p className="text-sm text-zinc-500">
<p className="text-sm text-muted">
Add the phone numbers (DIDs) you have at voip.ms. You choose where their calls go in step 9.
</p>
{trunks.data[0]!.hasApiPassword && <ImportNumbers trunk={trunks.data[0]!} />}
@@ -465,8 +469,8 @@ function NumbersStep({ next }: { next: () => void }) {
<ul className="text-sm">
{dids.data.map((d) => (
<li key={d.id}>
✓ {formatNumber(d.number)}{' '}
{d.label && <span className="text-zinc-500">{d.label}</span>}
<Check className="mr-1 inline size-4 align-[-3px] text-green-700 dark:text-green-400" />
{formatNumber(d.number)} {d.label && <span className="text-muted">{d.label}</span>}
</li>
))}
</ul>
@@ -554,7 +558,7 @@ function UsersStep({ next }: { next: () => void }) {
return (
<>
<p className="text-sm text-zinc-500">
<p className="text-sm text-muted">
Add the people who get a phone (up to 10 here; more later under Users &amp; Extensions).
Each gets the extension you type (any free number, like 1), or the next free one, and a
temporary password, shown once.
@@ -563,7 +567,8 @@ function UsersStep({ next }: { next: () => void }) {
<ul className="text-sm">
{withExt.map((u) => (
<li key={u.id}>
✓ {u.extension!.number} {u.displayName}
<Check className="mr-1 inline size-4 align-[-3px] text-green-700 dark:text-green-400" />
{u.extension!.number} {u.displayName}
</li>
))}
</ul>
@@ -614,7 +619,7 @@ function UsersStep({ next }: { next: () => void }) {
</form>
{save.isError && <Alert>{save.error.message}</Alert>}
{created.length > 0 && (
<div className="rounded-md border border-zinc-200 p-3 text-sm dark:border-zinc-800">
<div className="rounded-md border border-line p-3 text-sm">
<p className="mb-2 font-medium">
Give each person their temporary password (shown only now):
</p>
@@ -624,7 +629,7 @@ function UsersStep({ next }: { next: () => void }) {
<tr key={c.email}>
<td>{c.number}</td>
<td>{c.name}</td>
<td className="text-zinc-500">{c.email}</td>
<td className="text-muted">{c.email}</td>
<td className="font-mono">{c.password}</td>
</tr>
))}
@@ -682,7 +687,7 @@ function RoutingStep({ next }: { next: () => void }) {
const incomplete = Object.values(dest).some((d) => d === null);
return (
<>
<p className="text-xs text-zinc-500">
<p className="text-xs text-muted">
A digital receptionist ("press 1 for sales…") needs a recorded greeting: build it under
Digital receptionist later and pick it here or on the Phone numbers page.
</p>
@@ -715,7 +720,7 @@ function RoutingStep({ next }: { next: () => void }) {
})}
{everyone.isError && <Alert>{everyone.error.message}</Alert>}
{save.isError && <Alert>{save.error.message}</Alert>}
<div className="border-t border-zinc-200 pt-4 dark:border-zinc-800">
<div className="border-t border-line pt-4">
<Button
variant="primary"
onClick={() => save.mutate()}
@@ -740,7 +745,7 @@ function DoneStep({ onFinish, busy }: { onFinish: () => void; busy: boolean }) {
<li>From a desk phone, call your mobile: it shows your main number as caller ID.</li>
<li>Everything here can be changed later in the admin pages.</li>
</ul>
<div className="border-t border-zinc-200 pt-4 dark:border-zinc-800">
<div className="border-t border-line pt-4">
<Button variant="primary" onClick={onFinish} disabled={busy}>
Finish
</Button>
@@ -753,6 +758,6 @@ function Loading({ q }: { q: { isError: boolean; error: Error | null } }): React
return q.isError ? (
<Alert>{q.error!.message}</Alert>
) : (
<p className="text-sm text-zinc-500">Loading…</p>
<p className="text-sm text-muted">Loading…</p>
);
}
+2 -2
View File
@@ -73,7 +73,7 @@ function TwoStep() {
<h2 id="two-step-h" className="font-medium">
Two-step sign-in
</h2>
<p className="text-zinc-600 dark:text-zinc-400">
<p className="text-muted">
Besides your password, signing in asks for a code from an authenticator app on your phone
(any app that does TOTP, such as Aegis, 2FAS, Google or Microsoft Authenticator).
</p>
@@ -115,7 +115,7 @@ function TwoStep() {
height={240}
className="rounded bg-white"
/>
<p className="text-xs text-zinc-500">
<p className="text-xs text-muted">
Or type this key: <span className="font-mono break-all">{setup.data.secret}</span>
</p>
<Field label="2. Enter the 6-digit code it shows">
+117 -51
View File
@@ -1,6 +1,30 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Admin shell (05 §2): sidebar in spec order; unbuilt pages say which milestone brings them.
// Admin shell (05 §2): sidebar grouped by section; unbuilt pages say which milestone brings them.
import { useQuery, useQueryClient } from '@tanstack/react-query';
import {
Activity,
AudioLines,
Ban,
BellRing,
BookUser,
Cable,
ChartColumn,
Clock,
Hash,
Headset,
HistoryIcon,
LayoutDashboard,
Menu,
ListOrdered,
Megaphone,
MessageSquare,
MonitorSmartphone,
Music,
PhoneOutgoing,
Settings,
Split,
Users,
} from 'lucide-react';
import { useEffect, useState } from 'react';
import { Navigate, NavLink, Outlet, useNavigate } from 'react-router';
import type { AuthUser } from '@selfphoneng/shared';
@@ -9,28 +33,65 @@ import { ThemeToggle } from '../../components/ThemeToggle.tsx';
import { Brand, Button } from '../../components/ui.tsx';
import { subscribeLive, useLive } from '../../live/store.ts';
// Grouped by job rather than spec order, so the 21 pages scan as six short lists.
export const ADMIN_PAGES = [
{ path: '', label: 'Dashboard', milestone: null },
{ path: 'users', label: 'Users & Extensions', milestone: null },
{ path: 'trunks', label: 'Trunks', milestone: null },
{ path: 'numbers', label: 'Phone numbers', milestone: null },
{ path: 'outbound', label: 'Outbound rules', milestone: null },
{ path: 'ring-groups', label: 'Ring groups', milestone: null },
{ path: 'queues', label: 'Queues', milestone: null },
{ path: 'ivrs', label: 'Digital receptionist', milestone: null },
{ path: 'hours', label: 'Office hours', milestone: null },
{ path: 'conferences', label: 'Conferences', milestone: null },
{ path: 'paging', label: 'Paging', milestone: null },
{ path: 'sounds', label: 'Sounds & music', milestone: null },
{ path: 'devices', label: 'Phones', milestone: null },
{ path: 'contacts', label: 'Contacts', milestone: null },
{ path: 'calls', label: 'Call history', milestone: null },
{ path: 'recordings', label: 'Recordings', milestone: null },
{ path: 'reports', label: 'Reports', milestone: null },
{ path: 'sms', label: 'SMS', milestone: 'M9' },
{ path: 'blocked', label: 'Blocked numbers', milestone: null },
{ path: 'settings', label: 'Settings', milestone: null },
{ path: 'system', label: 'System', milestone: null },
{ path: '', label: 'Dashboard', milestone: null, section: '', icon: LayoutDashboard },
{ path: 'trunks', label: 'Trunks', milestone: null, section: 'Call routing', icon: Cable },
{ path: 'numbers', label: 'Phone numbers', milestone: null, section: 'Call routing', icon: Hash },
{
path: 'outbound',
label: 'Outbound rules',
milestone: null,
section: 'Call routing',
icon: PhoneOutgoing,
},
{
path: 'ring-groups',
label: 'Ring groups',
milestone: null,
section: 'Call routing',
icon: BellRing,
},
{ path: 'queues', label: 'Queues', milestone: null, section: 'Call routing', icon: ListOrdered },
{
path: 'ivrs',
label: 'Digital receptionist',
milestone: null,
section: 'Call routing',
icon: Split,
},
{ path: 'hours', label: 'Office hours', milestone: null, section: 'Call routing', icon: Clock },
{
path: 'blocked',
label: 'Blocked numbers',
milestone: null,
section: 'Call routing',
icon: Ban,
},
{ path: 'users', label: 'Users & Extensions', milestone: null, section: 'People', icon: Users },
{ path: 'devices', label: 'Phones', milestone: null, section: 'People', icon: MonitorSmartphone },
{ path: 'contacts', label: 'Contacts', milestone: null, section: 'People', icon: BookUser },
{
path: 'conferences',
label: 'Conferences',
milestone: null,
section: 'Features',
icon: Headset,
},
{ path: 'paging', label: 'Paging', milestone: null, section: 'Features', icon: Megaphone },
{ path: 'sounds', label: 'Sounds & music', milestone: null, section: 'Features', icon: Music },
{ path: 'sms', label: 'SMS', milestone: 'M9', section: 'Features', icon: MessageSquare },
{ path: 'calls', label: 'Call history', milestone: null, section: 'History', icon: HistoryIcon },
{
path: 'recordings',
label: 'Recordings',
milestone: null,
section: 'History',
icon: AudioLines,
},
{ path: 'reports', label: 'Reports', milestone: null, section: 'History', icon: ChartColumn },
{ path: 'settings', label: 'Settings', milestone: null, section: 'System', icon: Settings },
{ path: 'system', label: 'System', milestone: null, section: 'System', icon: Activity },
] as const;
export function AdminLayout({ user }: { user: AuthUser }) {
@@ -52,29 +113,39 @@ export function AdminLayout({ user }: { user: AuthUser }) {
const links = (
<nav aria-label="Admin" className="flex-1 overflow-y-auto px-2 pb-4">
<ul className="flex flex-col gap-0.5">
{ADMIN_PAGES.map((p) => (
<li key={p.path}>
<NavLink
to={`/admin${p.path ? `/${p.path}` : ''}`}
end={p.path === ''}
onClick={() => setNavOpen(false)}
className={({ isActive }) =>
`flex items-center justify-between rounded-md px-3 py-1.5 text-sm ${isActive ? 'bg-sky-100 font-medium text-sky-900 dark:bg-sky-950 dark:text-sky-100' : 'hover:bg-zinc-200/60 dark:hover:bg-zinc-800'} ${p.milestone ? 'text-zinc-500' : ''}`
}
>
{p.label}
{p.milestone && <span className="text-[10px] text-zinc-400">{p.milestone}</span>}
</NavLink>
</li>
))}
</ul>
{[...new Set(ADMIN_PAGES.map((p) => p.section))].map((section) => (
<div key={section} className="mt-3 first:mt-0">
{section && (
<h2 className="px-3 pb-1 text-xs font-semibold tracking-wide text-muted uppercase">
{section}
</h2>
)}
<ul className="flex flex-col gap-0.5">
{ADMIN_PAGES.filter((p) => p.section === section).map((p) => (
<li key={p.path}>
<NavLink
to={`/admin${p.path ? `/${p.path}` : ''}`}
end={p.path === ''}
onClick={() => setNavOpen(false)}
className={({ isActive }) =>
`relative flex items-center gap-2.5 rounded-md px-3 py-1.5 text-sm pointer-coarse:py-2.5 ${isActive ? 'bg-primary-soft font-medium text-primary-ink before:absolute before:inset-y-1.5 before:left-0 before:w-1 before:rounded-full before:bg-primary' : 'hover:bg-subtle'} ${p.milestone ? 'text-muted' : ''}`
}
>
<p.icon className="size-4 shrink-0" />
<span className="flex-1">{p.label}</span>
{p.milestone && <span className="text-xs text-muted">{p.milestone}</span>}
</NavLink>
</li>
))}
</ul>
</div>
))}
</nav>
);
return (
<div className="flex min-h-dvh">
<aside className="sticky top-0 hidden h-dvh w-60 flex-col border-r border-zinc-200 bg-zinc-50 md:flex dark:border-zinc-800 dark:bg-zinc-900">
<aside className="sticky top-0 hidden h-dvh w-60 flex-col border-r border-line bg-surface md:flex">
<p className="px-4 py-4 text-lg">
<Brand />
</p>
@@ -82,7 +153,7 @@ export function AdminLayout({ user }: { user: AuthUser }) {
</aside>
<div className="flex min-w-0 flex-1 flex-col">
<header className="flex items-center gap-1 border-b border-zinc-200 px-2 py-2 sm:gap-2 sm:px-4 dark:border-zinc-800">
<header className="flex items-center gap-1 border-b border-line px-2 py-2 sm:gap-2 sm:px-4">
<Button
variant="ghost"
className="md:hidden"
@@ -91,13 +162,11 @@ export function AdminLayout({ user }: { user: AuthUser }) {
aria-label="Menu"
onClick={() => setNavOpen((o) => !o)}
>
☰
<Menu className="size-5" />
</Button>
<Brand className="md:hidden" />
<div className="ml-auto flex items-center gap-1 whitespace-nowrap sm:gap-2">
<span className="hidden text-sm text-zinc-600 sm:inline dark:text-zinc-400">
{user.displayName}
</span>
<span className="hidden text-sm text-muted sm:inline">{user.displayName}</span>
<ThemeToggle />
{/* On phones this lives in the menu instead. */}
<span className="hidden sm:contents">
@@ -111,12 +180,9 @@ export function AdminLayout({ user }: { user: AuthUser }) {
</div>
</header>
{navOpen && (
<div
id="mobile-nav"
className="border-b border-zinc-200 bg-zinc-50 pt-2 md:hidden dark:border-zinc-800 dark:bg-zinc-900"
>
<div id="mobile-nav" className="border-b border-line bg-surface pt-2 md:hidden">
{links}
<div className="border-t border-zinc-200 px-2 py-2 dark:border-zinc-800">
<div className="border-t border-line px-2 py-2">
<Button
variant="ghost"
className="w-full justify-start"
@@ -148,7 +214,7 @@ export function ComingSoon({ label, milestone }: { label: string; milestone: str
return (
<section>
<h1 className="mb-2 text-xl font-semibold">{label}</h1>
<p className="text-sm text-zinc-500">Coming in {milestone}.</p>
<p className="text-sm text-muted">Coming in {milestone}.</p>
</section>
);
}
+51 -7
View File
@@ -1,14 +1,16 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Blocked numbers (05 §2): calls from these numbers are rejected before anything rings.
// Blocked numbers (05 §2): calls from these numbers are rejected before anything rings. Users'
// Report spam waits at the top for Block or Dismiss.
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { formatNumber } from '@selfphoneng/shared';
import { blockedApi } from '../../api/client.ts';
import { Alert, Button, Field, inputClass } from '../../components/ui.tsx';
import { Alert, Button, Field, Panel, inputClass } from '../../components/ui.tsx';
export function BlockedPage() {
const qc = useQueryClient();
const list = useQuery({ queryKey: ['blocked'], queryFn: blockedApi.list });
const reports = useQuery({ queryKey: ['blocked', 'reports'], queryFn: blockedApi.reports });
const refresh = () => qc.invalidateQueries({ queryKey: ['blocked'] });
const [number, setNumber] = useState('');
const [reason, setReason] = useState('');
@@ -24,14 +26,56 @@ export function BlockedPage() {
mutationFn: (id: string) => blockedApi.remove(id),
onSuccess: refresh,
});
// Blocking a number clears every report of it (api).
const block = useMutation({
mutationFn: (r: { number: string; reason: string }) => blockedApi.create(r),
onSuccess: refresh,
});
const dismiss = useMutation({
mutationFn: (id: string) => blockedApi.dismiss(id),
onSuccess: refresh,
});
const error = add.error ?? remove.error ?? block.error ?? dismiss.error ?? reports.error;
return (
<section className="flex flex-col gap-4">
<h1 className="text-xl font-semibold">Blocked numbers</h1>
<p className="text-sm text-zinc-500">
<p className="text-sm text-muted">
Calls from these numbers are rejected before any phone rings. Emergency calls you make are
never affected.
</p>
{!!reports.data?.length && (
<Panel title="Reported as spam">
<ul className="divide-y divide-line">
{reports.data.map((r) => (
<li key={r.id} className="flex flex-wrap items-center gap-x-3 gap-y-1 py-2 text-sm">
<span className="font-mono">{formatNumber(r.number)}</span>
<span className="min-w-0 flex-1">
{r.reason}
<span className="block text-xs text-muted">
{r.reportedBy ?? 'A former user'}, {new Date(r.createdAt).toLocaleString()}
</span>
</span>
<Button
variant="primary"
aria-label={`Block ${formatNumber(r.number)}`}
disabled={block.isPending}
onClick={() => block.mutate({ number: r.number, reason: r.reason })}
>
Block
</Button>
<Button
variant="ghost"
aria-label={`Dismiss the report of ${formatNumber(r.number)}`}
onClick={() => dismiss.mutate(r.id)}
>
Dismiss
</Button>
</li>
))}
</ul>
</Panel>
)}
<form
className="flex flex-wrap items-end gap-2"
onSubmit={(e) => {
@@ -66,14 +110,14 @@ export function BlockedPage() {
Block
</Button>
</form>
{(add.error ?? remove.error) && <Alert>{(add.error ?? remove.error)!.message}</Alert>}
{list.data?.length === 0 && <p className="text-sm text-zinc-500">Nobody is blocked.</p>}
{error && <Alert>{error.message}</Alert>}
{list.data?.length === 0 && <p className="text-sm text-muted">Nobody is blocked.</p>}
{list.data && list.data.length > 0 && (
<ul className="divide-y divide-zinc-100 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800">
<ul className="divide-y divide-line rounded-xl border border-line bg-surface shadow-sm">
{list.data.map((b) => (
<li key={b.id} className="flex flex-wrap items-center gap-x-3 px-3 py-2 text-sm">
<span className="font-mono">{formatNumber(b.number)}</span>
<span className="flex-1 text-zinc-500">{b.reason}</span>
<span className="flex-1 text-muted">{b.reason}</span>
<Button
variant="ghost"
aria-label={`Unblock ${formatNumber(b.number)}`}
+13 -11
View File
@@ -11,6 +11,7 @@ import {
type CallFilter,
} from '@selfphoneng/shared';
import { callsApi, extensionsApi, recordingsApi } from '../../api/client.ts';
import { SpamNote } from '../../components/SpamNote.tsx';
import { Alert, Button, Field, inputClass } from '../../components/ui.tsx';
export const fmtNumber = (n: string | null) =>
@@ -20,10 +21,10 @@ export const fmtDuration = (s: number) =>
export const STATUS_LOOK: Record<CallDto['status'], string> = {
answered: 'bg-green-100 text-green-900 dark:bg-green-900 dark:text-green-100',
missed: 'bg-red-100 text-red-900 dark:bg-red-900 dark:text-red-100',
voicemail: 'bg-sky-100 text-sky-900 dark:bg-sky-900 dark:text-sky-100',
voicemail: 'bg-primary-soft text-primary-ink',
abandoned: 'bg-amber-100 text-amber-900 dark:bg-amber-900 dark:text-amber-100',
busy: 'bg-zinc-200 text-zinc-800 dark:bg-zinc-800 dark:text-zinc-200',
failed: 'bg-zinc-200 text-zinc-800 dark:bg-zinc-800 dark:text-zinc-200',
busy: 'bg-line text-fg',
failed: 'bg-line text-fg',
};
export const DIRECTION_TEXT = { inbound: 'In', outbound: 'Out', internal: 'Internal' };
@@ -70,7 +71,7 @@ export function CallsPage() {
<div className="flex flex-wrap items-center justify-between gap-2">
<h1 className="text-xl font-semibold">Call history</h1>
<a
className="rounded-md border border-zinc-300 px-3 py-2 text-sm font-medium hover:bg-zinc-100 dark:border-zinc-700 dark:hover:bg-zinc-800"
className="rounded-md border border-line-strong bg-surface px-3 py-2 text-sm font-medium hover:bg-subtle"
href={callsApi.csvUrl(filter)}
>
Export CSV
@@ -147,10 +148,10 @@ export function CallsPage() {
</div>
</form>
{list.isError && <Alert>{list.error.message}</Alert>}
{list.data && calls.length === 0 && <p className="text-sm text-zinc-500">No calls match.</p>}
{list.data && calls.length === 0 && <p className="text-sm text-muted">No calls match.</p>}
<ul
aria-label="Calls"
className="divide-y divide-zinc-100 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800"
className="divide-y divide-line rounded-xl border border-line bg-surface shadow-sm"
>
{calls.map((c) => (
<li key={c.id} className="flex flex-col gap-2 px-3 py-2 text-sm">
@@ -160,16 +161,17 @@ export function CallsPage() {
className="flex flex-wrap items-center gap-x-3 gap-y-1 text-left"
onClick={() => setOpen(open === c.id ? null : c.id)}
>
<span className="w-36 text-zinc-500">{new Date(c.startedAt).toLocaleString()}</span>
<span className="w-36 text-muted">{new Date(c.startedAt).toLocaleString()}</span>
<span className="w-14">{DIRECTION_TEXT[c.direction]}</span>
<span className="min-w-0 flex-1">
<span className="font-medium">{c.fromName ?? fmtNumber(c.fromNumber)}</span>
{c.fromName && (
<span className="text-zinc-500"> {fmtNumber(c.fromNumber)}</span>
<span className="text-muted"> {fmtNumber(c.fromNumber)}</span>
)} → {fmtNumber(c.toNumber)}
{c.answeredBy && (
<span className="text-zinc-500"> · answered by {c.answeredBy.name}</span>
<span className="text-muted"> · answered by {c.answeredBy.name}</span>
)}
<SpamNote check={c.spam} />
</span>
<StatusBadge status={c.status} />
<span className="w-12 text-right tabular-nums">{fmtDuration(c.talkSeconds)}</span>
@@ -200,12 +202,12 @@ export function CallsPage() {
function Legs({ id }: { id: string }) {
const d = useQuery({ queryKey: ['call', id], queryFn: () => callsApi.get(id) });
if (!d.data) return <p className="text-xs text-zinc-500">Loading…</p>;
if (!d.data) return <p className="text-xs text-muted">Loading…</p>;
return (
<div className="overflow-x-auto">
<table className="w-full text-xs">
<caption className="sr-only">Call legs</caption>
<thead className="text-left text-zinc-500">
<thead className="text-left text-muted">
<tr>
<th className="pr-3 font-normal">Time</th>
<th className="pr-3 font-normal">From channel</th>
+3 -3
View File
@@ -35,13 +35,13 @@ export function ConferencesPage() {
</div>
{rooms.isError && <Alert>{rooms.error.message}</Alert>}
{rooms.data?.length === 0 && (
<p className="text-sm text-zinc-500">
<p className="text-sm text-muted">
No conference rooms yet. A room is a number (like 800) that several people dial to talk
together, with an optional PIN.
</p>
)}
{rooms.data && rooms.data.length > 0 && (
<ul className="divide-y divide-zinc-100 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800">
<ul className="divide-y divide-line rounded-xl border border-line bg-surface shadow-sm">
{rooms.data.map((c) => (
<li
key={c.id}
@@ -49,7 +49,7 @@ export function ConferencesPage() {
>
<span className="font-mono">{c.number}</span>
<span className="font-medium">{c.name}</span>
<span className="min-w-0 flex-1 truncate text-zinc-500">
<span className="min-w-0 flex-1 truncate text-muted">
{c.pin ? `PIN ${c.pin}` : 'No PIN'}
{c.adminPin ? ` · admin PIN ${c.adminPin}` : ''} · up to {c.maxMembers}
{c.record ? ' · recorded' : ''}
+50 -24
View File
@@ -1,11 +1,11 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Dashboard (05 §2). Warnings, telephony link, trunks, calls in progress (hang up / transfer)
// queue tiles, parked calls and extensions online.
import { useMutation, useQuery } from '@tanstack/react-query';
// queue tiles, parked calls and extensions online, spam reports waiting.
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useState, type ReactNode } from 'react';
import type { ActiveCall, QueueLive } from '@selfphoneng/shared';
import type { ActiveCall, QueueLive, Warning } from '@selfphoneng/shared';
import { Link } from 'react-router';
import { liveApi, settingsApi, type SpyMode } from '../../api/client.ts';
import { blockedApi, liveApi, settingsApi, type SpyMode } from '../../api/client.ts';
import { ConfirmDialog } from '../../components/Dialog.tsx';
import { Alert, Button, inputClass } from '../../components/ui.tsx';
import { fmtNumber } from './CallsPage.tsx';
@@ -15,8 +15,8 @@ import { ParkedCalls } from '../../components/ParkedCalls.tsx';
function Card({ title, children }: { title: string; children: ReactNode }) {
return (
<div className="rounded-lg border border-zinc-200 p-4 dark:border-zinc-800">
<h2 className="text-sm text-zinc-500">{title}</h2>
<div className="rounded-xl border border-line bg-surface shadow-sm p-4">
<h2 className="text-sm text-muted">{title}</h2>
<div className="mt-1 text-2xl font-semibold">{children}</div>
</div>
);
@@ -31,6 +31,11 @@ export function DashboardPage() {
const onCall = list.filter((e) =>
['inuse', 'busy', 'onhold', 'ringinuse'].includes(e.state),
).length;
const reports = useQuery({
queryKey: ['blocked', 'reports'],
queryFn: blockedApi.reports,
refetchInterval: 30_000,
});
return (
<section className="flex flex-col gap-6">
@@ -40,7 +45,7 @@ export function DashboardPage() {
(t) => t.registration === 'rejected' || (!t.reachable && t.registration !== 'registered'),
)}
/>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5">
<Card title="Extensions online">
{status === 'connecting' ? '…' : `${online} / ${list.length}`}
</Card>
@@ -55,12 +60,18 @@ export function DashboardPage() {
<Card title="Telephony engine">
<span
className={
amiConnected ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'
amiConnected ? 'text-green-700 dark:text-green-400' : 'text-red-600 dark:text-red-400'
}
>
{status === 'connecting' ? '…' : amiConnected ? 'Connected' : 'Not connected'}
</span>
</Card>
<Card title="Spam reports">
<Link to="/admin/blocked" className="hover:underline">
{reports.data?.length ?? '…'}
<span className="sr-only"> waiting on Blocked numbers</span>
</Link>
</Card>
</div>
<QueueTiles />
@@ -72,19 +83,19 @@ export function DashboardPage() {
<div>
<h2 className="mb-2 font-medium">Extensions</h2>
{list.length === 0 ? (
<p className="text-sm text-zinc-500">
<p className="text-sm text-muted">
No extensions yet. Give a user a number under Users & Extensions.
</p>
) : (
<ul className="divide-y divide-zinc-100 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800">
<ul className="divide-y divide-line rounded-xl border border-line bg-surface shadow-sm">
{list.map((e) => (
<li key={e.number} className="flex items-center gap-3 px-3 py-2 text-sm">
<StatusDot ext={e} />
<span className="w-14 font-mono">{e.number}</span>
<span className="flex-1 truncate">{e.name}</span>
<span className="text-zinc-500">{statusLabel(e)}</span>
<span className="text-muted">{statusLabel(e)}</span>
{e.contacts && (
<span className="hidden w-24 text-right text-zinc-500 sm:inline">
<span className="hidden w-24 text-right text-muted sm:inline">
{e.contacts.length} device{e.contacts.length === 1 ? '' : 's'}
</span>
)}
@@ -118,15 +129,12 @@ function QueueTiles() {
null,
);
return (
<li
key={q.number}
className="rounded-lg border border-zinc-200 p-4 dark:border-zinc-800"
>
<Link className="text-sm text-zinc-500 hover:underline" to={`queues/${q.id}`}>
<li key={q.number} className="rounded-xl border border-line bg-surface shadow-sm p-4">
<Link className="text-sm text-muted hover:underline" to={`queues/${q.id}`}>
{q.number} {q.name}
</Link>
<div className="mt-1 text-2xl font-semibold">{q.waiting.length} waiting</div>
<div className="text-sm text-zinc-500">
<div className="text-sm text-muted">
Longest {oldest ? since(oldest, now) : '—'} · {available(q)} of {q.members.length}{' '}
agents free
</div>
@@ -144,7 +152,12 @@ function Warnings({ trunkDown }: { trunkDown: boolean }) {
queryFn: settingsApi.warnings,
refetchInterval: 30_000,
});
const items = [
const qc = useQueryClient();
const dismiss = useMutation({
mutationFn: settingsApi.dismissWarning,
onSuccess: () => qc.invalidateQueries({ queryKey: ['warnings'] }),
});
const items: Warning[] = [
...(trunkDown
? [
{
@@ -168,6 +181,19 @@ function Warnings({ trunkDown }: { trunkDown: boolean }) {
<Link to={w.link} className="font-medium underline">
Fix
</Link>
{w.dismissable && (
<>
{' '}
<button
type="button"
className="font-medium underline"
disabled={dismiss.isPending}
onClick={() => dismiss.mutate(w.code)}
>
Dismiss
</button>
</>
)}
</li>
))}
</ul>
@@ -240,11 +266,11 @@ function LiveCalls() {
</p>
)}
{calls.length === 0 ? (
<p className="text-sm text-zinc-500">No calls right now.</p>
<p className="text-sm text-muted">No calls right now.</p>
) : (
<ul
aria-label="Calls now"
className="divide-y divide-zinc-100 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800"
className="divide-y divide-line rounded-xl border border-line bg-surface shadow-sm"
>
{calls.map((c) => (
<li
@@ -254,15 +280,15 @@ function LiveCalls() {
>
<span className="min-w-0 flex-1">
<span className="font-medium">{c.from.name ?? fmtNumber(c.from.number)}</span>
{c.from.name && <span className="text-zinc-500"> {fmtNumber(c.from.number)}</span>}
{c.from.name && <span className="text-muted"> {fmtNumber(c.from.number)}</span>}
{' → '}
{fmtNumber(c.to.number)}
<span className="text-zinc-500">
<span className="text-muted">
{' '}
· {STATE_TEXT[c.state]} {since(c.answeredAt ?? c.startedAt, now)}
{c.recording && ' · recording'}
</span>
<span className="block font-mono text-xs text-zinc-500" data-channels>
<span className="block font-mono text-xs text-muted" data-channels>
{c.channels.join(' ')}
</span>
</span>
+7 -7
View File
@@ -42,14 +42,14 @@ export function IvrsPage() {
</div>
{list.isError && <Alert>{list.error.message}</Alert>}
{list.data?.length === 0 && (
<p className="text-sm text-zinc-500">
<p className="text-sm text-muted">
No menus yet. A menu greets callers ("Thanks for calling. For sales press 1…") and sends
them where they choose. Upload the greeting under Sounds &amp; music, or right in the
form.
</p>
)}
{list.data && list.data.length > 0 && (
<ul className="divide-y divide-zinc-100 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800">
<ul className="divide-y divide-line rounded-xl border border-line bg-surface shadow-sm">
{list.data.map((r) => (
<li
key={r.id}
@@ -57,7 +57,7 @@ export function IvrsPage() {
>
<span className="font-mono">{r.number}</span>
<span className="font-medium">{r.name}</span>
<span className="min-w-0 flex-1 truncate text-zinc-500">
<span className="min-w-0 flex-1 truncate text-muted">
{r.options.map((o) => `${o.digit}: ${o.summary}`).join(' · ') || 'No keys yet'}
</span>
<Button onClick={() => setEditing(r)}>Edit</Button>
@@ -230,10 +230,10 @@ function IvrForm({
aria-label={`Key ${d}`}
aria-pressed={selected === d}
onClick={() => setSelected(d)}
className={`flex min-h-16 flex-col items-start rounded-md border p-2 text-left text-xs ${selected === d ? 'border-sky-500 ring-2 ring-sky-500' : 'border-zinc-300 dark:border-zinc-700'} ${dest ? 'bg-sky-50 dark:bg-sky-950' : ''}`}
className={`flex min-h-16 flex-col items-start rounded-md border p-2 text-left text-xs ${selected === d ? 'border-primary ring-2 ring-primary' : 'border-line-strong'} ${dest ? 'bg-primary-soft' : ''}`}
>
<span className="text-lg font-semibold">{d}</span>
<span className="line-clamp-2 text-zinc-600 dark:text-zinc-400">
<span className="line-clamp-2 text-muted">
{dest ? describe(dest) : dest === null ? 'Not finished' : 'Unused'}
</span>
</button>
@@ -242,7 +242,7 @@ function IvrForm({
</div>
{errors.options && <Alert>{errors.options}</Alert>}
{selected && (
<div className="flex flex-col gap-2 rounded-md border border-zinc-200 p-3 dark:border-zinc-800">
<div className="flex flex-col gap-2 rounded-md border border-line p-3">
{keys[selected] === undefined ? (
<Button onClick={() => setKeys((k) => ({ ...k, [selected]: { type: 'hangup' } }))}>
Use key {selected}
@@ -281,7 +281,7 @@ function IvrForm({
/>
<span>
Callers may dial an extension directly (e.g. 101)
<span className="block text-xs text-zinc-500">
<span className="block text-xs text-muted">
A key that is also the first digit of an extension waits 3 seconds for more digits.
</span>
</span>
Loaded 100 of 162 files, more files were not shown because too many files have changed in this diff. Show more