@dszp/netsapiens-lib 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -37,7 +37,7 @@ export {
37
37
  } from './html.js';
38
38
  export { resolveSvgSize, rasterizerScript } from './raster.js';
39
39
  export { NsClient, NsApiError, assertBareServer, fetchDomainSnapshot, listDomains, asArray, type NsClientConfig, type FetchSnapshotOptions } from './nsClient.js';
40
- export { countDomainInventory, countInventoryDetail, listDomainInventory, itemsFor, itemLabel, destinationOf, usersByExt, DEFAULT_DEVICE_SUFFIXES, type DeviceSuffixLegend, type DomainInventory, type DomainInventoryDetail, type InventoryOptions, type ExtensionItem, type NumberItem, type AddressItem, type SmsItem, type InventoryItem } from './inventory.js';
40
+ export { countDomainInventory, countInventoryDetail, listDomainInventory, itemsFor, itemLabel, destinationOf, emergencyDigits, legacyEmergencyNumber, resolveEmergency, usersByExt, DEFAULT_DEVICE_SUFFIXES, type DeviceSuffixLegend, type DomainInventory, type DomainInventoryDetail, type EmergencyModel, type InventoryOptions, type ExtensionItem, type NumberItem, type AddressItem, type EndpointItem, type LegacyE911Item, type SmsItem, type InventoryItem } from './inventory.js';
41
41
  export { attributeDomainInventory, type DomainAttribution, type ItemAttribution } from './attribution.js';
42
42
  export { NsWriteClient, type NsWriteClientConfig } from './nsWriteClient.js';
43
43
  export {
@@ -1,5 +1,5 @@
1
1
  /** Offline test for the domain inventory counter. pnpm test:inventory */
2
- import { countDomainInventory, listDomainInventory, itemsFor, itemLabel, destinationOf, usersByExt, DEFAULT_DEVICE_SUFFIXES } from './inventory.js';
2
+ import { countDomainInventory, emergencyDigits, legacyEmergencyNumber, listDomainInventory, itemsFor, itemLabel, destinationOf, resolveEmergency, usersByExt, DEFAULT_DEVICE_SUFFIXES } from './inventory.js';
3
3
  import type { Rec, Snapshot } from './model.js';
4
4
 
5
5
  let pass = 0, fail = 0;
@@ -474,5 +474,191 @@ ok(sysDev.devices.total === 0, 'a system user device is not counted');
474
474
  '[suffix] the exported default marks only t as the Teams connector');
475
475
  }
476
476
 
