Files
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

23 KiB

11 — Contacts

Owner, 2026-10-23: contacts come in two kinds, picked in the setup wizard and changeable later in Settings → Contacts (contacts.provider):

  • Simple (builtin): the contacts table (02), a phonebook inside SelfPhoneNG.
  • Robust (contrack): Contrack, a self-hosted contact and relationship manager, running beside the PBX. SelfPhoneNG reads names from it and writes each call to the contact's timeline. The owner's own install uses this.

Both kinds feed the same features, so the rest of SelfPhoneNG asks one question, "who is 13125551234 for this user?", and doesn't care which one answers.

1. What contacts are used for (both kinds)

Feature Where Today
My contacts: list, search, click to call, add/edit /me/contacts (05 §4) not built
Company phonebook, CSV and vCard (.vcf) import /admin/contacts (05 §3) not built
My contacts: CSV and vCard (.vcf) import /me/contacts not built
Caller name: softphone ringing, call history, voicemail list, call report email, transcripts 05 §5, 04 §7.3, 10 §3 extensions → caller ID name → number
"Add to contacts" from a call or voicemail /me/calls, /me/voicemail not built
Transfer search (contacts + extensions) softphone (05 §5) extensions only
Desk phone phonebook provisioning (07) extensions only

Lookup order everywhere: extensions → my contacts → company phonebook → caller ID name → number.

2. The provider seam (apps/api/src/contacts/)

type ContactHit = {
  name: string;
  company: string | null;
  url: string | null; // Contrack: the contact's page
  book: 'mine' | 'company';
};

interface ContactsProvider {
  lookup(userId: string, number: string): Promise<ContactHit | null>; // normalized E.164-ish digits
  search(userId: string, q: string, limit: number): Promise<ContactDto[]>;
  list(userId: string, cursor?: string): Promise<ListResponse<ContactDto>>;
  create(userId: string, c: ContactInput): Promise<ContactDto>;
  update?(userId: string, id: string, c: ContactInput): Promise<ContactDto>; // builtin only
  remove?(userId: string, id: string): Promise<void>; // builtin only
  logCall?(userId: string, call: CallForLog): Promise<void>; // contrack only (§4.4)
}
  • builtin.ts and contrack.ts implement it; index.ts picks one from contacts.provider.
  • lookup is never a network call in the call path. Contrack answers from a per-user cache (§4.3); builtin answers from one indexed query.
  • Numbers are compared after normalizeNumber (00 §8) on both sides, so "+1 (312) 555-1234", "312-555-1234" and "13125551234" match.
  • Owner, 2026-10-23: every address book belongs to one owner, in both modes, and the company is one more owner. Simple: the builtin table, owner_user_id = the user, or null for the company. Contrack: every Contrack account is private, so the company gets its own Contrack account (the admin sets its token, §4.3) and each user connects their own account. SelfPhoneNG does the sharing:
    • the company book is shown read-only to everyone (lookup, search, desk phones);
    • a personal book is private; its owner can share contacts from it (§2.1).

2.1 Sharing contacts (owner, 2026-10-23)

Sharing offers a copy: the recipient accepts or denies each shared contact. Accepted, it is imported into their own book, and from then on the two copies are separate (editing one doesn't change the other). Denied, nothing is added. Nothing lands in anyone's book without their click. Both modes.

  • /me/contacts: select one or more contacts → Share → pick colleagues, or Everyone in the company. POST /api/me/contacts/share { contactIds, userIds | everyone: true } makes one pending offer per contact per recipient. The answer says how many offers went to how many people.

  • The offer holds a copy taken when it was shared (name, company, email, numbers; not notes or a Contrack timeline), so the sender editing or deleting the contact later doesn't change it:

    create table contact_shares (
      id            uuid primary key default gen_random_uuid(),
      from_user_id  uuid references users(id) on delete set null,
      to_user_id    uuid not null references users(id) on delete cascade,
      contact       jsonb not null,   -- { name, company, email, numbers: [{ label, number }] }
      status        text not null default 'pending' check (status in ('pending', 'accepted', 'denied')),
      decided_at    timestamptz,
      created_at    timestamptz not null default now()
    );
    create index on contact_shares (to_user_id) where status = 'pending';
    
  • The recipient sees Shared with you at the top of /me/contacts (a count on the Contacts link and a line on Home): "Dan Doe shared Acme Corp (312) 555-1234", with Accept and Deny on each, and Accept all / Deny all for one sender's batch. GET /api/me/contacts/shares, POST /api/me/contacts/shares/accept|deny { ids } (one id or a sender's batch).

  • Accept imports with the duplicate rule (§3.1: same name and same normalized number already in the book → skipped, and the answer says so); the offer is accepted either way. Deny marks it denied. Decided offers are deleted after 30 days; pending ones stay until decided.

  • The same contact offered again to someone with a pending offer of it replaces that offer, so a recipient never sees two of one contact.

  • Contrack mode: the copy is read with the sender's token when sharing; Accept creates it with the recipient's token. A recipient without a connected Contrack account, or with a read-only token, can't accept yet: the offer says "Connect Contrack to accept" and waits.

  • Only users, not the company book: an admin adds to the company book directly.

