@dszp/netsapiens-lib 0.1.8 → 0.3.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.
Files changed (80) 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 +151 -0
  11. package/dist/inventory.d.ts.map +1 -0
  12. package/dist/inventory.js +0 -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 +8 -2
  36. package/dist/policy.d.ts.map +1 -0
  37. package/dist/policy.js +3 -1
  38. package/dist/policy.js.map +1 -0
  39. package/dist/principal.d.ts.map +1 -0
  40. package/dist/principal.js.map +1 -0
  41. package/dist/raster.d.ts.map +1 -0
  42. package/dist/raster.js.map +1 -0
  43. package/dist/resolver.d.ts.map +1 -0
  44. package/dist/resolver.js.map +1 -0
  45. package/dist/sensitivity.d.ts.map +1 -0
  46. package/dist/sensitivity.js.map +1 -0
  47. package/dist/themes.d.ts.map +1 -0
  48. package/dist/themes.js.map +1 -0
  49. package/package.json +7 -3
  50. package/src/eligibility.selftest.ts +95 -0
  51. package/src/eligibility.ts +118 -0
  52. package/src/html.ts +407 -0
  53. package/src/index.ts +120 -0
  54. package/src/inventory.selftest.ts +198 -0
  55. package/src/inventory.ts +314 -0
  56. package/src/jwt.selftest.ts +145 -0
  57. package/src/jwt.ts +491 -0
  58. package/src/mermaid.ts +169 -0
  59. package/src/model.ts +130 -0
  60. package/src/nsAuthClient.selftest.ts +60 -0
  61. package/src/nsAuthClient.ts +102 -0
  62. package/src/nsClient.selftest.ts +173 -0
  63. package/src/nsClient.ts +323 -0
  64. package/src/nsDevice.selftest.ts +190 -0
  65. package/src/nsDevice.ts +167 -0
  66. package/src/nsSubscriptions.selftest.ts +486 -0
  67. package/src/nsSubscriptions.ts +638 -0
  68. package/src/nsSynchronous.selftest.ts +63 -0
  69. package/src/nsSynchronous.ts +98 -0
  70. package/src/nsWriteClient.selftest.ts +104 -0
  71. package/src/nsWriteClient.ts +157 -0
  72. package/src/policy.ts +123 -0
  73. package/src/principal.selftest.ts +118 -0
  74. package/src/principal.ts +101 -0
  75. package/src/raster.selftest.ts +42 -0
  76. package/src/raster.ts +79 -0
  77. package/src/resolver.selftest.ts +225 -0
  78. package/src/resolver.ts +1115 -0
  79. package/src/sensitivity.ts +40 -0
  80. package/src/themes.ts +142 -0
