@dszp/netsapiens-lib 0.1.9 → 0.3.1

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.
Files changed (78) hide show
  1. package/README.md +95 -1
  2. package/dist/eligibility.d.ts.map +1 -0
  3. package/dist/eligibility.js.map +1 -0
  4. package/dist/html.d.ts.map +1 -0
  5. package/dist/html.js.map +1 -0
  6. package/dist/index.d.ts +1 -0
  7. package/dist/index.d.ts.map +1 -0
  8. package/dist/index.js +1 -0
  9. package/dist/index.js.map +1 -0
  10. package/dist/inventory.d.ts +153 -0
  11. package/dist/inventory.d.ts.map +1 -0
  12. package/dist/inventory.js +234 -0
  13. package/dist/inventory.js.map +1 -0
  14. package/dist/jwt.d.ts.map +1 -0
  15. package/dist/jwt.js.map +1 -0
  16. package/dist/mermaid.d.ts.map +1 -0
  17. package/dist/mermaid.js.map +1 -0
  18. package/dist/model.d.ts +18 -0
  19. package/dist/model.d.ts.map +1 -0
  20. package/dist/model.js.map +1 -0
  21. package/dist/nsAuthClient.d.ts.map +1 -0
  22. package/dist/nsAuthClient.js.map +1 -0
  23. package/dist/nsClient.d.ts +19 -0
  24. package/dist/nsClient.d.ts.map +1 -0
  25. package/dist/nsClient.js +40 -3
  26. package/dist/nsClient.js.map +1 -0
  27. package/dist/nsDevice.d.ts.map +1 -0
  28. package/dist/nsDevice.js.map +1 -0
  29. package/dist/nsSubscriptions.d.ts.map +1 -0
  30. package/dist/nsSubscriptions.js.map +1 -0
  31. package/dist/nsSynchronous.d.ts.map +1 -0
  32. package/dist/nsSynchronous.js.map +1 -0
  33. package/dist/nsWriteClient.d.ts.map +1 -0
  34. package/dist/nsWriteClient.js.map +1 -0
  35. package/dist/policy.d.ts.map +1 -0
  36. package/dist/policy.js.map +1 -0
  37. package/dist/principal.d.ts.map +1 -0
  38. package/dist/principal.js.map +1 -0
  39. package/dist/raster.d.ts.map +1 -0
  40. package/dist/raster.js.map +1 -0
  41. package/dist/resolver.d.ts.map +1 -0
  42. package/dist/resolver.js.map +1 -0
  43. package/dist/sensitivity.d.ts.map +1 -0
  44. package/dist/sensitivity.js.map +1 -0
  45. package/dist/themes.d.ts.map +1 -0
  46. package/dist/themes.js.map +1 -0
  47. package/package.json +7 -3
  48. package/src/eligibility.selftest.ts +95 -0
  49. package/src/eligibility.ts +118 -0
  50. package/src/html.ts +407 -0
  51. package/src/index.ts +120 -0
  52. package/src/inventory.selftest.ts +213 -0
  53. package/src/inventory.ts +324 -0
  54. package/src/jwt.selftest.ts +145 -0
  55. package/src/jwt.ts +491 -0
  56. package/src/mermaid.ts +169 -0
  57. package/src/model.ts +130 -0
  58. package/src/nsAuthClient.selftest.ts +60 -0
  59. package/src/nsAuthClient.ts +102 -0
  60. package/src/nsClient.selftest.ts +173 -0
  61. package/src/nsClient.ts +323 -0
  62. package/src/nsDevice.selftest.ts +190 -0
  63. package/src/nsDevice.ts +167 -0
  64. package/src/nsSubscriptions.selftest.ts +486 -0
  65. package/src/nsSubscriptions.ts +638 -0
  66. package/src/nsSynchronous.selftest.ts +63 -0
  67. package/src/nsSynchronous.ts +98 -0
  68. package/src/nsWriteClient.selftest.ts +104 -0
  69. package/src/nsWriteClient.ts +157 -0
  70. package/src/policy.ts +123 -0
  71. package/src/principal.selftest.ts +118 -0
  72. package/src/principal.ts +101 -0
  73. package/src/raster.selftest.ts +42 -0
  74. package/src/raster.ts +79 -0
  75. package/src/resolver.selftest.ts +225 -0
  76. package/src/resolver.ts +1115 -0
  77. package/src/sensitivity.ts +40 -0
  78. package/src/themes.ts +142 -0