3. Simple (builtin)

The contacts table already in 02. numbers[].number is stored normalized (as forwards are, 04 §5.5) and shown with formatNumber. Lookup (contacts/book.ts contactNames) scans the two books' numbers with jsonb_array_elements; a GIN index when books reach tens of thousands.

As built (C1): GET /api/me/contacts answers my whole book and the company's in one list (no paging); the page searches it in the browser, and the browser looks up names from it (call history, voicemail, Home, the ringing softphone). The call report email looks up on the server.

API (04 §7.3, §7.4, as already listed there):

Route Who Does
GET/POST/PATCH/DELETE /api/me/contacts[/:id] user my contacts; GET ?q= searches name, company, number digits
GET/POST/PATCH/DELETE /api/contacts[/:id] admin company phonebook
POST /api/contacts/import admin CSV name,company,email,label1,number1,... (04 §7.4); preview, then commit

UI: /me/contacts (mine + company read-only, search, click to call, add/edit drawer, import, share), /admin/contacts (table, drawer, import), "Add to contacts" on calls and voicemail.

3.1 Import: CSV and vCard (owner, 2026-10-23)

Admins import into the company book, users into their own; same code, same screen.

  • POST /api/contacts/import (admin) and POST /api/me/contacts/import (user): { files: [{ name, text }] }, up to 20 files read as text in the browser (no multipart), .csv (04 §7.4 columns) or .vcf (any number of cards per file). 6 MB and 5,000 contacts per import. ?commit=0 answers a preview, ?commit=1 saves.
  • vCard 2.1, 3.0 and 4.0, read by our own parser (contacts/vcard.ts, no new dependency): line unfolding, QUOTED-PRINTABLE and CHARSET (2.1), escaped \, \; \n, item1. groups. Fields: FN (or N when FN is empty), ORG, first EMAIL, every TEL with its TYPE as the label (cell → mobile, work, home, fax, other), NOTE. Everything else (photos, addresses, birthdays) is ignored.
  • Preview: how many are new, how many are already in that book (same name and same normalized number: skipped), how many have no phone number (imported anyway: they still have a name and email), and the first rows that failed and why. Nothing is saved until Import.
  • Contrack mode: SelfPhoneNG's import buttons open Contrack's own import (it reads Apple, Google, LinkedIn and vCard exports), for the company account too.

4. Robust (Contrack)

4.1 What Contrack is (checked 2026-10-23, v2.0.2 of 2026-10-18)

  • TypeScript (Node ≥ 26.10), React, SQLite with sqlite-vec, AGPL-3.0-only. One Docker image, ghcr.io/arvarik/contrack, port 3210, data in /app/data.
  • REST API under /api (OpenAPI 3.1 in its docs/openapi.json), plus MCP.
  • Multi-user: every account has its own private address book. Credentials: the session cookie, or a personal token Authorization: Bearer ctk_… (read-only or read-write, made in Settings → Account). Tokens need sign-in on (AUTH_REQUIRED=true).
  • Contacts carry phones: [{ phone, label, isPrimary }] (free text such as "+49 30 1234567").
  • Timeline: POST /api/contacts/:id/interactions { type: 'call', title, content (HTML), date, duration (minutes), actionItem? }; PATCH /api/interactions/:id changes title/content. A future date is refused.
  • Its own AI (Gemini/OpenAI/Anthropic, or an OpenAI-compatible server such as our Ollama), web research (SearXNG), connectors (CardDAV, IMAP, calendar, Google), backups, CSV/vCard-style imports.