package/src/mermaid.ts ADDED
@@ -0,0 +1,169 @@
1
+ /**
2
+ * FlowGraph -> Mermaid flowchart. Node shape + CSS class encode the node kind so a
3
+ * reader can tell a queue from a mailbox from an external forward at a glance.
4
+ */
5
+
6
+ import type { EdgeKind, FlowGraph, FlowNode, NodeKind } from './model.js';
7
+ import { NODE_LIGHT, NODE_DARK } from './themes.js';
8
+
9
+ /** Wrap a label in the shape delimiters for its kind. */
10
+ function shape(n: FlowNode): string {
11
+ const text = quote(n);
12
+ switch (n.kind) {
13
+ case 'did':
14
+ return `(${text})`; // stadium
15
+ case 'timeframe':
16
+ return `{${text}}`; // diamond decision
17
+ case 'attendant':
18
+ return `{{${text}}}`; // hexagon
19
+ case 'queue':
20
+ return `[[${text}]]`; // subroutine
21
+ case 'voicemail':
22
+ return `[(${text})]`; // cylinder
23
+ case 'external':
24
+ case 'trunk':
25
+ case 'prompt':
26
+ return `[/${text}/]`; // parallelogram
27
+ case 'hangup':
28
+ return `((${text}))`; // circle
29
+ case 'devices':
30
+ case 'agents':
31
+ default:
32
+ return `[${text}]`; // rectangle
33
+ }
34
+ }
35
+
36
+ // Mermaid measures label width in a font that under-measures emoji, so it sizes the node box too
37
+ // narrow and the emoji-bearing line clips on the right (rectangles lack the stadium's built-in slack).
38
+ // Appending two non-breaking spaces to each line makes the MEASURED width include real slack, so the
39
+ // box is drawn wide enough for the rendered emoji. ` ` isn't collapsed and survives esc().
40
+ const PAD = '\u00a0\u00a0';
41
+
42
+ function quote(n: FlowNode): string {
43
+ const main = `${esc(n.label)}${PAD}`;
44
+ let extra = n.sub ? `<br/><small>${esc(n.sub)}${PAD}</small>` : '';
45
+ // A "header" line (a short label ending in ':' with no interior colon, e.g. "Tier 1:") renders
46
+ // bold at normal size so it reads as a heading above the small list rows beneath it.
47
+ if (n.lines?.length)
48
+ extra += n.lines
49
+ .map((l) => (/^\S[^:]*:$/.test(l.trim()) ? `<br/><b>${esc(l.trim())}${PAD}</b>` : `<br/><small>${esc(l)}${PAD}</small>`))
50
+ .join('');
51
+ return `"${main}${extra}"`;
52
+ }
53
+
54
+ /**
55
+ * Escape a dynamic (customer-sourced) label so it can't break Mermaid syntax or inject markup.
56
+ * Angle brackets, quotes and `#` become entities; newlines collapse to spaces (a raw `\n` would
57
+ * break the flowchart line). The renderer additionally runs `securityLevel: 'strict'` (DOMPurify +
58
+ * no click callbacks) as defense-in-depth — so injected tags are neutralized at both layers.
59
+ * Our own `<br/>`/`<small>` are added around the *escaped* text in quote(), never from input.
60
+ */
61
+ function esc(t: string): string {
62
+ return String(t)
63
+ .replace(/[\r\n]+/g, ' ')
64
+ .replace(/"/g, '&quot;')
65
+ .replace(/#/g, '&num;')
66
+ .replace(/</g, '&lt;')
67
+ .replace(/>/g, '&gt;');
68
+ }
69
+
70
+ const EDGE_STYLE: Partial<Record<EdgeKind, string>> = {
71
+ ref: '-.->',
72
+ };
73
+
74
+ function edgeArrow(kind: EdgeKind): string {
75
+ return EDGE_STYLE[kind] ?? '-->';
76
+ }
77
+
78
+ /** Diagram theme. `dark` is the original palette (Cloudflare Worker / live-chart use);
79
+ * `light` matches a light review report. */
80
+ export type FlowTheme = 'light' | 'dark';
81
+ export interface MermaidOptions {
82
+ /** Emit a themed diagram. OMIT for byte-identical legacy output (the Worker relies on this). */
83
+ theme?: FlowTheme;
84
+ }
85
+
86
+ // Per-kind node styling comes from the shared theme registry (themes.ts) so palettes live in one
87
+ // place. `dark` = saturated fills + white text (Worker legacy path); `light` = tints + dark text.
88
+ // (The registry also carries the richer viewer themes — slate / a11y / portal — over the same kinds.)
89
+
90
+ /** Mermaid YAML frontmatter carrying the Tier-0 polish (look: neo) + theme. Static config
91
+ * only — no customer data reaches it, so the synthetic-ID invariant is untouched. */
92
+ function frontmatter(theme: FlowTheme): string[] {
93
+ const base = theme === 'light' ? 'base' : 'dark';
94
+ const vars =
95
+ theme === 'light'
96
+ ? "{fontFamily: 'system-ui, sans-serif', fontSize: '14px', lineColor: '#64748b', primaryTextColor: '#1e293b', primaryBorderColor: '#cbd5e1'}"
97
+ : "{fontFamily: 'system-ui, sans-serif', fontSize: '14px', lineColor: '#8a94a6'}";
98
+ return ['---', `config: {look: neo, theme: ${base}, themeVariables: ${vars}}`, '---'];
99
+ }
100
+
101
+ export function toMermaid(g: FlowGraph, opts?: MermaidOptions): string {
102
+ const theme = opts?.theme;
103
+ const palette = theme === 'light' ? NODE_LIGHT : NODE_DARK;
104
+ // No theme → legacy output (no frontmatter, dark palette): byte-identical to pre-theming.
105
+ const lines: string[] = theme ? [...frontmatter(theme), 'flowchart TD'] : ['flowchart TD'];
106
+
107
+ // Mermaid node IDs must be a safe identifier — but FlowGraph ids are built from raw NS data
108
+ // (hosts, dialrule params, applications) that can contain '.', ':', '@', '*', spaces, '<>'.
109
+ // Map every id to a synthetic `n<i>` so no data ever lands in an id position; all human content
110
+ // lives in the (quoted) labels. Prevents mermaid.render() from throwing on real-world domains.
111
+ const mid = new Map<string, string>();
112
+ g.nodes.forEach((n, i) => mid.set(n.id, `n${i}`));
113
+ const safe = (id: string): string => mid.get(id) ?? `n_${id.replace(/[^A-Za-z0-9_]/g, '_')}`;
114
+
115
+ for (const n of g.nodes) lines.push(` ${safe(n.id)}${shape(n)}`);
116
+ lines.push('');
117
+
118
+ // Merge edges that share the same source, destination and arrow into ONE connector, stacking their
119
+ // distinct labels (e.g. an AA whose "press 0", "no key / timeout" and "unknown input" all go to the
120
+ // same voicemail → a single edge labelled with all three on separate lines).
121
+ const groups = new Map<string, { from: string; to: string; arrow: string; labels: string[]; time: boolean }>();
122
+ for (const e of g.edges) {
123
+ const arrow = edgeArrow(e.kind);
124
+ const key = `${e.from}\u0000${e.to}\u0000${arrow}`;
125
+ let grp = groups.get(key);
126
+ if (!grp) {
127
+ grp = { from: e.from, to: e.to, arrow, labels: [], time: false };
128
+ groups.set(key, grp);
129
+ }
130
+ if (e.kind === 'time') grp.time = true;
131
+ if (e.label && !grp.labels.includes(e.label)) grp.labels.push(e.label);
132
+ }
133
+ for (const grp of groups.values()) {
134
+ // Multiple options on one connector → bullet each (left-aligned via host CSS) so it reads as a
135
+ // list. A lone label is used as-is — it may carry its own `\n` line breaks (e.g. a timeframe:
136
+ // header line + bulleted schedule segments). Split on `\n` BEFORE esc (esc collapses newlines).
137
+ // Bullet only single-line labels when merging; a label that's already a multi-line "header +
138
+ // bulleted segments" (a timeframe schedule) keeps its own formatting so its header isn't bulleted.
139
+ // sibling OPTIONS get a bullet; TIMEFRAME branches (time_open, otherwise) stay header-level.
140
+ let rawLines: string[];
141
+ if (grp.labels.length > 1) rawLines = grp.time ? grp.labels : grp.labels.map((l) => (l.includes('\n') ? l : `• ${l}`));
142
+ else rawLines = grp.labels[0] ? [grp.labels[0]] : [];
143
+ // NO nbsp PAD on edge labels (unlike node labels — see quote()). The old trailing-PAD kludge tried
144
+ // to buy measurement slack so labels like "press *" / "press 2" wouldn't clip, but Mermaid's font
145
+ // measurement still drifts ~10px under `look: neo`, so the right edge clipped anyway (the recurring
146
+ // "numbers get cut off"). The durable fix is host CSS: edge-label foreignObjects are `overflow:
147
+ // visible` (so nothing clips regardless of drift) and `span.edgeLabel` carries symmetric padding —
148
+ // trailing PAD would only bias that padding rightward and break the even before/after spacing.
149
+ const body = rawLines
150
+ .flatMap((l) => l.split('\n'))
151
+ .map((l) => esc(l))
152
+ .join('<br/>');
153
+ const lbl = body ? `|"${body}"|` : '';
154
+ lines.push(` ${safe(grp.from)} ${grp.arrow}${lbl} ${safe(grp.to)}`);
155
+ }
156
+ lines.push('');
157
+
158
+ // class assignments grouped by kind
159
+ const byKind = new Map<NodeKind, string[]>();
160
+ for (const n of g.nodes) {
161
+ if (!byKind.has(n.kind)) byKind.set(n.kind, []);
162
+ byKind.get(n.kind)!.push(safe(n.id));
163
+ }
164
+ for (const [kind, ids] of byKind) {
165
+ lines.push(` classDef ${kind} ${palette[kind]}`);
166
+ lines.push(` class ${ids.join(',')} ${kind}`);
167
+ }
168
+ return lines.join('\n');
169
+ }
package/src/model.ts ADDED
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Normalized call-flow graph — renderer-agnostic. The resolver emits this from a
3
+ * NetSapiens domain snapshot; the Mermaid emitter (and, later, any other renderer)
4
+ * consumes it. This is the "real IP" contract from the handoff: a normalized graph JSON.
5
+ *
6
+ * Runtime-portable by design: no Node-only imports here or in resolver.ts, so the same
7
+ * code can run in an onboarding CLI and in a Cloudflare Worker.
8
+ */
9
+
10
+ export type NodeKind =
11
+ | 'did' // inbound phone number
12
+ | 'timeframe' // time-of-day / schedule decision
13
+ | 'user' // a subscriber extension
14
+ | 'devices' // "ring the user's registered devices"
15
+ | 'queue' // ACD call queue
16
+ | 'agents' // the agent roster of a queue
17
+ | 'attendant' // auto attendant
18
+ | 'prompt' // a played greeting / announcement
19
+ | 'voicemail' // a mailbox (terminal)
20
+ | 'external' // off-net / PSTN forward (terminal)
21
+ | 'trunk' // SIP connection / trunk (e.g. fax server) (terminal)
22
+ | 'hangup' // dead end / no route
23
+ | 'unknown';
24
+
25
+ export type EdgeKind =
26
+ | 'route' // plain "goes to"
27
+ | 'time' // a time-of-day branch
28
+ | 'always' // forward-always (unconditional)
29
+ | 'noanswer' // ring-no-answer timeout
30
+ | 'busy' // forward-on-busy
31
+ | 'unreg' // forward-when-unregistered
32
+ | 'dnd' // do-not-disturb path
33
+ | 'dispatch' // queue -> agents
34
+ | 'overflow' // queue overflow
35
+ | 'menu' // auto-attendant keypress
36
+ | 'ref'; // link back to an already-drawn node (breaks a cycle)
37
+
38
+ export interface FlowNode {
39
+ id: string;
40
+ kind: NodeKind;
41
+ /** Primary label line. */
42
+ label: string;
43
+ /** Optional secondary line (e.g. dispatch type, schedule). */
44
+ sub?: string;
45
+ /** Optional additional lines rendered one-per-line under the label (e.g. a bulleted agent list). */
46
+ lines?: string[];
47
+ /** Full text for a hover tooltip (e.g. a long greeting shown truncated in the label). Viewer-only. */
48
+ title?: string;
49
+ }
50
+
51
+ export interface FlowEdge {
52
+ from: string;
53
+ to: string;
54
+ kind: EdgeKind;
55
+ /** Edge caption (e.g. "open hrs", "no answer 30s"). */
56
+ label?: string;
57
+ }
58
+
59
+ export interface FlowGraph {
60
+ /** Entity the flow was resolved for. */
61
+ entity: { kind: string; ref: string; label: string };
62
+ domain: string;
63
+ rootId: string;
64
+ nodes: FlowNode[];
65
+ edges: FlowEdge[];
66
+ /** Human-facing caveats surfaced during resolution (gaps, unmapped params, cycles). */
67
+ notes: string[];
68
+ }
69
+
70
+ // ---------------------------------------------------------------------------
71
+ // Loose snapshot typings. The snapshot is raw NetSapiens JSON with hyphenated
72
+ // keys; we type only what the resolver reads and keep everything index-signature
73
+ // loose so drift in unrelated fields never breaks the build.
74
+ // ---------------------------------------------------------------------------
75
+
76
+ export type Rec = Record<string, any>;
77
+
78
+ export interface Snapshot {
79
+ meta: Rec;
80
+ domain?: Rec;
81
+ timeframes?: Rec[];
82
+ users?: Rec[];
83
+ devicesByUser?: Record<string, Rec[]>;
84
+ /**
85
+ * Extensions whose `/devices` read failed with something other than 404 — present only when
86
+ * `includeDevices` was asked for. The extension is still in `users`, with no entry in
87
+ * `devicesByUser`, so a consumer counting devices sees zero for it and must read this list to
88
+ * know that zero is not a fact.
89
+ */
90
+ deviceReadFailures?: string[];
91
+ callqueues?: Rec[];
92
+ agentsByQueue?: Record<string, Rec[]>;
93
+ phonenumbers?: Rec[];
94
+ /**
95
+ * E911 address records — GET /domains/{d}/addresses. Present only when the fetch asked for them;
96
+ * `undefined` means "not read", which is not the same fact as an empty array.
97
+ */
98
+ addresses?: Rec[];
99
+ /**
100
+ * SMS-enabled numbers — GET /domains/{d}/smsnumbers?dest=*. The endpoint is documented with no
101
+ * parameters, but a live server answers 400 without `dest` or `number`; `dest=*` is the wildcard
102
+ * that returns the list. Present only when the fetch asked for them.
103
+ */
104
+ smsnumbers?: Rec[];
105
+ autoattendants?: Rec[];
106
+ dialrulesByPlan?: Record<string, Rec[]>;
107
+ answerrulesByUser?: Record<string, Rec[]>;
108
+ /**
109
+ * Optional per-attendant menu detail, keyed by AA extension — the response of
110
+ * GET /domains/{d}/users/{ext}/autoattendants/{prompt} (an `auto-attendant` tier +
111
+ * top-level greeting `audio` + `intro-greetings[]` + `time-frame`). When present, the
112
+ * resolver renders the real keypress menu; when absent, it emits a "not captured" note.
113
+ *
114
+ * Two shapes are accepted:
115
+ * - `attendantDetails[ext]` — a single detail (current live fetch; SV builds AAs on `*`).
116
+ * - `attendantDetailsByUser[ext]` — an ARRAY of details (an enriched backup: an AA may
117
+ * have multiple prompts/timeframes). The resolver picks the `*`/Default one as primary and
118
+ * flags the rest as a deviation (see the AA backup enrichment spec, Addendum 2026-07-11).
119
+ */
120
+ attendantDetails?: Record<string, Rec>;
121
+ attendantDetailsByUser?: Record<string, Rec[]>;
122
+ /**
123
+ * Per-AA dialplan dialrules, keyed by AA extension — the AUTHORITATIVE menu + default routing that
124
+ * the /autoattendants detail omits (no-key/star/option). From GET /domains/{d}/dialplans/{domain}_{ext}/dialrules.
125
+ * The resolver reads `Prompt_<startingPrompt-id>.<suffix>` rules: .Default (no-key/timeout), .* (unassigned),
126
+ * .<digit> (press N), .Case_[...] (dial-by-ext). See ARCHITECTURE.md → NetSapiens routing model.
127
+ */
128
+ attendantDialrulesByExt?: Record<string, Rec[]>;
129
+ [k: string]: any;
130
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Offline test for NsAuthClient — OAuth2 password-grant against a recording mock fetch (no live
3
+ * creds). Asserts the token endpoint URL + form body, the token body passthrough, NsAuthError on a
4
+ * non-2xx, and the verifyCredentials fail-closed contract (4xx -> ok:false, 5xx -> rethrow).
5
+ * tsx src/nsAuthClient.selftest.ts
6
+ */
7
+ import { NsAuthClient, NsAuthError } from './index.js';
8
+
9
+ let pass = 0, fail = 0;
10
+ const ok = (c: boolean, m: string) => { c ? pass++ : fail++; console.log(`${c ? '✓' : '✗ FAIL'} ${m}`); };
11
+
12
+ interface Recorded { url: string; body: string }
13
+ let last: Recorded = { url: '', body: '' };
14
+ const mk = (status: number, body: unknown) =>
15
+ (async (input: any, init: any = {}) => {
16
+ last = { url: String(input), body: String(init.body ?? '') };
17
+ return new Response(typeof body === 'string' ? body : JSON.stringify(body), {
18
+ status,
19
+ headers: { 'content-type': 'application/json' },
20
+ });
21
+ }) as unknown as typeof fetch;
22
+
23
+ const cfg = (fetchImpl: typeof fetch) => ({ server: 'api.example.com', clientId: 'cid', clientSecret: 'csec', fetchImpl });
24
+
25
+ (async () => {
26
+ // passwordGrant posts a form to the token endpoint and returns the token body.
27
+ const okBody = { access_token: 'tok', user: '100', domain: 'demo.12345.service' };
28
+ const res = await new NsAuthClient(cfg(mk(200, okBody))).passwordGrant('100@demo', 'pw');
29
+ ok(last.url === 'https://api.example.com/ns-api/oauth2/token/', 'passwordGrant posts to the token endpoint');
30
+ ok(last.body.includes('grant_type=password'), 'passwordGrant body carries grant_type=password');
31
+ ok(last.body.includes('client_id=cid'), 'passwordGrant body carries client_id');
32
+ ok(last.body.includes('username=100%40demo'), 'passwordGrant body URI-encodes the username');
33
+ ok(res.access_token === 'tok' && res.user === '100', 'passwordGrant returns the token body incl. user');
34
+
35
+ // passwordGrant throws NsAuthError on a 400 (bad credentials).
36
+ let err400: any;
37
+ try { await new NsAuthClient(cfg(mk(400, { error: 'invalid_grant' }))).passwordGrant('x', 'y'); } catch (e) { err400 = e; }
38
+ ok(err400 instanceof NsAuthError, 'passwordGrant throws NsAuthError on a 400');
39
+
40
+ // verifyCredentials returns ok:true with the token on 200.
41
+ const verifiedOk = await new NsAuthClient(cfg(mk(200, { access_token: 'tok', user: '100' }))).verifyCredentials('100@demo', 'pw');
42
+ ok(verifiedOk.ok === true && verifiedOk.token?.access_token === 'tok', 'verifyCredentials returns ok:true with the token on 200');
43
+
44
+ // verifyCredentials returns ok:false on a 401 (invalid creds).
45
+ const verified401 = await new NsAuthClient(cfg(mk(401, { error: 'invalid_grant' }))).verifyCredentials('x', 'y');
46
+ ok(verified401.ok === false && verified401.token === undefined, 'verifyCredentials returns ok:false on a 401');
47
+
48
+ // verifyCredentials returns ok:false on a 200 whose body lacks access_token (fail-open regression:
49
+ // NS can return HTTP 200 with an empty/in-band-error body carrying no token, which is NOT a login).
50
+ const verifiedTokenless = await new NsAuthClient(cfg(mk(200, { user: '100', domain: 'demo.12345.service' }))).verifyCredentials('x', 'y');
51
+ ok(verifiedTokenless.ok === false, 'verifyCredentials returns ok:false on a 200 with no access_token');
52
+
53
+ // verifyCredentials RETHROWS on a 5xx (caller fails closed).
54
+ let err503: any;
55
+ try { await new NsAuthClient(cfg(mk(503, 'upstream down'))).verifyCredentials('x', 'y'); } catch (e) { err503 = e; }
56
+ ok(err503 instanceof NsAuthError, 'verifyCredentials rethrows NsAuthError on a 503');
57
+
58
+ console.log(`\n${pass} passed, ${fail} failed`);
59
+ process.exit(fail ? 1 : 0);
60
+ })();
@@ -0,0 +1,102 @@
1
+ /**
2
+ * NsAuthClient — the NetSapiens OAuth2 password-grant surface. Two jobs off one call:
3
+ * - verifyCredentials(user, pass): confirm an END USER's credentials (the SSO webhook's auth check).
4
+ * - passwordGrant(adminUser, adminPass): mint a reseller/admin access token to use as a write bearer,
5
+ * an alternative to a static API key.
6
+ * Both use the deployment's "master key" (an OAuth application's client_id/client_secret). Node-free
7
+ * (fetch/URLSearchParams). The token endpoint is form-encoded and returns JSON.
8
+ *
9
+ * Fail-closed contract: passwordGrant throws NsAuthError on ANY non-2xx. verifyCredentials treats a 4xx as
10
+ * "bad credentials" ({ ok:false }) but RETHROWS a 5xx / network error, so a caller cannot mistake an
11
+ * upstream outage for a failed login.
12
+ */
13
+
14
+ import { assertBareServer } from './nsClient.js';
15
+
16
+ export class NsAuthError extends Error {
17
+ constructor(message: string, readonly status: number) {
18
+ super(message);
19
+ this.name = 'NsAuthError';
20
+ }
21
+ }
22
+
23
+ export interface NsTokenResponse {
24
+ access_token?: string;
25
+ /** The authenticated user's extension (NetSapiens returns this on the token body). */
26
+ user?: string;
27
+ domain?: string;
28
+ scope?: string;
29
+ [k: string]: unknown;
30
+ }
31
+
32
+ export interface NsAuthClientConfig {
33
+ /** API host, e.g. "api.example.com" (bare — no scheme/path). Token endpoint = https://{server}/ns-api/oauth2/token/ */
34
+ server: string;
35
+ /** OAuth application client id (the "master key" id). */
36
+ clientId: string;
37
+ /** OAuth application client secret. */
38
+ clientSecret: string;
39
+ /** Injectable for tests / non-global fetch. */
40
+ fetchImpl?: typeof fetch;
41
+ }
42
+
43
+ export class NsAuthClient {
44
+ readonly #url: string;
45
+ readonly #clientId: string;
46
+ readonly #clientSecret: string;
47
+ readonly #fetchImpl: typeof fetch;
48
+
49
+ constructor(cfg: NsAuthClientConfig) {
50
+ this.#url = `https://${assertBareServer(cfg.server)}/ns-api/oauth2/token/`;
51
+ this.#clientId = cfg.clientId;
52
+ this.#clientSecret = cfg.clientSecret;
53
+ this.#fetchImpl = cfg.fetchImpl ?? fetch;
54
+ }
55
+
56
+ async passwordGrant(username: string, password: string): Promise<NsTokenResponse> {
57
+ const body = new URLSearchParams({
58
+ grant_type: 'password',
59
+ client_id: this.#clientId,
60
+ client_secret: this.#clientSecret,
61
+ username,
62
+ password,
63
+ format: 'json',
64
+ });
65
+ // Call via a local, NOT this.#fetchImpl(...): the global fetch requires a global `this` in workerd.
66
+ const doFetch = this.#fetchImpl;
67
+ const res = await doFetch(this.#url, {
68
+ method: 'POST',
69
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
70
+ body: body.toString(),
71
+ });
72
+ const text = await res.text();
73
+ let parsed: unknown = text;
74
+ if (text) {
75
+ try { parsed = JSON.parse(text); } catch { /* non-JSON error body */ }
76
+ }
77
+ if (!res.ok) {
78
+ const detail = (typeof parsed === 'object' && parsed !== null ? JSON.stringify(parsed) : String(parsed)).slice(0, 300);
79
+ throw new NsAuthError(`NS oauth2/token → ${res.status}: ${detail}`, res.status);
80
+ }
81
+ return (parsed && typeof parsed === 'object' ? parsed : {}) as NsTokenResponse;
82
+ }
83
+
84
+ /**
85
+ * Confirm an end user's credentials via OAuth2 password-grant.
86
+ *
87
+ * Contract: `ok` is true IF AND ONLY IF the token response carried a non-empty `access_token`.
88
+ * NetSapiens can return HTTP 200 with an empty/in-band-error body (no `access_token`) — that is
89
+ * NOT a successful login, so a bare 2xx is not sufficient. A 4xx maps to `{ ok: false }`; a 5xx /
90
+ * network error rethrows so a caller cannot mistake an upstream outage for a failed login.
91
+ */
92
+ async verifyCredentials(username: string, password: string): Promise<{ ok: boolean; token?: NsTokenResponse }> {
93
+ try {
94
+ const token = await this.passwordGrant(username, password);
95
+ if (!token.access_token) return { ok: false };
96
+ return { ok: true, token };
97
+ } catch (e) {
98
+ if (e instanceof NsAuthError && e.status >= 400 && e.status < 500) return { ok: false };
99
+ throw e; // 5xx / network → fail closed upstream
100
+ }
101
+ }
102
+ }
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Offline end-to-end test for the portable NS client (no live creds):
3
+ * tsx src/nsClient.selftest.ts <snapshot.json> [attendantsDir]
4
+ *
5
+ * Serves the NS v2 read endpoints from a real fixture snapshot via a mock fetch, then asserts
6
+ * that fetchDomainSnapshot() reconstructs a Snapshot which resolveFlow() turns into the SAME
7
+ * FlowGraph as resolving the raw fixture directly. Proves the client's endpoint map + assembly.
8
+ */
9
+ import { readFileSync, readdirSync } from 'node:fs';
10
+ import { join, resolve } from 'node:path';
11
+ import { NsApiError, NsClient, fetchDomainSnapshot } from './nsClient.js';
12
+ import { resolveFlow, listEntities } from './resolver.js';
13
+ import type { Snapshot } from './model.js';
14
+
15
+ // The fixture-diff test below (fetchDomainSnapshot vs a raw fixture, resolved both ways) needs a
16
+ // snapshot file and is skipped without one. The fake-client test further down needs nothing but
17
+ // the client, so it always runs — `pnpm exec tsx src/nsClient.selftest.ts` with no args exercises it.
18
+ const snapPath = process.argv[2];
19
+ const raw = snapPath ? (JSON.parse(readFileSync(snapPath, 'utf8')) as Snapshot) : undefined;
20
+ const domain = String(raw?.meta?.domain ?? raw?.domain?.domain ?? '');
21
+
22
+ // Optional AA menu sidecars keyed by ext.
23
+ const attendantsDir = snapPath ? (process.argv[3] ?? join(resolve(snapPath, '..'), 'attendants')) : undefined;
24
+ const aaByExt: Record<string, unknown> = {};
25
+ try {
26
+ if (attendantsDir) {
27
+ for (const f of readdirSync(attendantsDir).filter((f) => f.endsWith('.json'))) {
28
+ const d = JSON.parse(readFileSync(join(attendantsDir, f), 'utf8'));
29
+ aaByExt[String(d.user ?? f.replace(/\.json$/, ''))] = d;
30
+ }
31
+ }
32
+ } catch {
33
+ /* no sidecars */
34
+ }
35
+
36
+ // Mock fetch: route NS v2 read paths to fixture data.
37
+ const j = (body: unknown) => new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' } });
38
+ const notFound = () => new Response('[]', { status: 404 });
39
+ const mockFetch = (async (input: string) => {
40
+ const path = new URL(String(input)).pathname.replace(/^\/ns-api\/v2/, '');
41
+ const b = `/domains/${domain}`;
42
+ if (path === b) return j(raw?.domain ?? { domain });
43
+ if (path === `${b}/timeframes`) return j(raw?.timeframes ?? []);
44
+ if (path === `${b}/users`) return j(raw?.users ?? []);
45
+ if (path === `${b}/callqueues`) return j(raw?.callqueues ?? []);
46
+ if (path === `${b}/phonenumbers`) return j(raw?.phonenumbers ?? []);
47
+ if (path === `${b}/autoattendants`) return j(raw?.autoattendants ?? []);
48
+ let m = path.match(new RegExp(`^${b}/users/([^/]+)/answerrules$`));
49
+ if (m) return j(raw?.answerrulesByUser?.[decodeURIComponent(m[1]!)] ?? []);
50
+ m = path.match(new RegExp(`^${b}/callqueues/([^/]+)/agents$`));
51
+ if (m) return j(raw?.agentsByQueue?.[decodeURIComponent(m[1]!)] ?? []);
52
+ m = path.match(new RegExp(`^${b}/users/([^/]+)/autoattendants/([^/]+)$`));
53
+ if (m) {
54
+ const detail = aaByExt[decodeURIComponent(m[1]!)];
55
+ return detail ? j(detail) : notFound();
56
+ }
57
+ if (path === `${b}/dialplans/${domain}/dialrules`) return j(raw?.dialrulesByPlan?.[domain] ?? []);
58
+ return notFound();
59
+ }) as unknown as typeof fetch;
60
+
61
+ let pass = 0;
62
+ let fail = 0;
63
+ const ok = (c: boolean, msg: string) => {
64
+ c ? pass++ : fail++;
65
+ console.log(`${c ? '✓' : '✗ FAIL'} ${msg}`);
66
+ };
67
+
68
+ (async () => {
69
+ if (raw) {
70
+ const client = new NsClient({ server: 'mock.local', token: 'x', fetchImpl: mockFetch });
71
+ const rebuilt = await fetchDomainSnapshot(client, domain, { includeDialrules: true });
72
+
73
+ // Feed the raw fixture its sidecar AA details too, so both sides render menus identically.
74
+ const rawWithAa: Snapshot = { ...raw, attendantDetails: aaByExt as Record<string, any> };
75
+
76
+ const ents = listEntities(rebuilt);
77
+ const cases = [
78
+ ...ents.dids.map((d) => ({ kind: 'did' as const, ref: d.ref })),
79
+ ...ents.queues.map((q) => ({ kind: 'queue' as const, ref: q.ref })),
80
+ ...ents.attendants.map((a) => ({ kind: 'attendant' as const, ref: a.ref })),
81
+ ...ents.users.map((u) => ({ kind: 'user' as const, ref: u.ref })),
82
+ ];
83
+ ok(cases.length > 0, `enumerated ${cases.length} entities from the rebuilt snapshot`);
84
+
85
+ let mismatches = 0;
86
+ for (const c of cases) {
87
+ const a = JSON.stringify(resolveFlow(rawWithAa, c));
88
+ const b = JSON.stringify(resolveFlow(rebuilt, c));
89
+ if (a !== b) {
90
+ mismatches++;
91
+ console.log(` ✗ graph differs for ${c.kind} ${c.ref}`);
92
+ }
93
+ }
94
+ ok(mismatches === 0, `all ${cases.length} flows identical: rebuilt-from-API vs raw fixture`);
95
+ } else {
96
+ console.log('(no fixture given — skipping the rebuilt-vs-raw comparison; usage: tsx src/nsClient.selftest.ts <snapshot.json> [attendantsDir])');
97
+ }
98
+
99
+ // -- the optional inventory reads ----------------------------------------------------------------
100
+ {
101
+ const seen: string[] = [];
102
+ const fake = {
103
+ get: async (p: string) => {
104
+ seen.push(p);
105
+ if (/\/users$/.test(p)) return [
106
+ { user: '100', 'service-code': '' },
107
+ { user: '700', 'service-code': 'system-aa' },
108
+ ];
109
+ if (/\/users\/100\/devices$/.test(p)) return [{ aor: 'sip:100@acme.example', 'device-models-model': 'Yealink T54W' }];
110
+ if (/\/addresses$/.test(p)) return [{ 'address-id': '1' }];
111
+ if (/\/smsnumbers/.test(p)) return [{ number: '13175550100' }];
112
+ return [];
113
+ },
114
+ } as unknown as NsClient;
115
+
116
+ const snap = await fetchDomainSnapshot(fake, 'acme.example', {
117
+ includeAttendantMenus: false, includeAddresses: true, includeSmsNumbers: true, includeDevices: true,
118
+ });
119
+
120
+ ok(Array.isArray(snap.addresses) && snap.addresses.length === 1, 'addresses are read into the snapshot');
121
+ ok(Array.isArray(snap.smsnumbers) && snap.smsnumbers.length === 1, 'SMS numbers are read into the snapshot');
122
+ ok(seen.some((p) => p === '/domains/acme.example/smsnumbers?dest=*'), 'the SMS read carries dest=* - the live server refuses the documented no-parameter call');
123
+ ok(snap.devicesByUser?.['100']?.length === 1, 'a real extension devices are read');
124
+ ok(snap.devicesByUser?.['700'] === undefined, 'a system user costs no device call');
125
+ ok(!seen.some((p) => /\/users\/700\/devices$/.test(p)), 'and none was made');
126
+
127
+ const off = await fetchDomainSnapshot(fake, 'acme.example', { includeAttendantMenus: false });
128
+ ok(off.addresses === undefined && off.smsnumbers === undefined && off.devicesByUser === undefined,
129
+ 'all three reads are opt-in - a caller that wants routing pays for none of them');
130
+ }
131
+
132
+ // -- a per-extension devices read that fails is reported, not swallowed -------------------------
133
+ {
134
+ const failFake = {
135
+ get: async (p: string) => {
136
+ if (/\/users$/.test(p)) return [
137
+ { user: '100', 'service-code': '' },
138
+ { user: '102', 'service-code': '' },
139
+ { user: '103', 'service-code': '' },
140
+ ];
141
+ if (/\/users\/100\/devices$/.test(p)) return [{ aor: 'sip:100@acme.example' }];
142
+ if (/\/users\/102\/devices$/.test(p)) throw new NsApiError('GET .../devices → 500', 500, p, null);
143
+ if (/\/users\/103\/devices$/.test(p)) throw new NsApiError('GET .../devices → 404', 404, p, null);
144
+ return [];
145
+ },
146
+ } as unknown as NsClient;
147
+
148
+ const failSnap = await fetchDomainSnapshot(failFake, 'acme.example', { includeAttendantMenus: false, includeDevices: true });
149
+ ok(failSnap.devicesByUser?.['100']?.length === 1, 'the extension whose read succeeded keeps its devices');
150
+ ok(failSnap.devicesByUser?.['102'] === undefined, 'the extension whose read failed has no devicesByUser entry');
151
+ ok(failSnap.devicesByUser?.['103'] === undefined, 'a 404 extension also has no devicesByUser entry');
152
+ ok(JSON.stringify(failSnap.deviceReadFailures) === JSON.stringify(['102']),
153
+ 'deviceReadFailures names only the non-404 failure, not the 404');
154
+
155
+ const okFake = {
156
+ get: async (p: string) => {
157
+ if (/\/users$/.test(p)) return [{ user: '100', 'service-code': '' }];
158
+ if (/\/users\/100\/devices$/.test(p)) return [{ aor: 'sip:100@acme.example' }];
159
+ return [];
160
+ },
161
+ } as unknown as NsClient;
162
+
163
+ const okSnap = await fetchDomainSnapshot(okFake, 'acme.example', { includeAttendantMenus: false, includeDevices: true });
164
+ ok(Array.isArray(okSnap.deviceReadFailures) && okSnap.deviceReadFailures.length === 0,
165
+ 'no failures - deviceReadFailures is an empty array, not absent');
166
+
167
+ const noDevicesSnap = await fetchDomainSnapshot(okFake, 'acme.example', { includeAttendantMenus: false });
168
+ ok(noDevicesSnap.deviceReadFailures === undefined, 'without includeDevices, deviceReadFailures is absent entirely');
169
+ }
170
+
171
+ console.log(`\n${pass} passed, ${fail} failed`);
172
+ process.exit(fail ? 1 : 0);
173
+ })();