package/src/index.ts ADDED
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Public API of the portable call-flow library — the surface any host imports (Cloudflare
3
+ * Worker, an onboarding CLI / review page / build-preview, the portal viewer). Everything
4
+ * re-exported here is Node-free and runtime-portable; any Node-only host code (e.g. a CLI) lives
5
+ * outside this surface.
6
+ *
7
+ * Typical use in another project:
8
+ * import { resolveFlow, toMermaid, renderGalleryHtml, verify } from '@dszp/netsapiens-lib';
9
+ * const graph = resolveFlow(snapshot, { kind: 'did', ref: '13175550100' });
10
+ * const html = renderGalleryHtml(snapshot.meta.domain, [graph]);
11
+ */
12
+
13
+ export type { FlowGraph, FlowNode, FlowEdge, NodeKind, EdgeKind, Snapshot, Rec } from './model.js';
14
+ export { resolveFlow, listEntities, type EntityRef } from './resolver.js';
15
+ export { toMermaid, type FlowTheme, type MermaidOptions } from './mermaid.js';
16
+ export {
17
+ THEMES,
18
+ DEFAULT_LIGHT_THEME,
19
+ DEFAULT_DARK_THEME,
20
+ NODE_LIGHT,
21
+ NODE_DARK,
22
+ NODE_SLATE,
23
+ NODE_A11Y,
24
+ type ThemeDef,
25
+ type ThemeChrome,
26
+ type ThemeMode,
27
+ type NodePalette,
28
+ } from './themes.js';
29
+ export {
30
+ renderGalleryHtml,
31
+ renderFlowCards,
32
+ renderFlowCard,
33
+ mermaidBootstrap,
34
+ flowAnchorId,
35
+ type GalleryOptions,
36
+ type CardOptions,
37
+ } from './html.js';
38
+ export { resolveSvgSize, rasterizerScript } from './raster.js';
39
+ export { NsClient, NsApiError, assertBareServer, fetchDomainSnapshot, listDomains, asArray, type NsClientConfig, type FetchSnapshotOptions } from './nsClient.js';
40
+ export { countDomainInventory, listDomainInventory, itemsFor, itemLabel, type DomainInventory, type DomainInventoryDetail, type ExtensionItem, type NumberItem, type AddressItem, type SmsItem, type InventoryItem } from './inventory.js';
41
+ export { NsWriteClient, type NsWriteClientConfig } from './nsWriteClient.js';
42
+ export {
43
+ supportsSynchronous,
44
+ SYNCHRONOUS_OPERATIONS,
45
+ type SynchronousMethod,
46
+ type SynchronousOperation,
47
+ } from './nsSynchronous.js';
48
+ export {
49
+ ensureNsDevice,
50
+ generateSipPassword,
51
+ SIP_PW_FIELD,
52
+ type NsDeviceWriter,
53
+ type EnsureNsDeviceOptions,
54
+ type EnsureNsDeviceResult,
55
+ } from './nsDevice.js';
56
+ export { NsAuthClient, NsAuthError, type NsAuthClientConfig, type NsTokenResponse } from './nsAuthClient.js';
57
+ export {
58
+ NsSubscriptionsClient,
59
+ NsSubscriptionConflictError,
60
+ SUBSCRIPTION_MODELS,
61
+ isSubscriptionModel,
62
+ nsDatetime,
63
+ parseNsDatetime,
64
+ subscriptionFromWire,
65
+ createInputToWire,
66
+ updateInputToWire,
67
+ planSubscriptions,
68
+ type SubscriptionModel,
69
+ type SubscriptionStatus,
70
+ type Subscription,
71
+ type CreateSubscriptionInput,
72
+ type UpdateSubscriptionInput,
73
+ type NsSubscriptionsClientConfig,
74
+ type DesiredSubscription,
75
+ type SubscriptionAction,
76
+ type PlanSubscriptionsOptions,
77
+ } from './nsSubscriptions.js';
78
+ export {
79
+ verify,
80
+ validateJwtFormat,
81
+ extractContext,
82
+ assertClaims,
83
+ verifyHs256Signature,
84
+ normalizeToken,
85
+ tokenKey,
86
+ MemoryVerdictCache,
87
+ type JwtVerdict,
88
+ type JwtContext,
89
+ type ClaimExpectations,
90
+ type VerdictCache,
91
+ type VerifyOptions,
92
+ type FormatResult,
93
+ } from './jwt.js';
94
+ export { type CallSensitivity, needsFreshAuth, SENSITIVITY_NOTE } from './sensitivity.js';
95
+ export {
96
+ toPrincipal,
97
+ parseOperator,
98
+ isResellerScope,
99
+ isAdminScope,
100
+ type Principal,
101
+ type Operator,
102
+ type Scope,
103
+ } from './principal.js';
104
+ export {
105
+ ruleMatches,
106
+ isAllowed,
107
+ can,
108
+ type PolicyRule,
109
+ type Policy,
110
+ type FeaturePolicies,
111
+ } from './policy.js';
112
+ export {
113
+ evaluateEligibility,
114
+ type SoftCategory,
115
+ type EligibilityConfig,
116
+ type EligUser,
117
+ type EligContext,
118
+ type EligTier,
119
+ type EligResult,
120
+ } from './eligibility.js';
@@ -0,0 +1,213 @@
1
+ /** Offline test for the domain inventory counter. pnpm test:inventory */
2
+ import { countDomainInventory, listDomainInventory, itemsFor, itemLabel } from './inventory.js';
3
+ import type { Snapshot } from './model.js';
4
+
5
+ let pass = 0, fail = 0;
6
+ const ok = (c: boolean, m: string) => { c ? pass++ : fail++; console.log(`${c ? 'PASS' : 'FAIL'} ${m}`); };
7
+
8
+ const snap: Snapshot = {
9
+ meta: { domain: 'acme.example' },
10
+ users: [
11
+ { user: '100', 'user-scope': 'Basic User', 'service-code': '', 'voicemail-transcription-enabled': 'no', 'name-first-name': 'Ann', 'name-last-name': 'Lee', site: 'North' },
12
+ { user: '101', 'user-scope': 'Basic User', 'service-code': 'premium', 'voicemail-transcription-enabled': 'yes' },
13
+ { user: '102', 'user-scope': 'Office Manager', 'service-code': 'premium', 'voicemail-transcription-enabled': 'voicebase' },
14
+ { user: '103', 'user-scope': 'Basic User', 'service-code': '' },
15
+ { user: '104', 'user-scope': 'Basic User', 'service-code': '' },
16
+ { user: '700', 'user-scope': 'Basic User', 'service-code': 'system-aa' },
17
+ { user: '701', 'user-scope': 'Basic User', 'service-code': 'system-queue' },
18
+ { user: '702', 'user-scope': 'Basic User', 'service-code': 'system-tod' },
19
+ ],
20
+ devicesByUser: {
21
+ '100': [{ aor: 'sip:100@acme.example', 'device-models-model': 'Yealink T54W' }],
22
+ '101': [
23
+ { aor: 'sip:101a@acme.example', 'device-models-model': 'Yealink T54W' },
24
+ { aor: 'sip:101b@acme.example', 'device-models-model': 'Yealink T31P' },
25
+ { aor: 'sip:101c@acme.example', 'device-models-model': '' },
26
+ ],
27
+ '102': [{ aor: 'sip:102@acme.example', 'device-models-model': 'Yealink T31P' }],
28
+ '103': [{ aor: 'sip:103t@acme.example', 'device-models-model': 'Teams' }],
29
+ },
30
+ phonenumbers: [
31
+ { phonenumber: '13175550100' }, { phonenumber: '13175550101' },
32
+ { phonenumber: '18005550102' }, { phonenumber: '18335550103' },
33
+ ],
34
+ addresses: [
35
+ { 'emergency-address-id': 'a-1', 'address-name': 'HQ', 'address-line-1': '1 Main St', 'address-city': 'Springfield' },
36
+ { 'emergency-address-id': 'a-2', 'address-name': 'Annex' },
37
+ ],
38
+ smsnumbers: [{ number: '13175550100' }],
39
+ } as Snapshot;
40
+
41
+ const inv = countDomainInventory(snap);
42
+
43
+ ok(inv.extensions.total === 5, 'five real extensions — the three system-* users are not extensions');
44
+ ok(inv.extensions.byScope['Basic User'] === 4, 'four Basic User extensions');
45
+ ok(inv.extensions.byScope['Office Manager'] === 1, 'one Office Manager extension');
46
+ ok(inv.extensions.byScope['system-aa'] === undefined, 'byScope never carries a system user');
47
+ ok(inv.extensions.byServiceCode[''] === 3, 'the empty service code is a key, not a dropped bucket');
48
+ ok(inv.extensions.byServiceCode['premium'] === 2, 'two premium-coded extensions');
49
+ ok(inv.extensions.byDeviceCount['0'] === 2, 'two extensions with no device');
50
+ ok(inv.extensions.byDeviceCount['1'] === 2, 'two extensions with one device');
51
+ ok(inv.extensions.byDeviceCount['2'] === 0, 'none with exactly two');
52
+ ok(inv.extensions.byDeviceCount['3+'] === 1, 'one extension with three devices lands in 3+');
53
+ ok(inv.systemUsers.total === 3, 'three system users');
54
+ ok(inv.systemUsers.byServiceCode['system-aa'] === 1, 'one auto attendant');
55
+ ok(inv.systemUsers.byServiceCode['system-queue'] === 1, 'one queue');
56
+ ok(inv.systemUsers.byServiceCode['system-tod'] === 1, 'one time-of-day');
57
+ ok(inv.transcriptionEnabled === 2, 'yes and a provider name both count; no and absent do not');
58
+ ok(inv.dids.total === 4, 'four phone numbers');
59
+ ok(inv.dids.tollFree === 2, '800 and 833 are toll-free');
60
+ ok(inv.dids.local === 2, 'and the rest are local');
61
+ ok(inv.e911Addresses === 2, 'two address records');
62
+ ok(inv.smsNumbers === 1, 'one SMS number');
63
+ ok(inv.devices.total === 5, 'five devices across all extensions');
64
+ ok(inv.devices.byModel['Yealink T54W'] === 2, 'two T54W');
65
+ ok(inv.devices.byModel['Yealink T31P'] === 2, 'two T31P');
66
+ ok(inv.devices.byModel['(unknown)'] === 1, 'a device with no model is counted under (unknown), never dropped');
67
+
68
+ // An empty snapshot must answer zeros, not throw — a domain can genuinely have nothing.
69
+ const empty = countDomainInventory({ meta: { domain: 'empty.example' } } as Snapshot);
70
+ ok(empty.extensions.total === 0, 'empty snapshot: no extensions');
71
+ ok(empty.dids.total === 0 && empty.dids.tollFree === 0 && empty.dids.local === 0, 'empty snapshot: no numbers');
72
+ ok(empty.devices.total === 0, 'empty snapshot: no devices');
73
+ ok(empty.smsNumbers === 0 && empty.e911Addresses === 0, 'empty snapshot: no addresses and no SMS numbers');
74
+
75
+ // Devices belonging to a system user are not counted: they are not seats and never appear on a bill.
76
+ const sysDev = countDomainInventory({
77
+ meta: { domain: 'sys.example' },
78
+ users: [{ user: '700', 'service-code': 'system-aa' }],
79
+ devicesByUser: { '700': [{ aor: 'sip:700@sys.example', 'device-models-model': 'Yealink T54W' }] },
80
+ } as Snapshot);
81
+ ok(sysDev.devices.total === 0, 'a system user device is not counted');
82
+
83
+ // ── listDomainInventory ─────────────────────────────────────────────────────────────────────────────
84
+ {
85
+ const d = listDomainInventory(snap);
86
+ ok(d.extensions.length === 5, '[list] five real extensions');
87
+ ok(d.systemUsers.length === 3, '[list] three system users, listed apart');
88
+ const e100 = d.extensions.find((x) => x.ext === '100')!;
89
+ ok(e100.key === 'ext:100', '[list] an extension key is ext:<user>');
90
+ ok(e100.name === 'Ann Lee', '[list] name is first + last');
91
+ ok(e100.site === 'North', '[list] site is carried');
92
+ ok(e100.deviceCount === 1 && e100.deviceModels[0] === 'Yealink T54W', '[list] device count and models, never the MAC');
93
+ ok(e100.teams === false, '[list] a desk phone is not Teams');
94
+ const e103 = d.extensions.find((x) => x.ext === '103')!;
95
+ ok(e103.teams === true, '[list] a device whose aor local part is <ext>t marks the extension Teams-connected');
96
+ ok(e103.deviceCount === 0 && e103.deviceModels.length === 0, '[list] and that connector is not counted as a device');
97
+ ok(d.extensions.find((x) => x.ext === '101')!.transcription === true, '[list] transcription flag');
98
+ ok(d.extensions.find((x) => x.ext === '103')!.name === '', '[list] a user with no name has an empty name, not "undefined undefined"');
99
+ ok(d.extensions.find((x) => x.ext === '103')!.anyDevice === true, '[list] an extension with only the Teams connector still has a device');
100
+ ok(d.extensions.find((x) => x.ext === '104')!.anyDevice === false, '[list] and one with nothing has none');
101
+ ok(d.dids.length === 4 && d.dids.filter((n) => n.kind === 'tollFree').length === 2, '[list] numbers with kind');
102
+ ok(d.dids[0]!.key === 'did:13175550100', '[list] a number key is did:<phonenumber>');
103
+ ok(d.e911Addresses.length === 2, '[list] two address items');
104
+ ok(d.e911Addresses[0]!.key === 'addr:a-1', '[list] an address key is addr:<emergency-address-id>');
105
+ ok(d.e911Addresses[0]!.label === 'HQ — 1 Main St, Springfield', '[list] an address label is name — line 1, city');
106
+ ok(d.e911Addresses[1]!.label === 'Annex', '[list] and degrades to whatever parts exist');
107
+ ok(d.smsNumbers.length === 1 && d.smsNumbers[0]!.key === 'sms:13175550100', '[list] an SMS key is sms:<number>');
108
+ for (const x of d.extensions) ok(!JSON.stringify(x).includes('aor') && !JSON.stringify(x).includes('sip:'), `[list] no aor leaks on ${x.ext}`);
109
+ }
110
+
111
+ // ── counts are a fold over the lists ─────────────────────────────────────────────────────────────────
112
+ {
113
+ const c = countDomainInventory(snap);
114
+ const d = listDomainInventory(snap);
115
+ ok(c.extensions.total === d.extensions.length, '[fold] extensions.total equals the list length');
116
+ ok(c.transcriptionEnabled === d.extensions.filter((x) => x.transcription).length, '[fold] transcription count equals the flagged items');
117
+ ok(c.teamsConnected === 1, '[fold] teamsConnected is a new numeric leaf');
118
+ ok(c.devices.total === 5, '[fold] the Teams connector is excluded from devices.total (still 5)');
119
+ ok(c.extensions.byDeviceCount['0'] === 2, '[fold] and from byDeviceCount — 103 and 104 have zero handsets');
120
+ ok(c.extensions.withAnyDevice === 4 && c.extensions.withNoDevice === 1, '[fold] device presence counts, connector included');
121
+ ok(c.extensions.withAnyDevice + c.extensions.withNoDevice === c.extensions.total, '[fold] the two presence leaves partition the total');
122
+ ok(c.dids.total === d.dids.length, '[fold] dids.total equals the number list length');
123
+ ok(c.dids.tollFree === d.dids.filter((n) => n.kind === 'tollFree').length, '[fold] dids.tollFree equals the toll-free items');
124
+ ok(c.e911Addresses === d.e911Addresses.length && c.smsNumbers === d.smsNumbers.length, '[fold] address and SMS counts equal the lists');
125
+ }
126
+
127
+ // ── itemsFor ──────────────────────────────────────────────────────────────────────────────────────────
128
+ {
129
+ const d = listDomainInventory(snap);
130
+ const keys = (p: string) => (itemsFor(d, p) ?? []).map((x) => x.key).join(',');
131
+ ok(keys('extensions.total') === 'ext:100,ext:101,ext:102,ext:103,ext:104', '[itemsFor] extensions.total is every extension');
132
+ ok(keys('extensions.byScope.Office Manager') === 'ext:102', '[itemsFor] byScope filters on scope');
133
+ ok(keys('extensions.byServiceCode.premium') === 'ext:101,ext:102', '[itemsFor] byServiceCode filters on service code');
134
+ ok(keys('extensions.byServiceCode.') === 'ext:100,ext:103,ext:104', '[itemsFor] the empty service code is addressable with a trailing dot');
135
+ ok(keys('extensions.byDeviceCount.3+') === 'ext:101', '[itemsFor] byDeviceCount buckets');
136
+ ok(keys('extensions.withAnyDevice') === 'ext:100,ext:101,ext:102,ext:103', '[itemsFor] withAnyDevice includes the Teams-only extension');
137
+ ok(keys('extensions.withNoDevice') === 'ext:104', '[itemsFor] withNoDevice is the rest');
138
+ ok(keys('transcriptionEnabled') === 'ext:101,ext:102', '[itemsFor] transcriptionEnabled is the flagged extensions');
139
+ ok(keys('teamsConnected') === 'ext:103', '[itemsFor] teamsConnected is the Teams extensions');
140
+ ok(keys('dids.total').split(',').length === 4, '[itemsFor] dids.total is every number');
141
+ ok(keys('dids.tollFree') === 'did:18005550102,did:18335550103', '[itemsFor] dids.tollFree is the toll-free numbers');
142
+ ok(keys('dids.local') === 'did:13175550100,did:13175550101', '[itemsFor] dids.local is the rest');
143
+ ok(keys('e911Addresses') === 'addr:a-1,addr:a-2', '[itemsFor] addresses');
144
+ ok(keys('smsNumbers') === 'sms:13175550100', '[itemsFor] SMS numbers');
145
+ ok(itemsFor(d, 'devices.total') === undefined, '[itemsFor] devices have no item list');
146
+ ok(itemsFor(d, 'devices.byModel.Yealink T54W') === undefined, '[itemsFor] not even per model');
147
+ ok(itemsFor(d, 'systemUsers.total') === undefined, '[itemsFor] system users are never compared, so no list');
148
+ ok(itemsFor(d, 'nonsense.path') === undefined, '[itemsFor] an unknown path is undefined, not []');
149
+ ok(itemLabel(d.extensions[0]!) === '100 — Ann Lee, North', '[label] extension: ext — name, site');
150
+ ok(itemLabel(d.extensions[3]!) === '103', '[label] extension with no name and no site is just the number');
151
+ ok(itemLabel(d.dids[2]!) === '18005550102 (toll-free)', '[label] number with kind');
152
+ ok(itemLabel(d.e911Addresses[0]!) === 'HQ — 1 Main St, Springfield', '[label] address is its label');
153
+ ok(itemLabel(d.smsNumbers[0]!) === '13175550100', '[label] SMS is its number');
154
+ }
155
+
156
+ // ── blank identity fields never collide onto one key ─────────────────────────────────────────────────
157
+ {
158
+ const blank = {
159
+ meta: { domain: 'blank.example' },
160
+ users: [{ 'user-scope': 'Basic User', 'service-code': '', 'name-first-name': 'No', 'name-last-name': 'Id' }],
161
+ devicesByUser: { '': [{ aor: 'sip:t@blank.example', 'device-models-model': 'Yealink T31P' }] },
162
+ phonenumbers: [{ phonenumber: '' }, { phonenumber: '' }],
163
+ addresses: [
164
+ { 'emergency-address-id': '', 'address-name': 'Suite A', 'address-line-1': '1 Main St', 'address-city': 'Springfield' },
165
+ { 'emergency-address-id': '', 'address-name': 'Suite B', 'address-line-1': '2 Main St', 'address-city': 'Springfield' },
166
+ ],
167
+ smsnumbers: [{ number: '' }, { number: '' }],
168
+ } as Snapshot;
169
+ const d = listDomainInventory(blank);
170
+ const again = listDomainInventory(blank);
171
+
172
+ const [a0, a1] = d.e911Addresses;
173
+ ok(a0!.key.startsWith('addr:~'), '[blank] a blank address id falls back to addr:~<hash>');
174
+ ok(a1!.key.startsWith('addr:~'), '[blank] and so does the second one');
175
+ ok(a0!.key !== a1!.key, '[blank] two blank-id addresses on different streets get different keys');
176
+ ok(a0!.key === again.e911Addresses[0]!.key, '[blank] the derived key is stable across two calls');
177
+ ok(a1!.key === again.e911Addresses[1]!.key, '[blank] for the second address too');
178
+ ok(a0!.label !== '', '[blank] a blank-id address still has a non-empty label');
179
+ ok(a0!.label === 'Suite A — 1 Main St, Springfield', '[blank] and it is the same name — line 1, city label');
180
+
181
+ const [n0, n1] = d.dids;
182
+ ok(n0!.key.startsWith('did:~'), '[blank] a blank number falls back to did:~<hash>');
183
+ ok(n0!.key === n1!.key, '[blank] two blank numbers share one derived key: nothing distinguishes them');
184
+ ok(n0!.key === again.dids[0]!.key, '[blank] and that key is stable across two calls');
185
+
186
+ const [s0, s1] = d.smsNumbers;
187
+ ok(s0!.key.startsWith('sms:~'), '[blank] a blank SMS number falls back to sms:~<hash>');
188
+ ok(s0!.key === s1!.key, '[blank] and two blank SMS numbers do the same');
189
+
190
+ const x = d.extensions[0]!;
191
+ ok(x.key.startsWith('ext:~'), '[blank] a blank user falls back to ext:~<hash>');
192
+ ok(x.teams === false, '[blank] and a bare `t` aor is not a Teams connector without an extension number to match');
193
+ ok(x.deviceCount === 1, '[blank] that device is counted as a handset rather than dropped');
194
+ ok(x.key === again.extensions[0]!.key, '[blank] the extension key is stable across two calls');
195
+ }
196
+
197
+ // ── a derived key does not depend on where the record sat in the array ───────────────────────────────
198
+ {
199
+ const numbers = [
200
+ { phonenumber: '' },
201
+ { phonenumber: '13175550100' },
202
+ { phonenumber: '' , 'dial-rule-application': 'to-user' },
203
+ ];
204
+ const forward = listDomainInventory({ meta: { domain: 'order.example' }, phonenumbers: numbers } as Snapshot);
205
+ const reversed = listDomainInventory({ meta: { domain: 'order.example' }, phonenumbers: [...numbers].reverse() } as Snapshot);
206
+ const derived = (d: ReturnType<typeof listDomainInventory>) =>
207
+ d.dids.map((n) => n.key).filter((k) => k.startsWith('did:~')).sort().join(',');
208
+ ok(derived(forward) !== '', '[order] the fixture really does produce derived keys');
209
+ ok(derived(forward) === derived(reversed), '[order] reversing the phonenumbers array leaves the did:~ keys unchanged');
210
+ }
211
+
212
+ console.log(`\n${pass} passed, ${fail} failed`);
213
+ if (fail) process.exit(1);
@@ -0,0 +1,324 @@
1
+ /**
2
+ * Count a domain's inventory along the dimensions a VoIP operator actually sells on.
3
+ *
4
+ * Pure: it fetches nothing. Feed it a `Snapshot` — from `fetchDomainSnapshot`, a backup, or a
5
+ * fixture — and it returns fixed, named counts and nothing else.
6
+ *
7
+ * ## Counts, and the lists behind them
8
+ *
9
+ * A device record from NetSapiens carries the SIP registration password. `listDomainInventory` builds
10
+ * per-item lists — an extension's name and site, a number's kind, an address's label — from an
11
+ * allowlist of named fields, and `countDomainInventory` is a fold over those same lists. Either way, a
12
+ * device's MAC and SIP credentials never appear: nothing here returns a raw record, and nothing should
13
+ * be added that does.
14
+ *
15
+ * ## Every countable dimension is a numeric leaf
16
+ *
17
+ * A caller comparing this against a billing system addresses a dimension by dotted path —
18
+ * `extensions.total`, `dids.tollFree`, `extensions.byServiceCode.premium`. Keeping every leaf numeric
19
+ * is what makes that possible without this module knowing anything about the billing side.
20
+ *
21
+ * ## What counts as an extension
22
+ *
23
+ * A user whose `service-code` is empty or does not begin with `system-`. NetSapiens models auto
24
+ * attendants, queues and time-of-day routers as users, and counting them as seats would overstate
25
+ * every domain that has any. They are counted separately, as information, and never compared.
26
+ */
27
+ import type { Rec, Snapshot } from './model.js';
28
+
29
+ export interface DomainInventory {
30
+ /** Real seats: users whose `service-code` is empty or non-`system-*`. */
31
+ extensions: {
32
+ total: number;
33
+ /** Keyed by the raw `user-scope` value, e.g. "Basic User". */
34
+ byScope: Record<string, number>;
35
+ /** Keyed by the raw `service-code`, the empty string included. */
36
+ byServiceCode: Record<string, number>;
37
+ /** Multi-device extensions are a real billing shape (a restaurant with four handsets on one seat). */
38
+ byDeviceCount: Record<'0' | '1' | '2' | '3+', number>;
39
+ /** Extensions with `anyDevice` true — handset or Teams connector, either counts. */
40
+ withAnyDevice: number;
41
+ /** The rest: no handset and no Teams connector. `withAnyDevice + withNoDevice === total`. */
42
+ withNoDevice: number;
43
+ };
44
+ /** `system-aa`, `system-queue`, `system-tod` and any other `system-*` code. Informational. */
45
+ systemUsers: { total: number; byServiceCode: Record<string, number> };
46
+ /** Extensions whose `voicemail-transcription-enabled` is anything but empty or `no`. */
47
+ transcriptionEnabled: number;
48
+ /**
49
+ * Extensions with a Microsoft Teams connector device — one whose SIP `aor` local part is the
50
+ * extension number followed by `t` (`1000t`), which is how the TeamMate connector registers.
51
+ * That device is NOT counted under `devices`: it is a connector, not a handset.
52
+ */
53
+ teamsConnected: number;
54
+ /** Phone numbers on the domain, split by NANP toll-free prefix. */
55
+ dids: { total: number; tollFree: number; local: number };
56
+ /** E911 address records on the domain. */
57
+ e911Addresses: number;
58
+ /** SMS-enabled numbers on the domain. */
59
+ smsNumbers: number;
60
+ /** Devices belonging to real extensions only — a system user's device is not a seat. */
61
+ devices: { total: number; byModel: Record<string, number> };
62
+ }
63
+
64
+ /**
65
+ * One extension, as a billing consumer may see it. An allowlist, not a record: name, site and the
66
+ * device MODELS are here; the MAC, the SIP credentials and the email are not, and nothing here should
67
+ * be added that carries one.
68
+ */
69
+ export interface ExtensionItem {
70
+ /**
71
+ * `ext:<user>` — the stable identity a consumer records a decision against. When `user` is blank
72
+ * the key falls back to `ext:~<hash>` of the remaining fields (see {@link listDomainInventory}),
73
+ * which keeps two differently-named nameless records apart and deliberately merges two whose
74
+ * fields are identical.
75
+ */
76
+ key: string;
77
+ ext: string;
78
+ /** `name-first-name` + `name-last-name`, trimmed; `''` when both are blank. */
79
+ name: string;
80
+ /** The user's `site`; `''` when none. */
81
+ site: string;
82
+ scope: string;
83
+ /** `service-code`, `''` included. */
84
+ serviceCode: string;
85
+ transcription: boolean;
86
+ /** See {@link DomainInventory.teamsConnected}. */
87
+ teams: boolean;
88
+ /** Handsets only — the Teams connector is excluded. */
89
+ deviceCount: number;
90
+ /** `device-models-model` per handset, `(unknown)` when blank. Never the MAC. */
91
+ deviceModels: string[];
92
+ /** `deviceCount > 0 || teams` — has a device of any kind, handset or connector. */
93
+ anyDevice: boolean;
94
+ }
95
+ export interface NumberItem { key: string /* did:<phonenumber>, or did:~<hash> when the number is blank */; number: string; kind: 'local' | 'tollFree' }
96
+ export interface AddressItem { key: string /* addr:<emergency-address-id>, or addr:~<hash> when the id is blank */; label: string }
97
+ export interface SmsItem { key: string /* sms:<number>, or sms:~<hash> when the number is blank */; number: string }
98
+ export type InventoryItem = ExtensionItem | NumberItem | AddressItem | SmsItem;
99
+
100
+ export interface DomainInventoryDetail {
101
+ /** Real seats only, same rule as the count. */
102
+ extensions: ExtensionItem[];
103
+ /** Informational, never compared. */
104
+ systemUsers: ExtensionItem[];
105
+ dids: NumberItem[];
106
+ e911Addresses: AddressItem[];
107
+ smsNumbers: SmsItem[];
108
+ }
109
+
110
+ /** NANP toll-free area codes, 800 through 888. A number outside this set is counted local. */
111
+ const TOLL_FREE = new Set(['800', '833', '844', '855', '866', '877', '888']);
112
+
113
+ const str = (v: unknown): string => (typeof v === 'string' ? v.trim() : v == null ? '' : String(v).trim());
114
+ const bump = (into: Record<string, number>, key: string): void => { into[key] = (into[key] ?? 0) + 1; };
115
+
116
+ /**
117
+ * FNV-1a over a string, as eight lowercase hex digits. Synchronous and dependency-free on purpose:
118
+ * `crypto.subtle.digest` is async, and an item key is built inside a pure, synchronous fold.
119
+ * This is an identity, not a checksum — nothing here defends against a chosen collision.
120
+ */
121
+ function hash32(s: string): string {
122
+ let h = 0x811c9dc5;
123
+ for (let i = 0; i < s.length; i++) {
124
+ h ^= s.charCodeAt(i);
125
+ h = Math.imul(h, 0x01000193);
126
+ }
127
+ return (h >>> 0).toString(16).padStart(8, '0');
128
+ }
129
+
130
+ /**
131
+ * `<kind>:<id>` when the record names itself, `<kind>:~<hash of seed>` when it does not. A blank
132
+ * identity field would otherwise hand every nameless record of that kind the same key, and a
133
+ * consumer keying decisions by it would accept one record and believe it had accepted all of them.
134
+ * The `~` is what tells a reader the key is derived rather than the system's own id.
135
+ *
136
+ * The seed is the record's OWN fields and nothing else — never its position in the array. A key that
137
+ * depended on position would change under a re-fetch that reordered the list, orphaning every
138
+ * decision a consumer had recorded against it. The price is that two blank records whose remaining
139
+ * fields are identical collapse onto ONE key, and that is the right trade: a number with no number
140
+ * is not a countable thing, and one derived row is more honest than two that shuffle.
141
+ */
142
+ function identityKey(kind: string, id: string, seed: string): string {
143
+ return id ? `${kind}:${id}` : `${kind}:~${hash32(seed)}`;
144
+ }
145
+
146
+ /** Is this user one of NetSapiens' internal routing objects rather than a seat? */
147
+ function isSystemUser(user: Rec): boolean {
148
+ return str(user['service-code']).toLowerCase().startsWith('system-');
149
+ }
150
+
151
+ /**
152
+ * Toll-free test on the digits alone. `+1 (800) 555-0102`, `18005550102` and `8005550102` all read as
153
+ * toll-free; anything that is not a 10- or 11-digit NANP number is counted local rather than guessed at.
154
+ */
155
+ function isTollFree(raw: string): boolean {
156
+ const digits = raw.replace(/\D+/g, '');
157
+ const nanp = digits.length === 11 && digits.startsWith('1') ? digits.slice(1) : digits;
158
+ return nanp.length === 10 && TOLL_FREE.has(nanp.slice(0, 3));
159
+ }
160
+
161
+ /** The local part of a device's `aor` (`sip:103t@acme.example` → `103t`), read only to test for Teams. */
162
+ function aorLocal(device: Rec): string {
163
+ const a = str(device.aor).replace(/^sip:/i, '');
164
+ const at = a.indexOf('@');
165
+ return at === -1 ? a : a.slice(0, at);
166
+ }
167
+
168
+ function extensionItem(u: Rec, devices: Rec[]): ExtensionItem {
169
+ const ext = str(u.user);
170
+ // The Teams test is `<ext>t`, so a blank ext would read every device whose aor local part is a
171
+ // bare `t` as a connector. No extension number, no Teams claim.
172
+ const handsets = ext ? devices.filter((d) => aorLocal(d) !== `${ext}t`) : devices;
173
+ const transcription = str(u['voicemail-transcription-enabled']).toLowerCase();
174
+ const teams = handsets.length !== devices.length;
175
+ const name = `${str(u['name-first-name'])} ${str(u['name-last-name'])}`.trim();
176
+ const scope = str(u['user-scope']);
177
+ const serviceCode = str(u['service-code']);
178
+ return {
179
+ key: identityKey('ext', ext, `${scope}\u0000${serviceCode}\u0000${name}`),
180
+ ext,
181
+ name,
182
+ site: str(u.site),
183
+ scope,
184
+ serviceCode,
185
+ transcription: transcription !== '' && transcription !== 'no',
186
+ teams,
187
+ deviceCount: handsets.length,
188
+ // A device whose model is blank is listed under a named bucket rather than dropped: a missing
189
+ // model is a provisioning gap worth seeing, and a silently smaller total hides it.
190
+ deviceModels: handsets.map((d) => str(d['device-models-model']) || '(unknown)'),
191
+ anyDevice: handsets.length > 0 || teams,
192
+ };
193
+ }
194
+
195
+ /**
196
+ * The items behind every count. Pure. Fields are copied by name from an allowlist; no record passes
197
+ * through, so a device's MAC or SIP password cannot reach a consumer by accident.
198
+ *
199
+ * ## Item keys, and what happens when the identity field is blank
200
+ *
201
+ * Each item's `key` is `<kind>:<the record's own id>` — `ext:1000`, `did:13175550100`,
202
+ * `addr:a-1`, `sms:13175550100`. NetSapiens will hand back a record whose id is blank, and a key of
203
+ * `addr:` shared by two records is worse than no key: a consumer recording an acceptance against it
204
+ * accepts both. So a blank id falls back to `<kind>:~<hash>`, an FNV-1a over whatever else names the
205
+ * record — an address by its name, street line and city; an extension by scope, service code and
206
+ * name; a number or SMS number by the (blank) number itself. No seed carries the array index, so a
207
+ * re-fetch that reorders the list returns the same keys. Two blank records that agree on every
208
+ * remaining field therefore land on ONE key rather than two: a number with no number is not a
209
+ * countable thing, and one derived row is more honest than two that shuffle. A blank id is a
210
+ * provisioning fault to fix; the fallback only keeps the distinguishable ones apart until it is.
211
+ */
212
+ export function listDomainInventory(snapshot: Snapshot): DomainInventoryDetail {
213
+ const users: Rec[] = Array.isArray(snapshot.users) ? snapshot.users : [];
214
+ const devicesByUser: Record<string, Rec[]> = (snapshot.devicesByUser ?? {}) as Record<string, Rec[]>;
215
+ const phonenumbers: Rec[] = Array.isArray(snapshot.phonenumbers) ? snapshot.phonenumbers : [];
216
+ const addresses: Rec[] = Array.isArray(snapshot.addresses) ? snapshot.addresses : [];
217
+ const smsnumbers: Rec[] = Array.isArray(snapshot.smsnumbers) ? snapshot.smsnumbers : [];
218
+
219
+ const extensions: ExtensionItem[] = [];
220
+ const systemUsers: ExtensionItem[] = [];
221
+ for (let i = 0; i < users.length; i++) {
222
+ const u = users[i]!;
223
+ const ext = str(u.user);
224
+ // A blank `user` is looked up too, rather than handed an empty device list. This library's own
225
+ // `fetchDomainSnapshot` never files anything under `''` — it skips a blank extension before the
226
+ // device read (see `nsClient.ts`) — so this lookup can only hit in a snapshot built elsewhere,
227
+ // from a backup or a fixture. Dropping it would read as a clean match on a domain that has
228
+ // handsets nobody can see; two blank users sharing one list overcount instead, which is a
229
+ // visible drift an operator investigates, and that is the failure worth having.
230
+ const item = extensionItem(u, devicesByUser[ext] ?? []);
231
+ (isSystemUser(u) ? systemUsers : extensions).push(item);
232
+ }
233
+ const dids: NumberItem[] = phonenumbers.map((p) => {
234
+ const number = str(p.phonenumber);
235
+ const kind: 'local' | 'tollFree' = isTollFree(number) ? 'tollFree' : 'local';
236
+ return { key: identityKey('did', number, JSON.stringify({ number, kind })), number, kind };
237
+ });
238
+ const e911Addresses: AddressItem[] = addresses.map((a, i) => {
239
+ const id = str(a['emergency-address-id']);
240
+ const name = str(a['address-name']);
241
+ const line1 = str(a['address-line-1']);
242
+ const city = str(a['address-city']);
243
+ const where = [line1, city].filter(Boolean).join(', ');
244
+ const label = [name, where].filter(Boolean).join(' — ');
245
+ return {
246
+ key: identityKey('addr', id, `${name} ${line1} ${city}`),
247
+ // An address with neither an id nor anything to name it by is still a row an operator has to
248
+ // decide about, so it gets a positional label rather than an empty cell.
249
+ label: label || id || `(address ${i + 1})`,
250
+ };
251
+ });
252
+ const smsNumbers: SmsItem[] = smsnumbers.map((s) => {
253
+ const number = str(s.number);
254
+ return { key: identityKey('sms', number, JSON.stringify({ number })), number };
255
+ });
256
+ return { extensions, systemUsers, dids, e911Addresses, smsNumbers };
257
+ }
258
+
259
+ /** The counts, as a fold over {@link listDomainInventory} so the two can never disagree. */
260
+ export function countDomainInventory(snapshot: Snapshot): DomainInventory {
261
+ const d = listDomainInventory(snapshot);
262
+ const inv: DomainInventory = {
263
+ extensions: { total: 0, byScope: {}, byServiceCode: {}, byDeviceCount: { '0': 0, '1': 0, '2': 0, '3+': 0 }, withAnyDevice: 0, withNoDevice: 0 },
264
+ systemUsers: { total: d.systemUsers.length, byServiceCode: {} },
265
+ transcriptionEnabled: 0,
266
+ teamsConnected: 0,
267
+ dids: { total: d.dids.length, tollFree: 0, local: 0 },
268
+ e911Addresses: d.e911Addresses.length,
269
+ smsNumbers: d.smsNumbers.length,
270
+ devices: { total: 0, byModel: {} },
271
+ };
272
+ for (const s of d.systemUsers) bump(inv.systemUsers.byServiceCode, s.serviceCode);
273
+ for (const x of d.extensions) {
274
+ inv.extensions.total++;
275
+ if (x.scope) bump(inv.extensions.byScope, x.scope);
276
+ bump(inv.extensions.byServiceCode, x.serviceCode);
277
+ if (x.transcription) inv.transcriptionEnabled++;
278
+ if (x.teams) inv.teamsConnected++;
279
+ if (x.anyDevice) inv.extensions.withAnyDevice++; else inv.extensions.withNoDevice++;
280
+ const bucket = x.deviceCount >= 3 ? '3+' : (String(x.deviceCount) as '0' | '1' | '2');
281
+ inv.extensions.byDeviceCount[bucket]++;
282
+ inv.devices.total += x.deviceCount;
283
+ for (const m of x.deviceModels) bump(inv.devices.byModel, m);
284
+ }
285
+ for (const n of d.dids) { if (n.kind === 'tollFree') inv.dids.tollFree++; else inv.dids.local++; }
286
+ return inv;
287
+ }
288
+
289
+ /**
290
+ * The items a `counts` path selects — the same vocabulary of dotted paths `countDomainInventory`
291
+ * answers numbers for. `undefined` means that dimension has no item list (devices, system users, or a
292
+ * path this module does not know), which is a different fact from an empty list.
293
+ */
294
+ export function itemsFor(detail: DomainInventoryDetail, path: string): InventoryItem[] | undefined {
295
+ const ex = detail.extensions;
296
+ if (path === 'extensions.total') return ex;
297
+ if (path.startsWith('extensions.byScope.')) { const v = path.slice('extensions.byScope.'.length); return ex.filter((x) => x.scope === v); }
298
+ if (path.startsWith('extensions.byServiceCode.')) { const v = path.slice('extensions.byServiceCode.'.length); return ex.filter((x) => x.serviceCode === v); }
299
+ if (path.startsWith('extensions.byDeviceCount.')) {
300
+ const v = path.slice('extensions.byDeviceCount.'.length);
301
+ return ex.filter((x) => (x.deviceCount >= 3 ? '3+' : String(x.deviceCount)) === v);
302
+ }
303
+ if (path === 'extensions.withAnyDevice') return ex.filter((x) => x.anyDevice);
304
+ if (path === 'extensions.withNoDevice') return ex.filter((x) => !x.anyDevice);
305
+ if (path === 'transcriptionEnabled') return ex.filter((x) => x.transcription);
306
+ if (path === 'teamsConnected') return ex.filter((x) => x.teams);
307
+ if (path === 'dids.total') return detail.dids;
308
+ if (path === 'dids.tollFree') return detail.dids.filter((n) => n.kind === 'tollFree');
309
+ if (path === 'dids.local') return detail.dids.filter((n) => n.kind === 'local');
310
+ if (path === 'e911Addresses') return detail.e911Addresses;
311
+ if (path === 'smsNumbers') return detail.smsNumbers;
312
+ return undefined;
313
+ }
314
+
315
+ /** One line naming an item to a person: what an operator sees when they accept it, and what history keeps. */
316
+ export function itemLabel(item: InventoryItem): string {
317
+ if ('ext' in item) {
318
+ const who = [item.name, item.site].filter(Boolean).join(', ');
319
+ return who ? `${item.ext} — ${who}` : item.ext;
320
+ }
321
+ if ('kind' in item) return item.kind === 'tollFree' ? `${item.number} (toll-free)` : item.number;
322
+ if ('label' in item) return item.label;
323
+ return item.number;
324
+ }