Endpoints SelfPhoneNG uses, and nothing else: GET /healthz, GET /api/contacts?view=slim, GET /api/contacts/:id, POST /api/contacts, GET /api/search?q=, POST /api/contacts/:id/interactions, PATCH /api/interactions/:id. A contract test checks them against the pinned version's openapi.json.

4.2 Running it

  • deploy/contrack/compose.yml, its own stack like deploy/ai/compose.yml: the image pinned to a version tag (not latest, decision 0001), 127.0.0.1:3210:3210, a contrack-data volume, and AUTH_REQUIRED=true, TRUST_PROXY_HOPS=1, PUBLIC_URL=https://${CONTRACK_HOSTNAME}.
  • People open Contrack on its own host name (PUBLIC_URL takes no path), e.g. contacts.example.com. The Caddyfile gets a second site block when CONTRACK_HOSTNAME is set; the nginx example gets a second server block. As built: both are commented blocks to uncomment (a Caddy site with an empty name would fail), and docker compose --env-file .env -f deploy/contrack/compose.yml up -d (a compose file in a subfolder reads no .env by itself).
  • The PBX reaches it at http://127.0.0.1:3210. contacts.contrack.url must pass the same local-address check as the AI endpoints (10 §1), so contact data and tokens stay on the network.
  • Backups: Contrack keeps its own snapshots in its volume. The SelfPhoneNG backup (pg_dump) does not include it; the admin guide says to back up the volume too.
  • docs/decisions/0003-contrack.md: why an outside service (it already does relationships, timelines, imports, CardDAV sync, dedupe and search far beyond a phonebook), why it's allowed (AGPL, open source, a separate program over HTTP, so no license mixing and no code in our dependency tree), and the risk (one maintainer, young project: pinned version + contract test).