477
+ // ── E911: endpoints bill, addresses locate, legacy numbers are derived ──────────────────────────────
478
+ {
479
+ // One domain default address, one endpoint bound to it by name, and a second endpoint that nothing
480
+ // defaults to. `emergency-address-id` on an ENDPOINT is the callback number; on an ADDRESS it is the
481
+ // `a-…` id — the same field name meaning two things is the trap this fixture pins down.
482
+ const e911: Snapshot = {
483
+ meta: { domain: 'acme.example' },
484
+ users: [
485
+ { user: '100', 'service-code': '', site: 'North', 'emergency-address-id': 'a-1', 'caller-id-number-emergency': '3175550100' },
486
+ // Eleven digits where the endpoint says ten — the same endpoint, and the point of `emergencyDigits`.
487
+ { user: '101', 'service-code': '', site: 'North', 'emergency-address-id': 'a-2', 'caller-id-number-emergency': '13175550101' },
488
+ // Both fields blank: inherits the domain default address AND its endpoint. Not legacy.
489
+ { user: '102', 'service-code': '', site: 'South' },
490
+ // The wildcard is "not set", not a value.
491
+ { user: '103', 'service-code': '', site: 'South', 'caller-id-number-emergency': '[*]' },
492
+ { user: '700', 'service-code': 'system-aa', 'caller-id-number-emergency': '3175559999' },
493
+ ],
494
+ addresses: [
495
+ { 'emergency-address-id': 'a-1', 'address-name': 'HQ', 'address-line-1': '1 Main St', 'address-city': 'Springfield', domain_default: true },
496
+ { 'emergency-address-id': 'a-2', 'address-name': 'Annex', 'address-line-1': '2 Side St', 'address-city': 'Springfield' },
497
+ ],
498
+ addressEndpoints: [
499
+ { 'emergency-address-id': '3175550100', 'address-name': 'HQ', 'caller-name': 'Acme HQ', 'address-line-1': '1 Main St', 'address-city': 'Springfield', 'count-users-configured': 3, sub_count_total: 9 },
500
+ { 'emergency-address-id': '3175550101', 'address-name': 'Annex', 'caller-name': '', 'address-line-1': '', 'address-city': '' },
501
+ ],
502
+ } as Snapshot;
503
+
504
+ const inv = countDomainInventory(e911);
505
+ const d = listDomainInventory(e911);
506
+ ok(inv.e911Endpoints === 2, '[e911] two provisioned endpoints');
507
+ ok(inv.e911Addresses === 2, '[e911] and the two addresses are still counted, as information');
508
+ ok(inv.e911Legacy === 0, '[e911] a domain on the endpoint model has no legacy numbers');
509
+
510
+ const hq = d.e911Endpoints[0]!;
511
+ ok(hq.key === 'e911:3175550100', '[e911] the key is the callback, taken from the endpoint’s emergency-address-id');
512
+ ok(hq.callback === '3175550100' && hq.callerName === 'Acme HQ', '[e911] the callback and the caller name come off the record');
513
+ ok(hq.billingAddress === '1 Main St, Springfield', '[e911] the billing address is one line, no wider than the address list');
514
+ ok(hq.users === 3, '[e911] users is count-users-configured, not sub_count_total');
515
+ ok(itemLabel(hq) === '3175550100 — Acme HQ, 1 Main St, Springfield', '[e911] the label names the number, then who and where');
516
+ ok(itemLabel(d.e911Endpoints[1]!) === '3175550101', '[e911] and an endpoint with neither is just its number — no dangling dash');
517
+ {
518
+ // NO CALLBACK. The label is what `applyAssignment` writes into acceptance history, so a leading
519
+ // dash is bad and an empty string is worse — it records a decision about a thing it cannot name.
520
+ const [named, blank] = listDomainInventory({ ...e911, addressEndpoints: [
521
+ { 'caller-name': 'Acme Dock', 'address-line-1': '9 Dock Rd', 'address-city': 'Springfield' },
522
+ { 'address-name': 'Nothing' },
523
+ ] } as Snapshot).e911Endpoints;
524
+ ok(itemLabel(named!) === 'Acme Dock, 9 Dock Rd, Springfield', '[e911] an endpoint with no callback leads with what it does have, never a dash');
525
+ ok(itemLabel(blank!) === `(endpoint ${blank!.key.slice('e911:'.length)})`,
526
+ '[e911] and one with nothing at all is named by its derived key - never the empty string');
527
+ ok(itemLabel(blank!) !== '', '[e911] which is the fact that matters: an acceptance row can always name its item');
528
+ }
529
+ ok(itemsFor(d, 'e911Endpoints')!.length === 2, '[e911] itemsFor answers the endpoints path');
530
+ ok(itemsFor(d, 'e911Addresses')!.length === 2, '[e911] and the addresses path still answers separately');
531
+
532
+ const em = resolveEmergency(e911);
533
+ ok(em.defaultAddressId === 'a-1', '[e911] the domain default is the address flagged domain_default');
534
+ ok(em.defaultCallback === '3175550100', '[e911] whose callback is joined through the endpoint naming the same address');
535
+ ok(em.addressIdFor(e911.users![2]!) === 'a-1', '[e911] a user with a blank address id inherits the domain default');
536
+ ok(em.addressIdFor(e911.users![1]!) === 'a-2', '[e911] and one that sets its own keeps it');
537
+ ok(em.callbackFor(e911.users![2]!) === '3175550100', '[e911] a user with a blank caller ID inherits the default address’s callback');
538
+ ok(em.callbackFor(e911.users![1]!) === '3175550101', '[e911] an 11-digit caller ID resolves to the 10-digit endpoint');
539
+ ok(em.setCallbackFor(e911.users![3]!) === '', '[e911] the [*] wildcard is not set');
540
+ ok(em.callbackFor(e911.users![3]!) === '3175550100', '[e911] so that user inherits the default too');
541
+
542
+ // An endpoint whose record names no callback still has to be a distinguishable row.
543
+ const blank = listDomainInventory({ ...e911, addressEndpoints: [{ 'address-name': 'Dock', 'caller-name': 'Acme Dock' }] } as Snapshot).e911Endpoints[0]!;
544
+ ok(blank.key.startsWith('e911:~'), '[e911] an endpoint with no callback falls back to a derived key');
545
+ }
546
+
547
+ {
548
+ // A LEGACY domain: no endpoints at all, every user with a blank emergency-address-id and one of two
549
+ // numbers set by hand. Measured shape — a 100-user domain with exactly two such numbers.
550
+ const legacy: Snapshot = {
551
+ meta: { domain: 'demo.12345.service' },
552
+ users: [
553
+ { user: '100', 'service-code': '', site: 'North', 'caller-id-number-emergency': '3175550200' },
554
+ { user: '101', 'service-code': '', site: 'North', 'caller-id-number-emergency': '13175550200' },
555
+ { user: '102', 'service-code': '', site: 'South', 'caller-id-number-emergency': '3175550201' },
556
+ // Nothing set anywhere and no domain default to inherit: not legacy, and not anything else.
557
+ { user: '103', 'service-code': '', site: 'South' },
558
+ // On its device rather than on the user.
559
+ { user: '104', 'service-code': '', site: 'South' },
560
+ // A system user is not a seat and does not make a number legacy.
561
+ { user: '700', 'service-code': 'system-queue', 'caller-id-number-emergency': '3175550299' },
562
+ ],
563
+ devicesByUser: { '104': [{ device: 'sip:104@demo.12345.service', 'caller-id-number-emergency': '3175550201' }] },
564
+ // READ, and there are none — which is what a legacy domain looks like. An ABSENT list means the
565
+ // fetch never asked, and then no legacy count is derivable at all; that pair is tested below.
566
+ addressEndpoints: [],
567
+ } as Snapshot;
568
+
569
+ const inv = countDomainInventory(legacy);
570
+ const d = listDomainInventory(legacy);
571
+ ok(inv.e911Legacy === 2, '[legacy] two DISTINCT numbers across five seats, matching the carrier’s two lines');
572
+ ok(inv.e911Endpoints === 0 && inv.e911Addresses === 0, '[legacy] and no endpoints or addresses to count');
573
+ ok(d.e911Legacy.map((x) => x.key).join() === 'e911legacy:3175550200,e911legacy:3175550201', '[legacy] keyed by the digits');
574
+ ok(d.e911Legacy[0]!.users === 2, '[legacy] the 10- and 11-digit spellings are one number');
575
+ ok(d.e911Legacy[1]!.users === 2, '[legacy] and a device’s number counts when the user sets none');
576
+ ok(itemLabel(d.e911Legacy[0]!) === '3175550200 — legacy E911 (2 users)', '[legacy] the label says why a bare number is on an E911 row');
577
+ ok(itemLabel({ key: 'e911legacy:3175550299', number: '3175550299', users: 1 }) === '3175550299 — legacy E911 (1 user)',
578
+ '[legacy] and it agrees with itself on one — a label that reads "(1 users)" reads as a rendering fault');
579
+ ok(itemsFor(d, 'e911Legacy')!.length === 2, '[legacy] itemsFor answers the legacy path');
580
+
581
+ // HALF-MIGRATED: one of the two numbers is now a provisioned endpoint. It must be counted once, as an
582
+ // endpoint, or the domain pays for the same place twice.
583
+ const half = { ...legacy, addressEndpoints: [{ 'emergency-address-id': '3175550200', 'address-name': 'HQ', 'caller-name': 'Demo HQ' }] } as Snapshot;
584
+ const hi = countDomainInventory(half);
585
+ ok(hi.e911Endpoints === 1 && hi.e911Legacy === 1, '[legacy] a number that became an endpoint leaves the legacy count');
586
+ ok(listDomainInventory(half).e911Legacy[0]!.number === '3175550201', '[legacy] and the one still on the old model stays');
587
+
588
+ // A user with BOTH fields blank on a domain that HAS a default is on the new model, not the old one.
589
+ const withDefault = {
590
+ ...legacy,
591
+ users: [{ user: '110', 'service-code': '', site: 'North' }],
592
+ devicesByUser: {},
593
+ addresses: [{ 'emergency-address-id': 'a-9', 'address-name': 'HQ', domain_default: true }],
594
+ addressEndpoints: [{ 'emergency-address-id': '3175550300', 'address-name': 'HQ', 'caller-name': 'Demo HQ' }],
595
+ } as Snapshot;
596
+ ok(countDomainInventory(withDefault).e911Legacy === 0, '[legacy] both fields blank is the domain default, not a legacy number');
597
+ const em = resolveEmergency(withDefault);
598
+ ok(legacyEmergencyNumber(withDefault.users![0]!, em) === '', '[legacy] and the predicate says so directly');
599
+
600
+ // A user who SETS an emergency address is on the new model whatever their caller ID says — even when
601
+ // that number matches no endpoint this snapshot can see. Without the address-id clause of
602
+ // `legacyEmergencyNumber` this reads as a legacy line, and the account is billed for one.
603
+ const addressed = {
604
+ ...legacy,
605
+ users: [{ user: '120', 'service-code': '', site: 'North', 'emergency-address-id': 'a-9', 'caller-id-number-emergency': '3175550777' }],
606
+ devicesByUser: {},
607
+ addresses: [{ 'emergency-address-id': 'a-9', 'address-name': 'HQ', domain_default: true }],
608
+ addressEndpoints: [{ 'emergency-address-id': '3175550300', 'address-name': 'HQ', 'caller-name': 'Demo HQ' }],
609
+ } as Snapshot;
610
+ ok(countDomainInventory(addressed).e911Legacy === 0,
611
+ '[legacy] a user with a SET emergency address is never legacy, whatever its caller ID matches');
612
+ ok(legacyEmergencyNumber(addressed.users![0]!, resolveEmergency(addressed)) === '',
613
+ '[legacy] and the predicate refuses it on the address id alone, not on the number');
614
+ }
615
+
616
+ {
617
+ // ── the USER's own caller ID wins over its devices' ────────────────────────────────────────────
618
+ // The device is consulted only where the user sets nothing. Reversing the two would attribute the
619
+ // seat to whichever handset happened to be first in the record, which is not what the portal does.
620
+ const both = {
621
+ meta: { domain: 'acme.example' },
622
+ users: [{ user: '100', 'service-code': '', 'caller-id-number-emergency': '3175550100' }],
623
+ devicesByUser: { '100': [{ device: 'sip:100@acme.example', 'caller-id-number-emergency': '3175550101' }] },
624
+ addressEndpoints: [],
625
+ } as Snapshot;
626
+ ok(resolveEmergency(both).setCallbackFor(both.users![0]!) === '3175550100',
627
+ '[e911] a user carrying its own caller ID wins over a device carrying a different one');
628
+ const deviceOnly = { ...both, users: [{ user: '100', 'service-code': '' }] } as Snapshot;
629
+ ok(resolveEmergency(deviceOnly).setCallbackFor(deviceOnly.users![0]!) === '3175550101',
630
+ '[e911] and the device is read only where the user sets nothing');
631
+ }
632
+
633
+ {
634
+ // ── the ENDPOINTS list was never READ ──────────────────────────────────────────────────────────
635
+ // `undefined` (the fetch never asked) and `[]` (asked, and the domain has none) are different facts,
636
+ // and only the second one can support a legacy count: without the endpoint list there is nothing to
637
+ // exclude against, so every emergency caller ID on a fully-migrated domain would read as a legacy
638
+ // line. An under-count is safe here; a confident over-count on a billing page is not.
639
+ const users = [
640
+ { user: '100', 'service-code': '', site: 'North', 'emergency-address-id': '', 'caller-id-number-emergency': '3175550100' },
641
+ { user: '101', 'service-code': '', site: 'North', 'emergency-address-id': '', 'caller-id-number-emergency': '3175550101' },
642
+ ];
643
+ const never = { meta: { domain: 'acme.example' }, users } as Snapshot;
644
+ const read = { ...never, addressEndpoints: [] } as Snapshot;
645
+ const provisioned = { ...never, addressEndpoints: [
646
+ { 'emergency-address-id': '3175550100', 'address-name': 'HQ', 'caller-name': 'Acme HQ' },
647
+ { 'emergency-address-id': '3175550101', 'address-name': 'Annex', 'caller-name': 'Acme Annex' },
648
+ ] } as Snapshot;
649
+ ok(countDomainInventory(never).e911Legacy === 0 && listDomainInventory(never).e911Legacy.length === 0,
650
+ '[legacy] no endpoint read at all answers 0 legacy numbers rather than inventing two');
651
+ ok(countDomainInventory(read).e911Legacy === 2,
652
+ '[legacy] while an endpoint list that was read and is EMPTY is a real legacy domain');
653
+ ok(countDomainInventory(provisioned).e911Legacy === 0 && countDomainInventory(provisioned).e911Endpoints === 2,
654
+ '[legacy] and the same two users on the endpoint model are two endpoints and no legacy numbers');
655
+ }
656
+
657
+ ok(emergencyDigits('+1 (317) 555-0100') === '3175550100' && emergencyDigits('13175550100') === '3175550100',
658
+ '[e911] a caller ID normalises to ten digits however it is punctuated');
659
+ ok(emergencyDigits('[*]') === '' && emergencyDigits('') === '' && emergencyDigits(undefined) === '',
660
+ '[e911] and the three ways of saying "not set" all answer empty');
661
+
662
+
477
663
  console.log(`\n${pass} passed, ${fail} failed`);
