@dszp/netsapiens-lib
v0.10.0
Published
Portable, Node-free NetSapiens toolkit: split read/write API clients, JWT (ns_t) validation, and a snapshot -> FlowGraph -> Mermaid call-flow resolver/renderer. Runs unchanged in a Cloudflare Worker, Node, or the browser.
Maintainers
Readme
@dszp/netsapiens-lib
Portable, Node-free NetSapiens toolkit. The same code runs unchanged in a Cloudflare Worker, in
Node, or in the browser — it uses only Web APIs (fetch, atob, TextDecoder, crypto.subtle),
never node:*.
Five capabilities, one dependency-free package:
- NS API v2 client (read + write) —
NsClient(read-only:get()+fetchDomainSnapshot(client, domain)which assembles a routing-relevant domain snapshot) plusNsWriteClient, a separate write client (device provisioning). Both are bearer-auth with an injectablefetch; holding the read client still cannot write. - JWT (
ns_t) validation —verify()(cheap local format gate → cached live/jwtcheck) andvalidateJwtFormat(). PluggableVerdictCache(inject the Workers Cache API / KV / DO;MemoryVerdictCachefor dev). Anti-overload by design — a bad/expired token never hits the server. - Call-flow resolver + renderers —
resolveFlow(snapshot, ref)walks a NetSapiens domain snapshot into a normalizedFlowGraph;toMermaid()renders it to a Mermaid flowchart;renderGalleryHtml()/renderFlowCards()return HTML strings the caller can place anywhere. - Identity + policy —
toPrincipal()normalizes a validated token into an effective identity (masking-aware: the effective user is the masked one, theoperatoris the reseller behind amask_chain), andcan()/isAllowed()gate features against it with a declarative, fail-closed policy. So "who is this, and may they?" isn't re-invented per consumer. - Themes —
THEMES, a vendor-neutral registry (node palettes + Mermaid base/look + app chrome) as plain data. Add one here and every host picks it up; nothing is bound to one deployment's brand.
Install
npm install @dszp/netsapiens-lib # or: pnpm add / yarn addESM-only, zero runtime dependencies, ships its own types.
Usage
import { resolveFlow, toMermaid, renderGalleryHtml, verify, NsClient } from '@dszp/netsapiens-lib';
const graph = resolveFlow(snapshot, { kind: 'did', ref: '13175550100' });
const mermaid = toMermaid(graph);
const html = renderGalleryHtml(snapshot.meta.domain, [graph]);What NsClient covers
NsClient is deliberately not an enumeration of endpoints — it has exactly one method:
client.get<T>(path, query?) // any GET under https://{server}/ns-api/v2That's the whole surface. Any v2 read is reachable (/domains, /domains/{d}/users,
/domains/{d}/users/{ext}/devices, …) without this library needing to know about it, and one choke
point is what makes the read-only property below checkable rather than a promise. NetSapiens versions
drift; consult your server's own /ns-api/apidoc/ for the paths it offers.
Two composites are provided because they're multi-read and worth getting right once:
| Function | Reads |
|---|---|
| listDomains(client) | /domains → {domain, description, locked}[] |
| fetchDomainSnapshot(client, domain, opts?) | /domains/{d} plus, in parallel, timeframes, users, callqueues, phonenumbers, autoattendants — then per-user answerrules. Individual reads fail soft (a missing collection yields [], not a thrown snapshot). |
The snapshot is the routing subset — what resolveFlow() needs. It is not a full domain export. Four
options pull in more, each off by default because each costs an extra read: includeAddresses (E911
address records and emergency endpoints — two reads under one flag, because the endpoint is what the
carrier bills for and the address is the location it dispatches to, and counting one without the other
is how a domain gets billed for the wrong thing), includeSmsNumbers (the domain-level SMS-enabled
number list), includeDevices
(per-extension device records — one /devices read per real extension, so it is the expensive one on a
domain with many seats), and includeUserSmsNumbers (per-extension SMS numbers into
snapshot.smsNumbersByUser — same one-read-per-extension cost, needed because the domain-level list
doesn't say which user a number belongs to). All four exist for countDomainInventory() below; a caller
that only resolves call flows never needs them.
A per-extension devices read that fails with anything other than 404 does not abort the snapshot — the
extension stays in users with no entry in devicesByUser, and its number is recorded in
snapshot.deviceReadFailures instead. deviceReadFailures is set whenever includeDevices was asked
for (an empty array when nothing failed) and absent otherwise, so a device-count consumer can tell a
genuine zero from a read that never completed rather than silently undercounting.
includeUserSmsNumbers fails the same way into snapshot.smsReadFailures.
Counting a domain: countDomainInventory
countDomainInventory(snapshot, opts?) is pure — it fetches nothing, and turns a Snapshot (from
fetchDomainSnapshot, a backup, or a fixture) into a fixed tree of numeric leaves along the dimensions a
VoIP operator actually sells on:
extensions— real seats (users whoseservice-codeis empty or notsystem-*), bytotal, bybyScope(rawuser-scope), bybyServiceCode, bybyDeviceCount('0' | '1' | '2' | '3+'), and by device presence:withAnyDevice/withNoDevice(anyDevice = deviceCount > 0 || teams— a handset or a Teams connector, either counts; the two partitiontotal).systemUsers—system-aa,system-queue,system-todand friends:totalandbyServiceCode. Informational, never compared against a seat count.transcriptionEnabled— extensions with voicemail transcription on.teamsConnected— extensions with a Microsoft Teams connector device — one whose device-name suffix the legend marksteams(<ext>tmby default). That connector is excluded fromdevices/deviceCount: it is a connector, not a handset. See Device suffixes.dids— phone numbers:total/tollFree/local, all three excluding fax lines, plusfaxandall(everything,total + fax === all). See Fax lines.e911Addresses— E911 address records: dispatchable locations, information only.e911Endpoints— provisioned Emergency Endpoints: a callback number, a caller name, a billing address and a vendor. This is the unit the E911 carrier routes on and bills per. See E911.e911Legacy— distinct legacy emergency numbers on a domain that has no endpoint records. Comparable withe911Endpointsand never overlapping it.smsNumbers— record count.devices—totalandbyModel, real extensions only (a system user's device is not a seat).
Every leaf is a number, on purpose: a caller reconciling this against a billing system addresses a
dimension by dotted path (extensions.total, dids.tollFree) without this module knowing anything
about the billing side. It also never returns a device record — a NetSapiens device carries the SIP
registration password, so returning totals and model names only means a consumer showing inventory to
an operator cannot accidentally show a credential.
For a complete count, fetch the snapshot with all three extra options — dids, e911Addresses,
smsNumbers and devices all read as zero against a snapshot that omitted them:
import { fetchDomainSnapshot, countDomainInventory } from '@dszp/netsapiens-lib';
const snapshot = await fetchDomainSnapshot(client, 'acme.example', {
includeAddresses: true, includeSmsNumbers: true, includeDevices: true,
});
const inventory = countDomainInventory(snapshot);
inventory.dids.tollFree; // e.g. 3
inventory.devices.byModel; // e.g. { "Yealink T54W": 12, "(unknown)": 1 }Fax lines
On the portal's Fax Server treatment a fax line is an ordinary phone number whose dial rule hands it
to a fax server: dial-rule-application: to-connection and dial-rule-translation-destination-host set
to that server's host. The API has no fax-account endpoint and the ATA is not a device on any user, so
that host is the only thing that says "fax line" — and nothing in the API distinguishes an analog fax
from a digital one.
The host belongs to your deployment, so you supply it; with no hosts, nothing is a fax line and the counts are exactly what they were before 0.7.0:
const inventory = countDomainInventory(snapshot, { faxServerHosts: ['203.0.113.7'] });
inventory.dids.fax; // 2 — billed as fax lines
inventory.dids.total; // the DIDs, those two NOT among them
inventory.dids.all; // total + faxMatching is on the trimmed host, case-insensitively, and on nothing else — never the
dial-rule-description, which is a note the portal writes and an operator can edit. A fax line's
NumberItem.fax is true, its destination reads to fax server (the host is deliberately not shown),
and it keeps its kind: a fax number is local or toll-free like any other, it is simply not counted as a
DID.
Device suffixes
A device's suffix is what its name carries after the extension number: 1001wp on extension 1001
has suffix wp, a bare 1001 has none, and a name that does not start with the extension has none
either. Four suffixes ship with NetSapiens, and DEFAULT_DEVICE_SUFFIXES is that table:
// { wp: {label:'SNAPmobile Web'}, m: {label:'SNAPmobile'}, t: {label:'SNAPmobile Tablet'}, tm: {label:'Teams', teams:true} }
DEFAULT_DEVICE_SUFFIXES;NetSapiens moved the TeamMate Microsoft Teams connector from t to tm, and t now names SNAPmobile
running on a tablet. A deployment whose connectors still register as <ext>t keeps them counted by
supplying a legend that marks both — t: { label: 'Teams', teams: true } and the same for tm — because
under the default table an <ext>t device is a handset and teamsConnected will not see it.
Each device in ExtensionItem.devices carries its suffix (lower-cased) and the legend's label for it as
kind — '' when the suffix is empty or the legend does not carry it, because an unlisted suffix is a
name the deployment has not explained, not a device type to guess at. The suffix marked teams: true is
what identifies a Microsoft Teams connector: that is the whole of the test, so teamsConnected,
ExtensionItem.teams and the handset-only deviceCount/deviceModels all follow from the legend.
Your own suffixes go in deviceSuffixes, which replaces the default rather than merging with it:
listDomainInventory(snapshot, { deviceSuffixes: { r: { label: 'Acme App' }, tm: { label: 'Teams', teams: true } } });Replace-wholesale is deliberate. A deployment without TeamMate omits tm, and Teams detection is then off
entirely — every <ext>tm device is a handset and is counted as one — which a merge could not express. It
is also what lets a deployment mid-migration mark t and tm alike: nothing requires that only one
suffix carries teams.
Comparison is case-insensitive on both sides. resolveFlow labels a simultaneous-ring device from the
same default table (it takes no options — a call flow is drawn from a snapshot alone), so a suffix means
one thing across this library.
Listing a domain: listDomainInventory
countDomainInventory is a fold over listDomainInventory(snapshot, opts?) (same options), which returns the per-item lists
behind those counts — for anything that shows an operator which extension or number a count refers to,
not just how many. Same allowlist discipline as the counts: a device's MAC, SIP credentials and email
never appear, though names and sites now do (that's the point of a list). Each item carries a stable
key:
| List | Item key |
|---|---|
| extensions, systemUsers | ext:<user> |
| dids | did:<phonenumber> |
| e911Addresses | addr:<emergency-address-id> |
| e911Endpoints | e911:<callback digits> |
| e911Legacy | e911legacy:<digits> |
| smsNumbers | sms:<number> |
A number carries fax (see Fax lines) and destination — where it routes, in words
(to user 100 — Ann Lee, to queue 701 — Sales, to fax server), built by the exported
destinationOf(p, userByExt, faxServerHosts?) over usersByExt(users) — and description
(dial-rule-description, trimmed); an extension carries devices, an
{ name, model, teams, suffix, kind } per device it has (handset and Teams connector alike), in record
order — still never the MAC, SIP password or email. See Device suffixes for suffix
and kind.
itemsFor(detail, path) returns the items behind one of countDomainInventory's dotted-path counts —
the same vocabulary, so a UI that lets an operator drill from a count into the records behind it needs
no separate lookup table:
| Path | Items |
|---|---|
| extensions.total | every extension |
| extensions.byScope.<scope> | extensions with that user-scope |
| extensions.byServiceCode.<code> | extensions with that service-code (extensions.byServiceCode. selects the empty code) |
| extensions.byDeviceCount.<0\|1\|2\|3+> | extensions in that device-count bucket |
| extensions.withAnyDevice / extensions.withNoDevice | extensions with / without any device (handset or Teams connector) |
| transcriptionEnabled | extensions with transcription on |
| teamsConnected | extensions with a Teams connector |
| dids.total / dids.tollFree / dids.local | phone numbers, fax lines excluded |
| dids.fax | fax lines |
| dids.all | every phone number, fax lines included |
| e911Addresses | E911 addresses |
| e911Endpoints | Emergency endpoints |
| e911Legacy | Legacy emergency numbers |
| smsNumbers | SMS numbers |
devices.* and systemUsers.* paths return undefined — not [] — because there is no item list for
them (devices aren't compared individually; system users are informational, never compared). An
unrecognized path also returns undefined. itemLabel(item) gives one display line for any item, for
an operator-facing accept/reject list:
import { listDomainInventory, itemsFor, itemLabel } from '@dszp/netsapiens-lib';
const detail = listDomainInventory(snapshot);
const premium = itemsFor(detail, 'extensions.byServiceCode.premium') ?? [];
premium.map(itemLabel); // e.g. ["101 — Jane Doe, North", "102"]A domain often serves several billing accounts or physical sites at once, and a consumer reconciling
against one of them needs to know which inventory items are actually its own. attributeDomainInventory(snapshot)
answers that: it's pure (no account knowledge, just the snapshot) and labels every extension, number,
address and SMS number either own-site (the item's own site matches), via-user:<ext> /
via-users:<exts> (it's reachable only through another item that has a site), or an
unattributed:<reason> — no-site, routed-to:<x>, unreferenced, or
sms-user-unknown when a domain-level SMS number can't be matched to a user because the snapshot was
never fetched with includeUserSmsNumbers.
Each verdict carries sites: string[] as well as site. They agree wherever there is one site,
and only the three E911 kinds — an address, an endpoint, a legacy number — can carry more
than one: each is a fact about a place, and users on four sites can all reference it, so sites names
every one of them while site stays null. A consumer splitting a domain between billing accounts
should read sites — placing such an item on exactly one account leaves every other referencing
account's E911 line short.
Attribution reads two E911 inheritances into a blank field before it decides anything: a user with a
blank emergency-address-id is placed as referencing the domain's default address, and one with a blank
caller-id-number-emergency as referencing that address's endpoint. Reading the raw records instead
would call a domain's busiest address unreferenced — but both fallbacks are this library's inference,
not confirmed platform behaviour, and the same state can be read as an E911 gap. They fail closed
(nothing to inherit leaves the user referencing nothing), they move placement rather than counts, and
the ⚠️ on EmergencyModel sets out exactly what is known and what is assumed.
Filter listDomainInventory(snapshot)'s items by that
attribution to scope a domain down to one site, then run countInventoryDetail(detail) over what's left
to get counts that agree with what you kept, rather than recomputing countDomainInventory against the
whole domain and hoping the numbers happen to match.
E911: endpoints bill, addresses locate
Three things wear the E911 name in NetSapiens, and only one of them is billable.
An Emergency Endpoint (GET /domains/{d}/addresses/endpoints) is a callback number, a caller name,
a billing address and a vendor. It is what the carrier routes a 911 call on and what it charges for, and
it is counted as e911Endpoints. ⚠️ An endpoint record holds its callback NUMBER in the
emergency-address-id field — the same field name an address record uses for its own a-… id.
An Emergency Address is a dispatchable location forwarded to responders. Several can sit under one
endpoint, and nobody bills them; e911Addresses stays, as information.
A legacy emergency number has no API object at all. On a domain still on the pre-endpoint model every
user carries an empty emergency-address-id and a caller-id-number-emergency set to one of a handful
of DIDs, and the carrier bills per one of those DIDs. e911Legacy counts the distinct ones, excluding
any that is also an endpoint callback so a half-migrated domain is not billed twice for one place.
Users, devices and sites point at an endpoint through their Emergency Caller ID matching its callback
number, compared as digits (emergencyDigits collapses 1NXXNXXXXXX to ten and reads the [*]
wildcard as "not set"). resolveEmergency(snapshot) is the one place the domain-default fallbacks are
applied, and both the counter and attributeDomainInventory read it, so the count and the placement
cannot disagree about who references what. Read its ⚠️ before relying on the placement: that a blank
field falls back to the domain default at all, and that the default address's callback is reachable by
matching address-name against an endpoint, are two assumptions this library makes rather than two
things the platform documents. Both fail closed.
e911Legacy needs the endpoint list to have been read, because it is kept apart from e911Endpoints by
excluding numbers that are already endpoint callbacks. It is 0 whenever snapshot.addressEndpoints is
undefined — the fetch never asked — since a count derived from the users alone would report a
fully-migrated domain's every emergency caller ID as a billable line. An empty ARRAY is the other fact,
"asked, and there are none", and that one does support a count.
Read/write split by charter
NsClient exposes get() and nothing else, and verify() only ever issues GET /jwt. That is a
deliberate boundary, not a missing feature: this library is built for tools that visualize and audit a
NetSapiens domain, where "it cannot possibly write" is a property worth having structurally rather
than by convention. Writes live in a separate class — NsWriteClient, a small, explicitly-reviewed
surface (device provisioning) — never as new methods on NsClient. So a consumer that holds the read
client still cannot write; that guarantee holds by construction, not by convention.
Which writes actually confirm: synchronous
synchronous: 'yes' asks the API to finish the write before replying, so you get 200 with the
resulting resource inline — including server-generated fields you could not otherwise learn without a
second read, a new device's SIP registration password being the worked example. Without it you get
202 Accepted and a bare {code, message}.
It is a per-operation capability, not a global one: exactly 17 operations declare it in the v2 specification (core 44.4.10), and almost all of them are creates. Sending it anywhere else is inert — NetSapiens ignores unrecognized body fields and still answers 202 — so code that adds it everywhere merely looks as though its writes are confirmed.
NsWriteClient therefore injects the flag only where it is accepted, and exports the table so other
NetSapiens clients can share one answer instead of each keeping a copy that drifts:
import { supportsSynchronous, SYNCHRONOUS_OPERATIONS } from '@dszp/netsapiens-lib';
supportsSynchronous('POST', '/domains/acme.example/users'); // true — user CREATE
supportsSynchronous('PUT', '/domains/acme.example/users/100'); // false — user UPDATEpath is the concrete request path relative to /ns-api/v2, dynamic segments already URI-encoded.
The most consequential absence is that user update is not on the list even though user create is:
there is no response that can confirm a user update, so confirm it by reading the record back.
Configuration binds to your deployment
Two values are required and have no defaults, on purpose — a default would silently bind you to someone else's portal:
NsClient({ server })— your NS API host, e.g.api.example.com.verify(token, { expectedIss })— the Manager Portal host that issues yourns_t, e.g.manage.example.com. Pass an array when one backend is fronted by several portal hostnames (exact match, no wildcards), orvalidateIss: falseto opt out deliberately.
aud defaults to "ns" because that value is fixed by the NetSapiens platform and true for everyone.
Develop
pnpm install
pnpm build # tsc → dist/
pnpm test # the offline suite — green with no credentials, no setupThe build (tsconfig.json) omits @types/node on purpose: a stray node:* import fails the build,
which is how the Node-free guarantee is enforced.
pnpm test:ns <snapshot.json> is separate and not part of pnpm test: it needs a real domain
snapshot, which is customer data and correctly absent from this repo.
Docs
- ARCHITECTURE.md — module boundaries, why the live
/jwtcall is the signature authority, the Mermaid rendering traps, and the NetSapiens routing model the resolver decodes. - CONTRIBUTING.md — the rules: fictional fixtures, no deployment-binding defaults, doc comments are published API, Node-free.
- CHANGELOG.md
License
MIT © David Szpunar