4.3 Connecting users

  • Admin, Settings → Contacts (contacts.provider, contacts.contrack { url, publicUrl, companyTokenEnc }): Test (POST /api/contacts/contrack/test: /healthz, then the token against /api/contacts?view=slim; Contrack's /api/auth/me refuses tokens, so a token is checked by reading contacts).
  • Admin, same page: the company Contrack account's token (read-only is enough; read-write lets "Add to company contacts" work), write-only like the SMTP password (hasCompanyToken in GET). Like it, the saved token only goes to the address it was saved for: Test with another address sends none, and saving a new address needs the token typed again (400). A new address also clears everyone's own token: they connect again (08 §7 #7). The admin makes that account in Contrack (e.g. company@…) and fills it like any other.
  • Contrack tokens expire in 90 days unless made with Never: the guide and the forms say to pick Never (or a year).
  • Each user, My settings → Contacts: paste a Contrack personal token (read-write; read-only allowed, then nothing is written to the timeline). PUT /api/me/contrack { token } checks it reads contacts, then stores it with encrypt() in users.contrack_token_enc (contacts are a person's, not an extension's); DELETE disconnects. Never logged, never returned: GET /api/me has contrack: { connected, tokenPrefix: "ctk_AbCdEfGh…", publicUrl }.
    • Why not have the PBX create Contrack accounts: Contrack's admin routes refuse tokens (SESSION_REQUIRED), so the PBX would have to keep an admin password. A pasted token is explicit, revocable from Contrack, and scoped to one person.
  • Lookup cache (contacts/contrack.ts): per connected user and for the company account, GET /api/contacts?view=slim, every phone through normalizeNumber (text that isn't a number is left out), held in memory. As built: fetched on use when older than 5 minutes (no timer), forgotten when SelfPhoneNG adds a contact or the token changes. A failing Contrack keeps the last good list and the error, and is tried again 5 minutes later; a refused token (401) shows as "doesn't work any more" in My settings. No error email.
  • /me/contacts in this mode: search and list from Contrack (read-only rows, "Open in Contrack" link to ${publicUrl}/contact/:id), plus the company book. "Add to contacts" does POST /api/contacts { name, company, emails, phones: [{ phone, label }] }. Editing, deleting and importing answer 409 in this mode: they happen in Contrack. Desk phone directories (07) get the company account's contacts. Accepting a share creates the copy with the recipient's token; without one it answers 409 and the offer waits.

4.4 Calls on the Contrack timeline

New per-user options (My settings → Contacts, shown when connected):

  • Log my calls in Contrack (default on): each of my calls with a number that matches one of my Contrack contacts becomes a call interaction on that contact:

    • title: "Incoming call (answered)", "Missed call", "Outgoing call", "Voicemail left" …
    • date: calls.started_at; duration: talk minutes, rounded up; content: number, who answered, "recorded" (escaped HTML).
  • Add the call summary (default off, needs ai.callSummaries): the summary and action items (10 §3) go in the content; the first action item also becomes a Contrack follow-up (actionItem). Warning next to it: if Contrack's own AI uses a cloud provider, what was said on the call reaches it. The admin guide recommends AI_DISABLED=true or our Ollama for Contrack.

  • Job contrackLog (every 60 s, 04 §8): calls ended more than 1 min ago and not logged yet. A recorded call waits for its summary (recordings.ai_done_at) up to 30 minutes when the summary option is on. One row per logged call:

    create table contrack_call_log (
      call_id         uuid not null references calls(id) on delete cascade,
      user_id         uuid not null references users(id) on delete cascade,
      interaction_id  text,          -- null: no matching contact, or not logged
      logged_at       timestamptz not null default now(),
      primary key (call_id, user_id)
    );
    

    Contrack unreachable: try again next run, give up after 2 days (like call transcripts).

  • Unknown numbers are not added to Contrack automatically; "Add to contacts" does it, and calls from then on are logged.

As built (C5, contacts/timeline.ts):

  • Options on users (contrack_log_calls, contrack_log_summaries, contrack_log_since), PATCH /api/me/contrack { logCalls?, logSummaries? }, shown in GET /api/me contrack with summariesAvailable. Calls count from contrack_log_since: set when the token is first connected, and when logging is turned back on, so connecting doesn't fill the timeline with the last 2 days. Migration 0011 sets it to the migration time for people connected before.
  • Calls between colleagues (internal) aren't logged. Every person on the call (caller, rang, answered) gets it on their own timeline, as in My calls, with their own contact.
  • Titles: "Incoming call", "Incoming call, answered by Sue", "Missed call", "Missed call, voicemail left", "Outgoing call", "Outgoing call, busy" / "no answer". Content: <p>From … to … · 3:12 · recorded</p>, then the summary's sentences and an <ul> of its action items, all escaped. source: "selfphoneng". Contrack requires a due date on a follow-up: the day after the call, in the company time zone.
  • contrack_call_log also has error: a refused write (4xx other than 401/429: a read-only token, a deleted contact, a bad request) is noted once and not retried. Contrack down (5xx, unreachable), or a token it refuses (401): tried again on the next run.
  • Proven against Contrack 2.0.2 (contrack-live.test.ts): the entry, its follow-up, and the escaped content arrive. Contrack answers duration as a string ("4.0").

4.5 Not in Contrack mode

  • Editing a Contrack contact from SelfPhoneNG.
  • A contact in several books is shown once per book; lookup takes the first in §1's order.

5. Setup wizard and switching

  • New step Contacts, after Users (05 §2.1): two cards.
    • Simple: "A phonebook inside SelfPhoneNG: names, numbers, CSV import. Nothing else to run."
    • Contrack: "A full contact and relationship manager next to the phone system: notes, reminders, relationship tracking, imports from Google/Apple/LinkedIn, CardDAV sync. Every call lands on the contact's timeline. Runs as its own container." Then the URL field, Test, and the two commands to start deploy/contrack/compose.yml. Skippable: "Set it up later" keeps Simple.
  • Settings → Contacts can switch later. Builtin personal contacts are kept, not deleted; switching to Contrack offers Copy my contacts to Contrack per user (POST /api/contacts/bulk, ≤ 5,000), switching back just shows the builtin table again.

6. Order of work

Task What Done when
C1 Seam + Simple: migration, builtin provider, routes, /me/contacts, /admin/contacts, CSV and vCard import (§3.1), sharing (§2.1), "Add to contacts", lookup everywhere in §1 except desk phones a contact's name shows in call history, voicemail, call report and the ringing softphone; an iPhone and a Google .vcf export import
C2 Company book on desk phones (07) a Yealink shows company contacts in its directory. Already built in M10-T2 (loadPhonebook, the builtin company book); C3 adds the Contrack company account to it
C3 Contrack: decision 0003, deploy/contrack, Caddy/nginx blocks, Settings → Contacts, company token, per-user tokens, contrack provider + cache, sharing, setup wizard step with the owner's Contrack, a call from a Contrack contact shows their name
C4 Caller name on desk phones while ringing (owner, 2026-10-23: its own task after C3): the dialplan asks the api (AGI or func_curl) and sets CALLERID(name) a desk phone rings with the contact's name, from either kind of book. Done, see §6.1
C5 Calls on the Contrack timeline (§4.4), summaries opt-in an answered, recorded call shows up on the contact's Contrack timeline with its summary. Done, see §4.4 as built
C6 Copy builtin contacts to Contrack; personal and shared contacts in desk phone directories optional. Done, see §6.2

6.1 As built: C4

  • sp-sub-dial-user (03 §6.2), for calls from outside only: sp-sub-cnam asks GET /api/internal/caller-name?ext=&number=&token= with func_curl (connect 1 s, answer 2 s). A name goes on each ringing leg with a pre-dial handler, CONNECTEDLINE(name,i) (what a PJSIP phone shows in the INVITE's From), not on the call: the next ring group member, a queue agent or a forward does its own lookup in its own user's book, and never sees another's.
  • The prefix of a number, ring group or queue ("Sales:") is kept in front: __SP_CNAM_PREFIX grows the same way CALLERID(name) does.
  • No contact: with spam check on, the route waits up to 1.2 s for its answer and names a likely or possible spammer with spam.deskPhoneLabels (14 §5.2, §9.4).
  • Globals SP_CNAM_URL (http://127.0.0.1:API_PORT/…) and SP_CNAM_TOKEN (HMAC-SHA256 of APP_SECRET, so it survives restarts). The route answers 404 unless the request comes from this machine without X-Forwarded-For (not through the reverse proxy) and with the key, and isn't logged (the key is in the query). Names come back as letters, digits, spaces and . ' & @ -, at most 40 characters, so nothing can be read as dialplan or SIP syntax.
  • It never waits on Contrack: a stale book answers what it has and refreshes in the background, and the contactsWarm job refreshes every connected book every 5 minutes.
  • apps/api/test/asterisk/cnam-test.ts proves it on real Asterisk with a baresip phone: a contact's name, a stranger's provider name, "Sales: Jane Doe" through a ring group, 101's contact name not reaching 102 after a no-answer forward, and the phone still ringing (with the provider's name) when the api hangs.

6.2 As built: C6

  • A phone's directory (/prov/phonebook/<mac>.xml, 07) is the extensions, then its owner's own contacts (accepted shares are among them), then the company's, each by name, from the builtin books or Contrack. A phone whose extension has no user gets the extensions and the company only.
  • Contrack mode: GET /api/me/contacts (and /api/contacts) says in contrack.toCopy how many builtin contacts that book's Contrack account doesn't have yet (same name and number), and the page offers Copy them to Contrack: POST /api/me/contacts/copy-to-contrack (admin: /api/contacts/copy-to-contrack, audited) sends them in one POST /api/contacts/bulk (with an X-Import-Id). The builtin rows stay, for switching back; once copied the offer is gone. Contrack's own duplicate check then runs on the import as usual.
  • Proven against Contrack 2.0.2 (contrack-live.test.ts).

Tests: builtin lookup/search/import against the test DB; contrack provider against a fake HTTP server built from the pinned openapi.json; one e2e script against a real Contrack container.

7. Decided

  • Owner, 2026-10-23: caller name on desk phones while ringing is its own task, C4, after C3.
  • Owner, 2026-10-23: in Contrack mode the company book is a company Contrack account shared with everyone; each user has their own private Contrack account.
  • Owner, 2026-10-23: sharing offers a copy to picked colleagues or everyone, in both modes; each recipient accepts (it goes into their own book) or denies (nothing is added). An import or an accepted share skips contacts already in the book (§2.1, §3.1).
  • Owner, 2026-10-23: admins and users can import .vcf files (§3.1).