478
664
  if (fail) process.exit(1);
package/src/inventory.ts CHANGED
@@ -38,6 +38,35 @@
38
38
  * case-insensitively, and on nothing else — not the `dial-rule-description`, which is a portal-written
39
39
  * note an operator can edit. **With no hosts supplied nothing is a fax line**, because a library that
40
40
  * hardcoded one deployment's fax server would be wrong everywhere else.
41
+ *
42
+ * ## E911: the ENDPOINT is the billable unit, the address is a location
43
+ *
44
+ * An **Emergency Endpoint** is a callback number, a caller name, a billing address and a vendor. It is
45
+ * what the E911 carrier routes on and what it bills per, and it is counted as `e911Endpoints`. An
46
+ * **Emergency Address** is a dispatchable location forwarded to responders; several of them can sit
47
+ * under one endpoint, and nobody bills them. `e911Addresses` stays, as information.
48
+ *
49
+ * Users, devices and sites point at an endpoint through their Emergency Caller ID
50
+ * (`caller-id-number-emergency`) matching the endpoint's callback number. This library then INFERS two
51
+ * fallbacks for a blank field — a blank `emergency-address-id` reads as the domain default address, and
52
+ * a blank caller ID reads as that address's endpoint. **Neither is a measured platform behaviour**; see
53
+ * the ⚠️ on {@link EmergencyModel} for what is known and what is assumed. They live in
54
+ * {@link resolveEmergency} alone, so the counter and `attribution.ts` cannot disagree about them, and
55
+ * they fail closed: nothing to inherit leaves a user referencing nothing rather than referencing a
56
+ * guess.
57
+ *
58
+ * ## Legacy emergency numbers, which have no API object at all
59
+ *
60
+ * A domain still on the legacy provisioning model has no endpoint records. Every one of its users
61
+ * carries an EMPTY `emergency-address-id` and a `caller-id-number-emergency` set to one of a handful of
62
+ * DIDs — and the carrier bills per one of those DIDs, exactly as it bills per endpoint on the new
63
+ * model. `e911Legacy` is therefore the count of DISTINCT such numbers, and a rulebook can count the two
64
+ * dimensions together so one retail E911 line pays for either model.
65
+ *
66
+ * A number that IS an endpoint callback is excluded, because a half-migrated domain that counted it in
67
+ * both dimensions would bill the same place twice. That exclusion is also the precondition: with no
68
+ * endpoint list read there is nothing to exclude against, so `e911Legacy` is 0 whenever
69
+ * `snapshot.addressEndpoints` is `undefined` rather than an array. See {@link DomainInventory.e911Legacy}.
41
70
  */
