@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.
- package/README.md +95 -1
- package/dist/eligibility.d.ts.map +1 -0
- package/dist/eligibility.js.map +1 -0
- package/dist/html.d.ts.map +1 -0
- package/dist/html.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -0
- package/dist/inventory.d.ts +151 -0
- package/dist/inventory.d.ts.map +1 -0
- package/dist/inventory.js +0 -0
- package/dist/inventory.js.map +1 -0
- package/dist/jwt.d.ts.map +1 -0
- package/dist/jwt.js.map +1 -0
- package/dist/mermaid.d.ts.map +1 -0
- package/dist/mermaid.js.map +1 -0
- package/dist/model.d.ts +18 -0
- package/dist/model.d.ts.map +1 -0
- package/dist/model.js.map +1 -0
- package/dist/nsAuthClient.d.ts.map +1 -0
- package/dist/nsAuthClient.js.map +1 -0
- package/dist/nsClient.d.ts +19 -0
- package/dist/nsClient.d.ts.map +1 -0
- package/dist/nsClient.js +40 -3
- package/dist/nsClient.js.map +1 -0
- package/dist/nsDevice.d.ts.map +1 -0
- package/dist/nsDevice.js.map +1 -0
- package/dist/nsSubscriptions.d.ts.map +1 -0
- package/dist/nsSubscriptions.js.map +1 -0
- package/dist/nsSynchronous.d.ts.map +1 -0
- package/dist/nsSynchronous.js.map +1 -0
- package/dist/nsWriteClient.d.ts.map +1 -0
- package/dist/nsWriteClient.js.map +1 -0
- package/dist/policy.d.ts +8 -2
- package/dist/policy.d.ts.map +1 -0
- package/dist/policy.js +3 -1
- package/dist/policy.js.map +1 -0
- package/dist/principal.d.ts.map +1 -0
- package/dist/principal.js.map +1 -0
- package/dist/raster.d.ts.map +1 -0
- package/dist/raster.js.map +1 -0
- package/dist/resolver.d.ts.map +1 -0
- package/dist/resolver.js.map +1 -0
- package/dist/sensitivity.d.ts.map +1 -0
- package/dist/sensitivity.js.map +1 -0
- package/dist/themes.d.ts.map +1 -0
- package/dist/themes.js.map +1 -0
- package/package.json +7 -3
- package/src/eligibility.selftest.ts +95 -0
- package/src/eligibility.ts +118 -0
- package/src/html.ts +407 -0
- package/src/index.ts +120 -0
- package/src/inventory.selftest.ts +198 -0
- package/src/inventory.ts +314 -0
- package/src/jwt.selftest.ts +145 -0
- package/src/jwt.ts +491 -0
- package/src/mermaid.ts +169 -0
- package/src/model.ts +130 -0
- package/src/nsAuthClient.selftest.ts +60 -0
- package/src/nsAuthClient.ts +102 -0
- package/src/nsClient.selftest.ts +173 -0
- package/src/nsClient.ts +323 -0
- package/src/nsDevice.selftest.ts +190 -0
- package/src/nsDevice.ts +167 -0
- package/src/nsSubscriptions.selftest.ts +486 -0
- package/src/nsSubscriptions.ts +638 -0
- package/src/nsSynchronous.selftest.ts +63 -0
- package/src/nsSynchronous.ts +98 -0
- package/src/nsWriteClient.selftest.ts +104 -0
- package/src/nsWriteClient.ts +157 -0
- package/src/policy.ts +123 -0
- package/src/principal.selftest.ts +118 -0
- package/src/principal.ts +101 -0
- package/src/raster.selftest.ts +42 -0
- package/src/raster.ts +79 -0
- package/src/resolver.selftest.ts +225 -0
- package/src/resolver.ts +1115 -0
- package/src/sensitivity.ts +40 -0
- package/src/themes.ts +142 -0
package/src/nsClient.ts
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Portable NetSapiens API read client — the seed of the eventual "NS API for Worker/Node"
|
|
3
|
+
* library. Ported from an internal onboarding tool (its API client
|
|
4
|
+
* NsClient + `src/backup/snapshot.ts` backupDomain), trimmed to the READ-ONLY routing subset the
|
|
5
|
+
* resolver needs, and kept Node-free (fetch/URL only) so it runs in a Cloudflare Worker unchanged.
|
|
6
|
+
*
|
|
7
|
+
* `fetchDomainSnapshot()` assembles the same `Snapshot` shape the resolver already consumes, so a
|
|
8
|
+
* live domain flows end-to-end: domain + token → Snapshot → resolveFlow → FlowGraph.
|
|
9
|
+
*
|
|
10
|
+
* This client never writes — only GET is exposed. Writes live in the separate `NsWriteClient`.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { Rec, Snapshot } from './model.js';
|
|
14
|
+
|
|
15
|
+
export class NsApiError extends Error {
|
|
16
|
+
constructor(
|
|
17
|
+
message: string,
|
|
18
|
+
public readonly status: number,
|
|
19
|
+
public readonly path: string,
|
|
20
|
+
public readonly body: unknown,
|
|
21
|
+
/** HTTP method — set by the write client; optional so read-client call sites stay unchanged. */
|
|
22
|
+
public readonly method?: string,
|
|
23
|
+
) {
|
|
24
|
+
super(message);
|
|
25
|
+
this.name = 'NsApiError';
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface NsClientConfig {
|
|
30
|
+
/** API host, e.g. "api.example.com". Base URL becomes https://{server}/ns-api/v2. */
|
|
31
|
+
server: string;
|
|
32
|
+
/** Bearer token (the portal user's `ns_t`, or an API key). */
|
|
33
|
+
token: string;
|
|
34
|
+
/** Injectable for tests / non-global fetch. */
|
|
35
|
+
fetchImpl?: typeof fetch;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* NS API v2 client — READ-ONLY BY DESIGN.
|
|
40
|
+
*
|
|
41
|
+
* The ONLY method is `get()`, which hardcodes `method: 'GET'`. There is deliberately no
|
|
42
|
+
* post/put/delete/patch. Keep it that way: **do not add a mutating method here.** The point of this
|
|
43
|
+
* class is that holding one is proof you cannot write — a guarantee by construction, not convention,
|
|
44
|
+
* and adding a single mutating method destroys it for every consumer at once.
|
|
45
|
+
*
|
|
46
|
+
* Writes are a separate, explicitly-imported class: `NsWriteClient` (see `nsWriteClient.ts`). New
|
|
47
|
+
* write capability extends that one, never this one. `NsSubscriptionsClient` is a third client for
|
|
48
|
+
* the same reason — its `DELETE` semantics differ from `NsWriteClient`'s.
|
|
49
|
+
*/
|
|
50
|
+
/**
|
|
51
|
+
* Reject a `server` that isn't a bare host or `host:port`. A caller that derives `server` from
|
|
52
|
+
* request input (a multi-tenant tool) would otherwise let `api.example.com@evil.example` or
|
|
53
|
+
* `evil.example#…` redirect the Bearer token to another origin — `new URL('https://'+server).host`
|
|
54
|
+
* is what actually gets contacted, not the string. Comparing the parsed host back to the input
|
|
55
|
+
* catches an embedded `@`, `/path`, `?query`, `#frag`, or scheme; credentials are refused explicitly.
|
|
56
|
+
* Host comparison is case-insensitive (URL lowercases the host; NS hostnames are case-insensitive).
|
|
57
|
+
*/
|
|
58
|
+
export function assertBareServer(server: string): string {
|
|
59
|
+
const s = String(server ?? '').trim().replace(/\/+$/, '');
|
|
60
|
+
let u: URL;
|
|
61
|
+
try {
|
|
62
|
+
u = new URL(`https://${s}`);
|
|
63
|
+
} catch {
|
|
64
|
+
throw new Error(`Invalid NS server "${server}": expected a bare host or host:port`);
|
|
65
|
+
}
|
|
66
|
+
if (u.host !== s.toLowerCase() || u.username || u.password) {
|
|
67
|
+
throw new Error(`Invalid NS server "${server}": expected a bare host or host:port (no scheme, path, query, fragment, or credentials)`);
|
|
68
|
+
}
|
|
69
|
+
return u.host;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export class NsClient {
|
|
73
|
+
readonly #baseUrl: string;
|
|
74
|
+
readonly #token: string;
|
|
75
|
+
readonly #fetchImpl: typeof fetch;
|
|
76
|
+
|
|
77
|
+
constructor(cfg: NsClientConfig) {
|
|
78
|
+
this.#baseUrl = `https://${assertBareServer(cfg.server)}/ns-api/v2`;
|
|
79
|
+
this.#token = cfg.token;
|
|
80
|
+
this.#fetchImpl = cfg.fetchImpl ?? fetch;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async get<T = unknown>(path: string, query?: Record<string, string | number>): Promise<T> {
|
|
84
|
+
const url = new URL(this.#baseUrl + path);
|
|
85
|
+
for (const [k, v] of Object.entries(query ?? {})) url.searchParams.set(k, String(v));
|
|
86
|
+
|
|
87
|
+
// Call via a local, NOT `this.#fetchImpl(...)`: invoking the global fetch as a method of this
|
|
88
|
+
// instance throws "Illegal invocation" in workerd (the global fetch requires a global `this`).
|
|
89
|
+
const doFetch = this.#fetchImpl;
|
|
90
|
+
const res = await doFetch(url.toString(), {
|
|
91
|
+
method: 'GET',
|
|
92
|
+
headers: { Authorization: `Bearer ${this.#token}`, Accept: 'application/json' },
|
|
93
|
+
});
|
|
94
|
+
const text = await res.text();
|
|
95
|
+
let parsed: unknown = text;
|
|
96
|
+
if (text) {
|
|
97
|
+
try {
|
|
98
|
+
parsed = JSON.parse(text);
|
|
99
|
+
} catch {
|
|
100
|
+
/* some endpoints return empty / plain bodies */
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (!res.ok) {
|
|
104
|
+
// Truncate BOTH branches to 500 chars: the object branch used to JSON.stringify the whole
|
|
105
|
+
// upstream body unbounded, so a consumer that logs err.message could log an arbitrarily large
|
|
106
|
+
// NS response. No credential is ever in it, but size alone is a footgun.
|
|
107
|
+
const detail = (typeof parsed === 'object' && parsed !== null ? JSON.stringify(parsed) : String(parsed)).slice(0, 500);
|
|
108
|
+
const hint = res.status === 401 ? ' (token expired/invalid or domain out of scope)' : res.status === 403 ? ' (token lacks permission)' : '';
|
|
109
|
+
throw new NsApiError(`GET ${path} → ${res.status}${hint}: ${detail}`, res.status, path, parsed);
|
|
110
|
+
}
|
|
111
|
+
return parsed as T;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Normalize a v2 response to an array of records (endpoints return an array or a bare object). */
|
|
116
|
+
export function asArray(res: unknown): Rec[] {
|
|
117
|
+
if (Array.isArray(res)) return res as Rec[];
|
|
118
|
+
if (res && typeof res === 'object') return [res as Rec];
|
|
119
|
+
return [];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Map with bounded concurrency — keeps per-user/queue/AA fan-out from hammering the API. */
|
|
123
|
+
async function mapLimit<T>(items: T[], limit: number, fn: (item: T, index: number) => Promise<void>): Promise<void> {
|
|
124
|
+
let i = 0;
|
|
125
|
+
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
126
|
+
while (i < items.length) {
|
|
127
|
+
const idx = i++;
|
|
128
|
+
await fn(items[idx]!, idx);
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
await Promise.all(workers);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const enc = encodeURIComponent;
|
|
135
|
+
|
|
136
|
+
/** List domains the token can read (for the internal viewer's domain browser). `locked` is set only
|
|
137
|
+
* for domains flagged `is-domain-locked: yes` (config-locked in NetSapiens). */
|
|
138
|
+
export async function listDomains(client: NsClient): Promise<{ domain: string; description?: string; locked?: boolean }[]> {
|
|
139
|
+
const recs = asArray(await client.get('/domains'));
|
|
140
|
+
return recs
|
|
141
|
+
.map((r) => ({
|
|
142
|
+
domain: String(r.domain ?? ''),
|
|
143
|
+
...(r.description ? { description: String(r.description) } : {}),
|
|
144
|
+
...(r['is-domain-locked'] === 'yes' ? { locked: true } : {}),
|
|
145
|
+
}))
|
|
146
|
+
.filter((d) => d.domain);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export interface FetchSnapshotOptions {
|
|
150
|
+
/** Fetch each AA's keypress menu (GET .../autoattendants/{prompt}). Default true. */
|
|
151
|
+
includeAttendantMenus?: boolean;
|
|
152
|
+
/** Also fetch the default-plan dialrules (rarely needed — classifyParam handles aliases). Default false. */
|
|
153
|
+
includeDialrules?: boolean;
|
|
154
|
+
/** Max concurrent per-item requests. Default 5. Mind Workers' subrequest cap on huge domains. */
|
|
155
|
+
concurrency?: number;
|
|
156
|
+
/**
|
|
157
|
+
* Shallow: fetch only the top-level lists (domain, timeframes, users, callqueues, phonenumbers,
|
|
158
|
+
* autoattendants) and skip the per-user/queue/AA fan-out. Enough for `listEntities()` (the entity
|
|
159
|
+
* picker) at a fraction of the requests. Default false.
|
|
160
|
+
*/
|
|
161
|
+
shallow?: boolean;
|
|
162
|
+
/**
|
|
163
|
+
* With `shallow`, also fetch answer rules for the DIDs' destination users (a handful of extra
|
|
164
|
+
* reads) so `listEntities()` can flag time-of-day (TOD) DIDs. Default false.
|
|
165
|
+
*/
|
|
166
|
+
includeDidDestRules?: boolean;
|
|
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.
|
|
170
|
+
*/
|
|
171
|
+
includeAddresses?: boolean;
|
|
172
|
+
/**
|
|
173
|
+
* Also read the domain's SMS-enabled numbers into `snapshot.smsnumbers`. One extra call. Default false.
|
|
174
|
+
*
|
|
175
|
+
* Sent as `?dest=*`. The endpoint is documented as taking no parameters and a live server rejects it
|
|
176
|
+
* that way, demanding `dest` or `number`; the wildcard is what answers with the list.
|
|
177
|
+
*/
|
|
178
|
+
includeSmsNumbers?: boolean;
|
|
179
|
+
/**
|
|
180
|
+
* Also read each REAL extension's devices into `devicesByUser`. Costs one call per extension —
|
|
181
|
+
* NetSapiens exposes devices only per user — so it is off by default and belongs to inventory
|
|
182
|
+
* callers, not routing ones. Users whose `service-code` starts with `system-` are skipped: they are
|
|
183
|
+
* auto attendants and queues, and they hold no seat.
|
|
184
|
+
*/
|
|
185
|
+
includeDevices?: boolean;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Read a live domain into the `Snapshot` shape the resolver consumes. Routing subset only:
|
|
190
|
+
* domain, timeframes, users, callqueues, phonenumbers, autoattendants, per-user answerrules,
|
|
191
|
+
* per-queue agents, and (by default) per-AA menu detail.
|
|
192
|
+
*
|
|
193
|
+
* A per-item read that fails is treated as "absent" (empty) so one missing child never aborts the
|
|
194
|
+
* whole flow — the resolver tolerates gaps. A failing top-level read (e.g. 401) DOES throw.
|
|
195
|
+
*/
|
|
196
|
+
export async function fetchDomainSnapshot(client: NsClient, domain: string, opts: FetchSnapshotOptions = {}): Promise<Snapshot> {
|
|
197
|
+
const conc = opts.concurrency ?? 5;
|
|
198
|
+
const base = `/domains/${enc(domain)}`;
|
|
199
|
+
const soft = async (p: string): Promise<Rec[]> => {
|
|
200
|
+
try {
|
|
201
|
+
return asArray(await client.get(p));
|
|
202
|
+
} catch (err) {
|
|
203
|
+
if (err instanceof NsApiError && err.status === 404) return [];
|
|
204
|
+
throw err;
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const domainRec = asArray(await client.get(base))[0] ?? { domain };
|
|
209
|
+
const [timeframes, users, callqueues, phonenumbers, autoattendants, addresses, smsnumbers] = await Promise.all([
|
|
210
|
+
soft(`${base}/timeframes`),
|
|
211
|
+
soft(`${base}/users`),
|
|
212
|
+
soft(`${base}/callqueues`),
|
|
213
|
+
soft(`${base}/phonenumbers`),
|
|
214
|
+
soft(`${base}/autoattendants`),
|
|
215
|
+
opts.includeAddresses ? soft(`${base}/addresses`) : Promise.resolve(undefined),
|
|
216
|
+
// `dest=*`: see includeSmsNumbers. A 404 is already softened to []; a 400 from a server that wants
|
|
217
|
+
// a different parameter throws, which is right — a silent empty list would read as "no SMS numbers".
|
|
218
|
+
opts.includeSmsNumbers ? soft(`${base}/smsnumbers?dest=*`) : Promise.resolve(undefined),
|
|
219
|
+
]);
|
|
220
|
+
|
|
221
|
+
// Shallow mode stops here — enough for listEntities() (the picker). `includeDevices` is ignored in
|
|
222
|
+
// shallow mode: shallow's whole contract is "no per-item fan-out".
|
|
223
|
+
if (opts.shallow) {
|
|
224
|
+
let answerrulesByUser: Record<string, Rec[]> | undefined;
|
|
225
|
+
if (opts.includeDidDestRules) {
|
|
226
|
+
const dests = [...new Set(phonenumbers.map((p) => String(p['dial-rule-translation-destination-user'] ?? '')).filter(Boolean))];
|
|
227
|
+
answerrulesByUser = {};
|
|
228
|
+
await mapLimit(dests, conc, async (u) => {
|
|
229
|
+
const rules = await soft(`${base}/users/${enc(u)}/answerrules`).catch(() => []);
|
|
230
|
+
if (rules.length) answerrulesByUser![u] = rules;
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
return {
|
|
234
|
+
meta: { domain }, domain: domainRec, timeframes, users, callqueues, phonenumbers, autoattendants,
|
|
235
|
+
...(answerrulesByUser ? { answerrulesByUser } : {}),
|
|
236
|
+
...(addresses ? { addresses } : {}),
|
|
237
|
+
...(smsnumbers ? { smsnumbers } : {}),
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Devices, per REAL extension. One call each — there is no domain-level device list — so this is the
|
|
242
|
+
// expensive half of an inventory read and is opt-in for that reason.
|
|
243
|
+
let devicesByUser: Record<string, Rec[]> | undefined;
|
|
244
|
+
let deviceReadFailures: string[] | undefined;
|
|
245
|
+
if (opts.includeDevices) {
|
|
246
|
+
devicesByUser = {};
|
|
247
|
+
deviceReadFailures = [];
|
|
248
|
+
const seats = users.filter((u) => !String(u['service-code'] ?? '').trim().toLowerCase().startsWith('system-'));
|
|
249
|
+
await mapLimit(seats, conc, async (u) => {
|
|
250
|
+
const ext = String(u.user ?? '');
|
|
251
|
+
if (!ext) return;
|
|
252
|
+
// `soft()` already turns a 404 into `[]` — that is "no devices", not a failure. Anything else
|
|
253
|
+
// it rethrows, and THAT is what gets recorded here: a consumer counting devices must be able
|
|
254
|
+
// to tell a genuine zero from a read that never completed.
|
|
255
|
+
const devs = await soft(`${base}/users/${enc(ext)}/devices`).catch(() => {
|
|
256
|
+
deviceReadFailures!.push(ext);
|
|
257
|
+
return [];
|
|
258
|
+
});
|
|
259
|
+
if (devs.length) devicesByUser![ext] = devs;
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const answerrulesByUser: Record<string, Rec[]> = {};
|
|
264
|
+
await mapLimit(users, conc, async (u) => {
|
|
265
|
+
const ext = String(u.user ?? '');
|
|
266
|
+
if (!ext) return;
|
|
267
|
+
const rules = await soft(`${base}/users/${enc(ext)}/answerrules`).catch(() => []);
|
|
268
|
+
if (rules.length) answerrulesByUser[ext] = rules;
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
const agentsByQueue: Record<string, Rec[]> = {};
|
|
272
|
+
await mapLimit(callqueues, conc, async (q) => {
|
|
273
|
+
const ext = String(q.callqueue ?? '');
|
|
274
|
+
if (!ext) return;
|
|
275
|
+
const ags = await soft(`${base}/callqueues/${enc(ext)}/agents`).catch(() => []);
|
|
276
|
+
if (ags.length) agentsByQueue[ext] = ags;
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
// One list row per (user, prompt); an AA may have several (multi-timeframe). Collect ALL detail
|
|
280
|
+
// records per user (array) so the resolver can flag multi-prompt deviations, not just last-wins.
|
|
281
|
+
// Also fetch each AA's OWN dialplan dialrules ({domain}_{ext}) — the authoritative menu/default
|
|
282
|
+
// routing the /autoattendants detail omits (no-key/star/option). See ARCHITECTURE.md → NetSapiens routing model.
|
|
283
|
+
let attendantDetailsByUser: Record<string, Rec[]> | undefined;
|
|
284
|
+
let attendantDialrulesByExt: Record<string, Rec[]> | undefined;
|
|
285
|
+
if (opts.includeAttendantMenus ?? true) {
|
|
286
|
+
const rows = autoattendants.map((aa) => ({ ext: String(aa.user ?? ''), prompt: String(aa['starting-prompt'] ?? '') })).filter((r) => r.ext && r.prompt);
|
|
287
|
+
attendantDetailsByUser = {};
|
|
288
|
+
for (const r of rows) attendantDetailsByUser[r.ext] ??= []; // pre-init (avoid concurrent-init race)
|
|
289
|
+
await mapLimit(rows, conc, async (r) => {
|
|
290
|
+
const detail = (await soft(`${base}/users/${enc(r.ext)}/autoattendants/${enc(r.prompt)}`).catch(() => []))[0];
|
|
291
|
+
if (detail) attendantDetailsByUser![r.ext].push(detail);
|
|
292
|
+
});
|
|
293
|
+
attendantDialrulesByExt = {};
|
|
294
|
+
await mapLimit([...new Set(rows.map((r) => r.ext))], conc, async (ext) => {
|
|
295
|
+
const dr = await soft(`${base}/dialplans/${enc(`${domain}_${ext}`)}/dialrules`).catch(() => []);
|
|
296
|
+
if (dr.length) attendantDialrulesByExt![ext] = dr;
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
let dialrulesByPlan: Record<string, Rec[]> | undefined;
|
|
301
|
+
if (opts.includeDialrules) {
|
|
302
|
+
dialrulesByPlan = { [domain]: await soft(`${base}/dialplans/${enc(domain)}/dialrules`).catch(() => []) };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return {
|
|
306
|
+
meta: { domain },
|
|
307
|
+
domain: domainRec,
|
|
308
|
+
timeframes,
|
|
309
|
+
users,
|
|
310
|
+
callqueues,
|
|
311
|
+
phonenumbers,
|
|
312
|
+
autoattendants,
|
|
313
|
+
answerrulesByUser,
|
|
314
|
+
agentsByQueue,
|
|
315
|
+
...(addresses ? { addresses } : {}),
|
|
316
|
+
...(smsnumbers ? { smsnumbers } : {}),
|
|
317
|
+
...(devicesByUser ? { devicesByUser } : {}),
|
|
318
|
+
...(deviceReadFailures ? { deviceReadFailures } : {}),
|
|
319
|
+
...(attendantDetailsByUser && Object.keys(attendantDetailsByUser).length ? { attendantDetailsByUser } : {}),
|
|
320
|
+
...(attendantDialrulesByExt && Object.keys(attendantDialrulesByExt).length ? { attendantDialrulesByExt } : {}),
|
|
321
|
+
...(dialrulesByPlan ? { dialrulesByPlan } : {}),
|
|
322
|
+
};
|
|
323
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Selftests for device orchestration: ensureNsDevice + generateSipPassword. Fully offline.
|
|
3
|
+
*
|
|
4
|
+
* Run: pnpm test:nsdevice
|
|
5
|
+
*/
|
|
6
|
+
import { ensureNsDevice, generateSipPassword, SIP_PW_FIELD, type NsDeviceWriter } from './nsDevice.js';
|
|
7
|
+
import { NsWriteClient } from './nsWriteClient.js';
|
|
8
|
+
import type { Rec } from './model.js';
|
|
9
|
+
|
|
10
|
+
let pass = 0,
|
|
11
|
+
fail = 0;
|
|
12
|
+
const ok = (c: boolean, m: string) => {
|
|
13
|
+
c ? pass++ : fail++;
|
|
14
|
+
console.log(`${c ? '✓' : '✗ FAIL'} ${m}`);
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/** In-memory device store, recording every call. */
|
|
18
|
+
function mockWriter(seed: Record<string, string> = {}) {
|
|
19
|
+
const store = new Map<string, Rec>();
|
|
20
|
+
for (const [name, pw] of Object.entries(seed)) store.set(name, { device: name, [SIP_PW_FIELD]: pw });
|
|
21
|
+
const calls: string[] = [];
|
|
22
|
+
let failUpdate = false;
|
|
23
|
+
let omitPwOnUpdate = false;
|
|
24
|
+
let seq = 0;
|
|
25
|
+
const w: NsDeviceWriter = {
|
|
26
|
+
async getDevices() {
|
|
27
|
+
calls.push('getDevices');
|
|
28
|
+
return [...store.values()];
|
|
29
|
+
},
|
|
30
|
+
async getDevice(_d, _u, device) {
|
|
31
|
+
calls.push(`getDevice:${device}`);
|
|
32
|
+
return store.get(device) ?? {};
|
|
33
|
+
},
|
|
34
|
+
async createDevice(_d, _u, device) {
|
|
35
|
+
calls.push(`createDevice:${device}`);
|
|
36
|
+
const rec = { device, [SIP_PW_FIELD]: `GEN${++seq}` };
|
|
37
|
+
store.set(device, rec);
|
|
38
|
+
return rec;
|
|
39
|
+
},
|
|
40
|
+
async updateDevice(_d, _u, device, changes) {
|
|
41
|
+
calls.push(`updateDevice:${device}`);
|
|
42
|
+
if (failUpdate) throw new Error('404 No Route Found');
|
|
43
|
+
const rec = { ...(store.get(device) ?? { device }), ...changes };
|
|
44
|
+
store.set(device, rec);
|
|
45
|
+
return omitPwOnUpdate ? { device } : rec;
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
return { w, calls, store, failUpdate: (v: boolean) => (failUpdate = v), omitPwOnUpdate: (v: boolean) => (omitPwOnUpdate = v) };
|
|
49
|
+
}
|
|
50
|
+
const O = { domain: 'acme.example.com', user: '100', device: '100r' };
|
|
51
|
+
|
|
52
|
+
// ── generateSipPassword ───────────────────────────────────────────────────────
|
|
53
|
+
ok(generateSipPassword().length === 20, 'defaults to 20 characters');
|
|
54
|
+
ok(/^[A-Za-z0-9]{20}$/.test(generateSipPassword()), 'alphanumeric only — nothing to mis-escape in SIP auth or provisioning');
|
|
55
|
+
ok(generateSipPassword(40).length === 40, 'length is configurable');
|
|
56
|
+
ok(generateSipPassword(1).length === 1, 'a length of 1 works');
|
|
57
|
+
ok(new Set(Array.from({ length: 200 }, () => generateSipPassword(8))).size === 200, '200 generated passwords are all distinct');
|
|
58
|
+
{
|
|
59
|
+
// A uniform draw omits digits ~3% of the time at length 20 — often enough to look like a bug when you
|
|
60
|
+
// eyeball one, and enough to trip a downstream complexity rule. Every candidate must carry all three.
|
|
61
|
+
const sample = Array.from({ length: 500 }, () => generateSipPassword());
|
|
62
|
+
ok(sample.every((p) => /[0-9]/.test(p)), 'EVERY password contains at least one digit (500 samples)');
|
|
63
|
+
ok(sample.every((p) => /[a-z]/.test(p)), 'every password contains at least one lowercase letter');
|
|
64
|
+
ok(sample.every((p) => /[A-Z]/.test(p)), 'every password contains at least one uppercase letter');
|
|
65
|
+
ok(sample.every((p) => /^[A-Za-z0-9]{20}$/.test(p)), 'and they stay alphanumeric at the requested length');
|
|
66
|
+
ok(Array.from({ length: 200 }, () => generateSipPassword(3)).every((p) => p.length === 3 && /[0-9]/.test(p) && /[a-z]/.test(p) && /[A-Z]/.test(p)), 'the guarantee holds at the minimum viable length of 3');
|
|
67
|
+
ok([1, 2].every((n) => generateSipPassword(n).length === n), 'lengths below 3 still work (the guarantee is impossible there)');
|
|
68
|
+
}
|
|
69
|
+
{
|
|
70
|
+
// Uniformity sanity: with rejection sampling every symbol class should appear across a large sample.
|
|
71
|
+
const big = Array.from({ length: 400 }, () => generateSipPassword(16)).join('');
|
|
72
|
+
ok(/[A-Z]/.test(big) && /[a-z]/.test(big) && /[0-9]/.test(big), 'the whole alphabet is reachable');
|
|
73
|
+
ok(new Set(big).size >= 55, 'nearly every symbol appears in a large sample (no modulo dead zone)');
|
|
74
|
+
}
|
|
75
|
+
for (const bad of [0, -1, 1.5, NaN]) {
|
|
76
|
+
let threw = false;
|
|
77
|
+
try {
|
|
78
|
+
generateSipPassword(bad);
|
|
79
|
+
} catch {
|
|
80
|
+
threw = true;
|
|
81
|
+
}
|
|
82
|
+
ok(threw, `an invalid length (${bad}) throws rather than looping or returning junk`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ── absent device ─────────────────────────────────────────────────────────────
|
|
86
|
+
{
|
|
87
|
+
const m = mockWriter();
|
|
88
|
+
const r = await ensureNsDevice(m.w, O);
|
|
89
|
+
ok(r.created === true && r.password === 'GEN1', 'a missing device is created and its generated password returned');
|
|
90
|
+
ok(m.calls.includes('createDevice:100r'), 'createDevice was called');
|
|
91
|
+
ok(r.rotated === undefined, 'a newly created device reports no rotation — it is already exclusive');
|
|
92
|
+
}
|
|
93
|
+
{
|
|
94
|
+
const m = mockWriter();
|
|
95
|
+
const r = await ensureNsDevice(m.w, { ...O, mayCreate: false });
|
|
96
|
+
ok(r.password === '' && r.created === false, 'mayCreate:false on a missing device yields a blank password (a refusal signal)');
|
|
97
|
+
ok(!m.calls.some((c) => c.startsWith('createDevice')), 'and nothing is created');
|
|
98
|
+
}
|
|
99
|
+
{
|
|
100
|
+
const m = mockWriter();
|
|
101
|
+
const r = await ensureNsDevice(m.w, { ...O, mayCreate: false, rotateExisting: true });
|
|
102
|
+
ok(r.password === '' && !m.calls.some((c) => c.startsWith('updateDevice')), 'a missing device is never rotated');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ── existing device, no rotation ───────────────────────────────────────────────
|
|
106
|
+
{
|
|
107
|
+
const m = mockWriter({ '100r': 'STORED_PASSWORD' });
|
|
108
|
+
const r = await ensureNsDevice(m.w, O);
|
|
109
|
+
ok(r.password === 'STORED_PASSWORD' && r.created === false, 'an existing device returns its stored password');
|
|
110
|
+
ok(m.calls.includes('getDevice:100r'), 'a per-device GET is issued because a device LIST may omit the password');
|
|
111
|
+
ok(!m.calls.some((c) => c.startsWith('updateDevice')), 'nothing is rotated by default — this is the pre-existing behaviour');
|
|
112
|
+
}
|
|
113
|
+
{
|
|
114
|
+
// The list carries the password but the per-device GET does not: fall back to the list value.
|
|
115
|
+
const m = mockWriter();
|
|
116
|
+
m.store.set('100r', { device: '100r' });
|
|
117
|
+
const w2: NsDeviceWriter = { ...m.w, async getDevices() { return [{ device: '100r', [SIP_PW_FIELD]: 'FROM_LIST' }]; } };
|
|
118
|
+
const r = await ensureNsDevice(w2, O);
|
|
119
|
+
ok(r.password === 'FROM_LIST', 'falls back to the list password when the per-device read omits it');
|
|
120
|
+
}
|
|
121
|
+
{
|
|
122
|
+
const m = mockWriter({ other: 'X' });
|
|
123
|
+
const r = await ensureNsDevice(m.w, O);
|
|
124
|
+
ok(r.created === true, 'a device with a different name does not satisfy the requested one');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ── rotation ──────────────────────────────────────────────────────────────────
|
|
128
|
+
{
|
|
129
|
+
const m = mockWriter({ '100r': 'STORED_PASSWORD' });
|
|
130
|
+
const r = await ensureNsDevice(m.w, { ...O, rotateExisting: true });
|
|
131
|
+
ok(r.rotated === true && r.created === false, 'an existing device is rotated on request');
|
|
132
|
+
ok(r.password !== 'STORED_PASSWORD' && /^[A-Za-z0-9]{20}$/.test(r.password), 'a fresh password is returned');
|
|
133
|
+
ok(m.calls.includes('updateDevice:100r'), 'rotation is a PUT');
|
|
134
|
+
ok(!m.calls.some((c) => c.startsWith('deleteDevice')) && !m.calls.some((c) => c.startsWith('createDevice')), 'rotation never deletes and recreates — other device settings must survive');
|
|
135
|
+
ok(String(m.store.get('100r')?.[SIP_PW_FIELD]) === r.password, 'the stored device now carries the new password');
|
|
136
|
+
}
|
|
137
|
+
{
|
|
138
|
+
const m = mockWriter({ '100r': 'STORED_PASSWORD' });
|
|
139
|
+
m.omitPwOnUpdate(true);
|
|
140
|
+
const r = await ensureNsDevice(m.w, { ...O, rotateExisting: true });
|
|
141
|
+
ok(r.rotated === true && /^[A-Za-z0-9]{20}$/.test(r.password), 'when NS echoes nothing back, the value we set is returned');
|
|
142
|
+
ok(r.password !== 'STORED_PASSWORD', 'and it is definitely not the old one');
|
|
143
|
+
}
|
|
144
|
+
{
|
|
145
|
+
// The case that matters most: a release without the device PUT must not break the caller.
|
|
146
|
+
const m = mockWriter({ '100r': 'STORED_PASSWORD' });
|
|
147
|
+
m.failUpdate(true);
|
|
148
|
+
const r = await ensureNsDevice(m.w, { ...O, rotateExisting: true });
|
|
149
|
+
ok(r.rotated === false, 'a failed rotation is REPORTED, never thrown');
|
|
150
|
+
ok(r.password === 'STORED_PASSWORD', 'and it falls back to the existing password so the client still works');
|
|
151
|
+
ok((r.rotateError ?? '').includes('No Route Found'), 'the failure reason is carried for logging');
|
|
152
|
+
}
|
|
153
|
+
{
|
|
154
|
+
const m = mockWriter({ '100r': 'STORED' });
|
|
155
|
+
const r = await ensureNsDevice(m.w, { ...O, rotateExisting: true, passwordLength: 32 });
|
|
156
|
+
ok(r.password.length === 32, 'passwordLength is honoured');
|
|
157
|
+
}
|
|
158
|
+
{
|
|
159
|
+
const m = mockWriter({ '100r': '' });
|
|
160
|
+
const r = await ensureNsDevice(m.w, O);
|
|
161
|
+
ok(r.password === '' && r.created === false, 'a device with no readable password yields blank rather than throwing');
|
|
162
|
+
}
|
|
163
|
+
{
|
|
164
|
+
// A writer whose getDevices returns a non-array must not crash the caller.
|
|
165
|
+
const m = mockWriter();
|
|
166
|
+
const w2: NsDeviceWriter = { ...m.w, async getDevices() { return null as unknown as Rec[]; } };
|
|
167
|
+
const r = await ensureNsDevice(w2, O);
|
|
168
|
+
ok(r.created === true, 'a non-array device list degrades to "absent" instead of throwing');
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ── the delegating client method ──────────────────────────────────────────────
|
|
172
|
+
{
|
|
173
|
+
const seen: { method: string; url: string }[] = [];
|
|
174
|
+
const fetchImpl = (async (input: unknown, init: { method?: string } = {}) => {
|
|
175
|
+
seen.push({ method: init.method ?? 'GET', url: String(input) });
|
|
176
|
+
// devices list → one device; per-device GET → with password
|
|
177
|
+
return new Response(JSON.stringify(seen.length === 1 ? [{ device: '100r' }] : { device: '100r', [SIP_PW_FIELD]: 'VIA_CLIENT' }), {
|
|
178
|
+
status: 200,
|
|
179
|
+
headers: { 'content-type': 'application/json' },
|
|
180
|
+
});
|
|
181
|
+
}) as unknown as typeof fetch;
|
|
182
|
+
const client = new NsWriteClient({ server: 'api.example.com', token: 'tok', fetchImpl });
|
|
183
|
+
const r = await client.ensureDevice(O);
|
|
184
|
+
ok(r.password === 'VIA_CLIENT' && r.created === false, 'NsWriteClient.ensureDevice delegates and works end to end');
|
|
185
|
+
ok(seen[0]!.url.endsWith('/domains/acme.example.com/users/100/devices'), 'it drives the real device paths');
|
|
186
|
+
ok(seen.every((s) => s.method === 'GET'), 'no write was needed for an existing device');
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
console.log(`\n${pass} passed, ${fail} failed`);
|
|
190
|
+
if (fail > 0) process.exitCode = 1;
|