npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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.

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) plus NsWriteClient, a separate write client (device provisioning). Both are bearer-auth with an injectable fetch; holding the read client still cannot write.
  • JWT (ns_t) validationverify() (cheap local format gate → cached live /jwt check) and validateJwtFormat(). Pluggable VerdictCache (inject the Workers Cache API / KV / DO; MemoryVerdictCache for dev). Anti-overload by design — a bad/expired token never hits the server.
  • Call-flow resolver + renderersresolveFlow(snapshot, ref) walks a NetSapiens domain snapshot into a normalized FlowGraph; toMermaid() renders it to a Mermaid flowchart; renderGalleryHtml() / renderFlowCards() return HTML strings the caller can place anywhere.
  • Identity + policytoPrincipal() normalizes a validated token into an effective identity (masking-aware: the effective user is the masked one, the operator is the reseller behind a mask_chain), and can() / isAllowed() gate features against it with a declarative, fail-closed policy. So "who is this, and may they?" isn't re-invented per consumer.
  • ThemesTHEMES, 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 add

ESM-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/v2

That'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 whose service-code is empty or not system-*), by total, by byScope (raw user-scope), by byServiceCode, by byDeviceCount ('0' | '1' | '2' | '3+'), and by device presence: withAnyDevice / withNoDevice (anyDevice = deviceCount > 0 || teams — a handset or a Teams connector, either counts; the two partition total).
  • systemUserssystem-aa, system-queue, system-tod and friends: total and byServiceCode. 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 marks teams (<ext>tm by default). That connector is excluded from devices/deviceCount: it is a connector, not a handset. See Device suffixes.
  • dids — phone numbers: total / tollFree / local, all three excluding fax lines, plus fax and all (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 with e911Endpoints and never overlapping it.
  • smsNumbers — record count.
  • devicestotal and byModel, 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 + fax

Matching 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 UPDATE

path 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 your ns_t, e.g. manage.example.com. Pass an array when one backend is fronted by several portal hostnames (exact match, no wildcards), or validateIss: false to 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 setup

The 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 /jwt call 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