42
71
  import type { Rec, Snapshot } from './model.js';
43
72
 
@@ -80,8 +109,25 @@ export interface DomainInventory {
80
109
  * numbers this returned before 0.7.0, unchanged.
81
110
  */
82
111
  dids: { total: number; tollFree: number; local: number; fax: number; all: number };
83
- /** E911 address records on the domain. */
112
+ /**
113
+ * E911 address records on the domain — dispatchable LOCATIONS, and information only. The billable
114
+ * unit is {@link DomainInventory.e911Endpoints}; see the module doc.
115
+ */
84
116
  e911Addresses: number;
117
+ /** Provisioned Emergency Endpoints — the unit the E911 carrier bills per. */
118
+ e911Endpoints: number;
119
+ /**
120
+ * Distinct legacy emergency numbers — the pre-endpoint model, which has no API object of its own.
121
+ * Comparable with {@link DomainInventory.e911Endpoints}, and never overlapping it **provided the
122
+ * snapshot carries an endpoint list**: the two are kept apart by excluding numbers that are already
123
+ * endpoint callbacks, which needs the endpoints to have been read.
124
+ *
125
+ * So this is **0 whenever `snapshot.addressEndpoints` is `undefined`** — the fetch never asked, and a
126
+ * count derived from the users alone would report a fully-migrated domain's every emergency caller ID
127
+ * as a line to bill for. An empty ARRAY is the other fact — asked, and the domain has none — and that
128
+ * one does support a count. Fetch with `includeAddresses: true` (see `fetchDomainSnapshot`).
129
+ */
130
+ e911Legacy: number;
85
131
  /** SMS-enabled numbers on the domain. */
86
132
  smsNumbers: number;
87
133
  /** Devices belonging to real extensions only — a system user's device is not a seat. */
@@ -154,8 +200,40 @@ export interface NumberItem {
154
200
  description: string;
155
201
  }
156
202
  export interface AddressItem { key: string /* addr:<emergency-address-id>, or addr:~<hash> when the id is blank */; label: string }
203
+ /**
204
+ * One provisioned Emergency Endpoint — the thing the E911 carrier bills per.
205
+ *
206
+ * An allowlist like every other item: the callback, the caller name, and the billing address reduced to
207
+ * the ONE line {@link AddressItem} already exposes. The endpoint record also carries a geolocation XML
208
+ * and a public IP, and neither belongs in front of a billing operator.
209
+ */
210
+ export interface EndpointItem {
211
+ /** `e911:<callback>` — the digits, so a device's 11-digit form and the record's 10-digit form are one
212
+ * key. `e911:~<hash>` when the record names no callback at all. */
213
+ key: string;
214
+ /** The callback number, digits only ({@link emergencyDigits}); `''` when the record has none. */
215
+ callback: string;
216
+ /** `caller-name` — who the carrier announces; `''` when blank. */
217
+ callerName: string;
218
+ /** Street and city, one line — no more of the billing address than the address list already shows. */
219
+ billingAddress: string;
220
+ /** How many users the RECORD says are configured on it (`count-users-configured`), 0 when it says nothing. */
221
+ users: number;
222
+ }
223
+ /**
224
+ * One legacy emergency number: a `caller-id-number-emergency` in use on a domain that has no endpoint
225
+ * records for it. Derived from the users, because the legacy model has no object of its own to read.
226
+ */
227
+ export interface LegacyE911Item {
228
+ /** `e911legacy:<digits>`. Never a hash fallback — a blank number is not one of these. */
229
+ key: string;
230
+ /** The number, digits only ({@link emergencyDigits}). */
231
+ number: string;
232
+ /** How many real extensions reference it — counted here, not read off a record. */
233
+ users: number;
234
+ }
157
235
  export interface SmsItem { key: string /* sms:<number>, or sms:~<hash> when the number is blank */; number: string }
158
- export type InventoryItem = ExtensionItem | NumberItem | AddressItem | SmsItem;
236
+ export type InventoryItem = ExtensionItem | NumberItem | AddressItem | EndpointItem | LegacyE911Item | SmsItem;
159
237
 
160
238
  export interface DomainInventoryDetail {
161
239
  /** Real seats only, same rule as the count. */
@@ -164,6 +242,8 @@ export interface DomainInventoryDetail {
164
242
  systemUsers: ExtensionItem[];
165
243
  dids: NumberItem[];
166
244
  e911Addresses: AddressItem[];
245
+ e911Endpoints: EndpointItem[];
246
+ e911Legacy: LegacyE911Item[];
167
247
  smsNumbers: SmsItem[];
168
248
  }
169
249
 
@@ -279,6 +359,25 @@ function isFaxLine(p: Rec, hosts: Set<string>): boolean {
279
359
  return hosts.has(str(p['dial-rule-translation-destination-host']).toLowerCase());
280
360
  }
281
361
 
362
+ /**
363
+ * An Emergency Caller ID reduced to what two records can be compared on: its digits, with an 11-digit
364
+ * `1NXXNXXXXXX` collapsed to its 10-digit form so a device's spelling matches an endpoint's.
365
+ *
366
+ * `''` for "not set", which the API says three ways: empty, absent, and the `[*]` wildcard the portal
367
+ * renders as "Select a Caller ID for 911 calls". Treating `[*]` as a value would give every unset
368
+ * device on a domain one shared fake endpoint.
369
+ */
370
+ export function emergencyDigits(v: unknown): string {
371
+ const raw = str(v);
372
+ if (!raw || raw === '[*]') return '';
373
+ const digits = raw.replace(/\D+/g, '');
374
+ if (!digits) return '';
375
+ return digits.length === 11 && digits.startsWith('1') ? digits.slice(1) : digits;
376
+ }
377
+
378
+ /** A NetSapiens boolean, which arrives as a JSON `true` from one endpoint and as `"yes"` from another. */
379
+ const flag = (v: unknown): boolean => v === true || ['yes', 'true', '1'].includes(str(v).toLowerCase());
380
+
282
381
  /**
283
382
  * A device's NAME — the local part of its SIP URI (`sip:103t@acme.example` → `103t`), which is the short
284
383
  * id the portal shows and the string the Teams test matches against.
@@ -380,6 +479,107 @@ export function usersByExt(users: Rec[]): Map<string, Rec> {
380
479
  return map;
381
480
  }
382
481
 
482
+ /**
483
+ * How a domain's E911 records join up, and the two INHERITANCES this library reads into a blank field.
484
+ *
485
+ * Both the counter and `attribution.ts` need to answer "which endpoint does this user reference?" and
486
+ * "which address?". Resolved in one place so the count and the site attribution cannot disagree about
487
+ * who references what.
488
+ *
489
+ * ⚠️ **Both inheritances are this library's inference, not a measured platform behaviour.** Two
490
+ * separate assumptions sit under them, and a consumer relying on the placement should know which:
491
+ *
492
+ * 1. **That a blank field falls back to the domain default at all.** A user with a blank
493
+ * `caller-id-number-emergency` is read here as referencing the default address's endpoint, and one
494
+ * with a blank `emergency-address-id` as referencing the default address. That is a plausible
495
+ * reading of a record the portal badges "Domain Default" — but it has not been confirmed against a
496
+ * live 911 call, and the same state can be read as an E911 GAP rather than an inheritance.
497
+ * 2. **That the default address's callback is joined by `address-name`.** An address record carries no
498
+ * callback field of its own (checked against a live domain and 34 captured snapshots), so the only
499
+ * thing tying the domain default to an endpoint is that the endpoint names the same address. Every
500
+ * captured domain carrying both agreed on that name.
501
+ *
502
+ * Both fail CLOSED. No default address, no endpoint naming it, or a name that does not match, and
503
+ * `defaultCallback` is `''` — the users who would have inherited it reference nothing and are left
504
+ * unattributed, rather than being attached to a guess. The COUNTS are unaffected either way
505
+ * (`e911Endpoints` is a record count, and a user with both fields blank is correctly not legacy); what
506
+ * these assumptions move is PLACEMENT, which on a split domain decides which accounts are told they
507
+ * need an E911 line.
508
+ */
509
+ export interface EmergencyModel {
510
+ /** The `emergency-address-id` of the record marked `domain_default`; `''` when the domain has none. */
511
+ defaultAddressId: string;
512
+ /** The callback of the endpoint bound to the DEFAULT address, digits only; `''` when there is none. */
513
+ defaultCallback: string;
514
+ /** Every provisioned endpoint's callback, digits only — the "is this number already an endpoint?" test. */
515
+ endpointCallbacks: Set<string>;
516
+ /** Which address a user references: their own field, the domain default when it is blank. */
517
+ addressIdFor: (user: Rec) => string;
518
+ /** The callback a user SETS: their own field, else any of their devices'; `''` when neither does. */
519
+ setCallbackFor: (user: Rec) => string;
520
+ /** Which endpoint a user references: {@link EmergencyModel.setCallbackFor}, else the domain default's. */
521
+ callbackFor: (user: Rec) => string;
522
+ }
523
+
524
+ export function resolveEmergency(snapshot: Snapshot): EmergencyModel {
525
+ const addresses: Rec[] = Array.isArray(snapshot.addresses) ? snapshot.addresses : [];
526
+ const endpoints: Rec[] = Array.isArray(snapshot.addressEndpoints) ? snapshot.addressEndpoints : [];
527
+ const devicesByUser: Record<string, Rec[]> = (snapshot.devicesByUser ?? {}) as Record<string, Rec[]>;
528
+
529
+ // First one wins. Two records marked default is a provisioning fault, and picking one of them
530
+ // silently is better than resolving every blank user to nothing because two records disagree.
531
+ const def = addresses.find((a) => flag(a['domain_default']));
532
+ const defaultAddressId = def ? str(def['emergency-address-id']) : '';
533
+ const defName = def ? str(def['address-name']).toLowerCase() : '';
534
+ const boundToDefault = defName ? endpoints.find((e) => str(e['address-name']).toLowerCase() === defName) : undefined;
535
+ // NB: on an ENDPOINT record `emergency-address-id` holds the callback NUMBER, not an address id.
536
+ const defaultCallback = boundToDefault ? emergencyDigits(boundToDefault['emergency-address-id']) : '';
537
+ const endpointCallbacks = new Set(endpoints.map((e) => emergencyDigits(e['emergency-address-id'])).filter(Boolean));
538
+
539
+ const setCallbackFor = (user: Rec): string => {
540
+ const own = emergencyDigits(user['caller-id-number-emergency']);
541
+ if (own) return own;
542
+ // A user who sets none can still have a handset that does — the portal reads the device's own
543
+ // setting first and only then the user's, so a domain whose numbers live on the devices is
544
+ // invisible to a rule that reads the user record alone.
545
+ for (const d of devicesByUser[str(user.user)] ?? []) {
546
+ const dev = emergencyDigits(d['caller-id-number-emergency']);
547
+ if (dev) return dev;
548
+ }
549
+ return '';
550
+ };
551
+
552
+ return {
553
+ defaultAddressId,
554
+ defaultCallback,
555
+ endpointCallbacks,
556
+ addressIdFor: (user) => str(user['emergency-address-id']) || defaultAddressId,
557
+ setCallbackFor,
558
+ callbackFor: (user) => setCallbackFor(user) || defaultCallback,
559
+ };
560
+ }
561
+
562
+ /**
563
+ * Is this user on the LEGACY emergency model — a caller ID set by hand, with no address record behind
564
+ * it and no endpoint provisioned for the number?
565
+ *
566
+ * All three clauses matter. A blank `emergency-address-id` alone is not legacy: a user with BOTH fields
567
+ * blank inherits the domain default address, which is the new model working as designed. And a number
568
+ * that IS an endpoint callback is the new model too — counting it here as well would bill a
569
+ * half-migrated domain twice for one place.
570
+ *
571
+ * ⚠️ **The `em` must come from a snapshot whose endpoints were READ.** The third clause tests against
572
+ * `em.endpointCallbacks`, which is empty both when the domain has no endpoints and when nobody asked
573
+ * for them — so on a snapshot fetched without `includeAddresses` this answers "legacy" for every user
574
+ * on a fully-migrated domain. {@link listDomainInventory} refuses to derive the list at all in that
575
+ * state; a caller using this predicate directly has to make the same check.
576
+ */
577
+ export function legacyEmergencyNumber(user: Rec, em: EmergencyModel): string {
578
+ if (str(user['emergency-address-id'])) return '';
579
+ const n = em.setCallbackFor(user);
580
+ return n && !em.endpointCallbacks.has(n) ? n : '';
581
+ }
582
+
383
583
  /**
384
584
  * Where a phone number routes, in words a person reads at a glance — not the raw NetSapiens dial
385
585
  * rule fields. Pure; looks the destination user up in `userByExt` ({@link usersByExt}) so it can
@@ -495,11 +695,49 @@ export function listDomainInventory(snapshot: Snapshot, opts?: InventoryOptions)
495
695
  label: label || id || `(address ${i + 1})`,
496
696
  };
497
697
  });
698
+ // The two E911 lists share one resolution of the domain's inheritance — see `resolveEmergency`.
699
+ const em = resolveEmergency(snapshot);
700
+ const endpoints: Rec[] = Array.isArray(snapshot.addressEndpoints) ? snapshot.addressEndpoints : [];
701
+ const e911Endpoints: EndpointItem[] = endpoints.map((e) => {
702
+ // NB: `emergency-address-id` on an ENDPOINT record is the callback NUMBER. See `Snapshot`.
703
+ const callback = emergencyDigits(e['emergency-address-id']);
704
+ const callerName = str(e['caller-name']);
705
+ const line1 = str(e['address-line-1']);
706
+ const city = str(e['address-city']);
707
+ return {
708
+ key: identityKey('e911', callback, `${callerName} ${line1} ${city}`),
709
+ callback,
710
+ callerName,
711
+ billingAddress: [line1, city].filter(Boolean).join(', '),
712
+ // `count-users-configured` and NOT `sub_count_total`: the two disagree on live records (a
713
+ // captured endpoint had 0 and 16), and only the first one names what it counts.
714
+ users: Number(e['count-users-configured'] ?? 0) || 0,
715
+ };
716
+ });
717
+ // Legacy numbers are DERIVED — there is no record to map over. One entry per distinct number, in the
718
+ // order the users first name it, so the list does not reshuffle between two reads of one domain.
719
+ //
720
+ // ⚠️ ONLY when the endpoint list was actually READ. `snapshot.addressEndpoints` is `undefined` when
721
+ // the fetch never asked for it and `[]` when it asked and the domain has none, and the difference
722
+ // decides whether this list can exist at all: the legacy test excludes numbers that are already
723
+ // endpoint callbacks, and with no endpoint list there is nothing to exclude against — so a domain
724
+ // fully on the ENDPOINT model, read with `includeAddresses` off, would report every distinct
725
+ // emergency caller ID as a legacy line the carrier bills for. `e911Addresses` answering 0 in that
726
+ // state is a safe under-count; this answering N is a confident over-count that looks like real data.
727
+ const legacyUsers = new Map<string, number>();
728
+ if (Array.isArray(snapshot.addressEndpoints)) {
729
+ for (const u of users) {
730
+ if (isSystemUser(u)) continue;
731
+ const n = legacyEmergencyNumber(u, em);
732
+ if (n) legacyUsers.set(n, (legacyUsers.get(n) ?? 0) + 1);
733
+ }
734
+ }
735
+ const e911Legacy: LegacyE911Item[] = [...legacyUsers].map(([number, count]) => ({ key: `e911legacy:${number}`, number, users: count }));
498
736
  const smsNumbers: SmsItem[] = smsnumbers.map((s) => {
499
737
  const number = str(s.number);
500
738
  return { key: identityKey('sms', number, JSON.stringify({ number })), number };
501
739
  });
502
- return { extensions, systemUsers, dids, e911Addresses, smsNumbers };
740
+ return { extensions, systemUsers, dids, e911Addresses, e911Endpoints, e911Legacy, smsNumbers };
503
741
  }
504
742
 
505
743
  /** The counts, as a fold over {@link listDomainInventory} so the two can never disagree. */
@@ -519,6 +757,11 @@ export function countInventoryDetail(d: DomainInventoryDetail): DomainInventory
519
757
  teamsConnected: 0,
520
758
  dids: { total: 0, tollFree: 0, local: 0, fax: 0, all: d.dids.length },
521
759
  e911Addresses: d.e911Addresses.length,
760
+ // `?? []` on the two newest lists alone: a detail object cached or serialised by a consumer running
761
+ // an older version of this library has neither field, and a count that threw on it would take out a
762
+ // whole page over a dimension that did not exist when the entry was written.
763
+ e911Endpoints: (d.e911Endpoints ?? []).length,
764
+ e911Legacy: (d.e911Legacy ?? []).length,
522
765
  smsNumbers: d.smsNumbers.length,
523
766
  devices: { total: 0, byModel: {} },
524
767
  };
@@ -574,6 +817,8 @@ export function itemsFor(detail: DomainInventoryDetail, path: string): Inventory
574
817
  if (path === 'dids.fax') return detail.dids.filter((n) => n.fax);
575
818
  if (path === 'dids.all') return detail.dids;
576
819
  if (path === 'e911Addresses') return detail.e911Addresses;
820
+ if (path === 'e911Endpoints') return detail.e911Endpoints ?? [];
821
+ if (path === 'e911Legacy') return detail.e911Legacy ?? [];
577
822
  if (path === 'smsNumbers') return detail.smsNumbers;
578
823
  return undefined;
579
824
  }
@@ -586,5 +831,20 @@ export function itemLabel(item: InventoryItem): string {
586
831
  }
587
832
  if ('kind' in item) return item.kind === 'tollFree' ? `${item.number} (toll-free)` : item.number;
588
833
  if ('label' in item) return item.label;
834
+ // An ENDPOINT is named by the number the carrier bills, then by who it announces and where it sends
835
+ // responders. Either half is dropped when blank rather than printed against a dangling dash, and a
836
+ // record with NEITHER falls back to its derived key — the same shape an address with nothing to name
837
+ // it by gets, except the id here is the key rather than a position, so two blank-callback endpoints
838
+ // stay apart. Never the empty string: this label is what a consumer writes into its acceptance
839
+ // history, and a row that cannot name its own item is worse than an ugly one.
840
+ if ('callback' in item) {
841
+ const who = [item.callerName, item.billingAddress].filter(Boolean).join(', ');
842
+ if (item.callback) return who ? `${item.callback} — ${who}` : item.callback;
843
+ return who || `(endpoint ${item.key.slice('e911:'.length)})`;
844
+ }
845
+ // A LEGACY number says so on its own line: it looks like a DID, and nothing else on the page would
846
+ // tell a reader why a bare number is sitting on an E911 row. Singular is written out: a label that
847
+ // does not agree with itself reads as a rendering fault, and this one is frozen into history rows.
848
+ if ('users' in item) return `${item.number} — legacy E911 (${item.users} user${item.users === 1 ? '' : 's'})`;
589
849
  return item.number;
590
850
  }
package/src/model.ts CHANGED
@@ -96,6 +96,19 @@ export interface Snapshot {
96
96
  * `undefined` means "not read", which is not the same fact as an empty array.
97
97
  */
98
98
  addresses?: Rec[];
99
+ /**
100
+ * Emergency ENDPOINT records — GET /domains/{d}/addresses/endpoints. Read alongside `addresses`,
101
+ * and `undefined` means "not read" the same way.
102
+ *
103
+ * An endpoint is what the E911 carrier routes on and bills per: a callback number, a caller name, a
104
+ * billing address and a vendor. An ADDRESS is a dispatchable location forwarded to responders, and
105
+ * several of them can sit under one endpoint — so the two lists count different things and neither
106
+ * substitutes for the other.
107
+ *
108
+ * ⚠️ An endpoint record holds its callback NUMBER in `emergency-address-id` — the same field name an
109
+ * address record uses for its own `a-…` id. Reading it as an address id joins nothing.
110
+ */
111
+ addressEndpoints?: Rec[];
99
112
  /**
100
113
  * SMS-enabled numbers — GET /domains/{d}/smsnumbers?dest=*. The endpoint is documented with no
101
114
  * parameters, but a live server answers 400 without `dest` or `number`; `dest=*` is the wildcard
package/src/nsClient.ts CHANGED
@@ -165,8 +165,14 @@ export interface FetchSnapshotOptions {
165
165
  */
166
166
  includeDidDestRules?: boolean;
167
167
  /**
168
- * Also read the domain's E911 addresses into `snapshot.addresses`. One extra call. Default false —
169
- * the resolver does not use them; an inventory count does.
168
+ * Also read the domain's E911 addresses into `snapshot.addresses` AND its emergency ENDPOINTS into
169
+ * `snapshot.addressEndpoints`. Two extra calls. Default false the resolver uses neither; an
170
+ * inventory count uses both.
171
+ *
172
+ * ONE flag for the two because they are one subject read two ways: the endpoint is what the E911
173
+ * carrier bills per, the address is the location responders are sent to, and a caller given the
174
+ * addresses alone would count the dispatchable locations and bill for them. That was the bug this
175
+ * option grew to fix, so the two are not separable here.
170
176
  */
171
177
  includeAddresses?: boolean;
172
178
  /**
@@ -212,13 +218,16 @@ export async function fetchDomainSnapshot(client: NsClient, domain: string, opts
212
218
  };
213
219
 
214
220
  const domainRec = asArray(await client.get(base))[0] ?? { domain };
215
- const [timeframes, users, callqueues, phonenumbers, autoattendants, addresses, smsnumbers] = await Promise.all([
221
+ const [timeframes, users, callqueues, phonenumbers, autoattendants, addresses, addressEndpoints, smsnumbers] = await Promise.all([
216
222
  soft(`${base}/timeframes`),
217
223
  soft(`${base}/users`),
218
224
  soft(`${base}/callqueues`),
219
225
  soft(`${base}/phonenumbers`),
220
226
  soft(`${base}/autoattendants`),
221
227
  opts.includeAddresses ? soft(`${base}/addresses`) : Promise.resolve(undefined),
228
+ // The BILLABLE half of E911, softened like the rest: a domain still on the legacy provisioning
229
+ // model has no endpoints at all and answers 404, which is "none" rather than a failure.
230
+ opts.includeAddresses ? soft(`${base}/addresses/endpoints`) : Promise.resolve(undefined),
222
231
  // `dest=*`: see includeSmsNumbers. A 404 is already softened to []; a 400 from a server that wants
223
232
  // a different parameter throws, which is right — a silent empty list would read as "no SMS numbers".
224
233
  opts.includeSmsNumbers ? soft(`${base}/smsnumbers?dest=*`) : Promise.resolve(undefined),
@@ -240,6 +249,7 @@ export async function fetchDomainSnapshot(client: NsClient, domain: string, opts
240
249
  meta: { domain }, domain: domainRec, timeframes, users, callqueues, phonenumbers, autoattendants,
241
250
  ...(answerrulesByUser ? { answerrulesByUser } : {}),
242
251
  ...(addresses ? { addresses } : {}),
252
+ ...(addressEndpoints ? { addressEndpoints } : {}),
243
253
  ...(smsnumbers ? { smsnumbers } : {}),
244
254
  };
245
255
  }
@@ -343,6 +353,7 @@ export async function fetchDomainSnapshot(client: NsClient, domain: string, opts
343
353
  answerrulesByUser,
344
354
  agentsByQueue,
345
355
  ...(addresses ? { addresses } : {}),
356
+ ...(addressEndpoints ? { addressEndpoints } : {}),
346
357
  ...(smsnumbers ? { smsnumbers } : {}),
347
358
  ...(devicesByUser ? { devicesByUser } : {}),
348
359
  ...(deviceReadFailures ? { deviceReadFailures } : {}),