@dszp/netsapiens-lib 0.1.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.
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Portable NetSapiens API read client — the seed of the eventual "NS API for Worker/Node"
3
+ * library (see CLAUDE.md → North star). Ported from NetSapiens-Onboarding-Backup (`src/api/client.ts`
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 tool never writes to NetSapiens — only GET is exposed.
11
+ */
12
+ export class NsApiError extends Error {
13
+ status;
14
+ path;
15
+ body;
16
+ constructor(message, status, path, body) {
17
+ super(message);
18
+ this.status = status;
19
+ this.path = path;
20
+ this.body = body;
21
+ this.name = 'NsApiError';
22
+ }
23
+ }
24
+ /**
25
+ * NS API v2 client — READ-ONLY BY DESIGN (the central gate for this whole tool).
26
+ *
27
+ * The ONLY method is `get()`, which hardcodes `method: 'GET'`. There is deliberately no
28
+ * post/put/delete/patch — the viewer must never mutate NetSapiens. Keep it that way: do not add a
29
+ * mutating method here. Any write capability must be a separate, explicitly-reviewed client, not a
30
+ * quiet addition to this one. This is the single choke point every NS call in the Worker flows through.
31
+ */
32
+ export class NsClient {
33
+ baseUrl;
34
+ token;
35
+ fetchImpl;
36
+ constructor(cfg) {
37
+ this.baseUrl = `https://${cfg.server.replace(/\/+$/, '')}/ns-api/v2`;
38
+ this.token = cfg.token;
39
+ this.fetchImpl = cfg.fetchImpl ?? fetch;
40
+ }
41
+ async get(path, query) {
42
+ const url = new URL(this.baseUrl + path);
43
+ for (const [k, v] of Object.entries(query ?? {}))
44
+ url.searchParams.set(k, String(v));
45
+ // Call via a local, NOT `this.fetchImpl(...)`: invoking the global fetch as a method of this
46
+ // instance throws "Illegal invocation" in workerd (the global fetch requires a global `this`).
47
+ const doFetch = this.fetchImpl;
48
+ const res = await doFetch(url.toString(), {
49
+ method: 'GET',
50
+ headers: { Authorization: `Bearer ${this.token}`, Accept: 'application/json' },
51
+ });
52
+ const text = await res.text();
53
+ let parsed = text;
54
+ if (text) {
55
+ try {
56
+ parsed = JSON.parse(text);
57
+ }
58
+ catch {
59
+ /* some endpoints return empty / plain bodies */
60
+ }
61
+ }
62
+ if (!res.ok) {
63
+ const detail = typeof parsed === 'object' && parsed !== null ? JSON.stringify(parsed) : String(parsed).slice(0, 500);
64
+ const hint = res.status === 401 ? ' (token expired/invalid or domain out of scope)' : res.status === 403 ? ' (token lacks permission)' : '';
65
+ throw new NsApiError(`GET ${path} → ${res.status}${hint}: ${detail}`, res.status, path, parsed);
66
+ }
67
+ return parsed;
68
+ }
69
+ }
70
+ /** Normalize a v2 response to an array of records (endpoints return an array or a bare object). */
71
+ export function asArray(res) {
72
+ if (Array.isArray(res))
73
+ return res;
74
+ if (res && typeof res === 'object')
75
+ return [res];
76
+ return [];
77
+ }
78
+ /** Map with bounded concurrency — keeps per-user/queue/AA fan-out from hammering the API. */
79
+ async function mapLimit(items, limit, fn) {
80
+ let i = 0;
81
+ const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
82
+ while (i < items.length) {
83
+ const idx = i++;
84
+ await fn(items[idx], idx);
85
+ }
86
+ });
87
+ await Promise.all(workers);
88
+ }
89
+ const enc = encodeURIComponent;
90
+ /** List domains the token can read (for the internal viewer's domain browser). `locked` is set only
91
+ * for domains flagged `is-domain-locked: yes` (config-locked in NetSapiens). */
92
+ export async function listDomains(client) {
93
+ const recs = asArray(await client.get('/domains'));
94
+ return recs
95
+ .map((r) => ({
96
+ domain: String(r.domain ?? ''),
97
+ ...(r.description ? { description: String(r.description) } : {}),
98
+ ...(r['is-domain-locked'] === 'yes' ? { locked: true } : {}),
99
+ }))
100
+ .filter((d) => d.domain);
101
+ }
102
+ /**
103
+ * Read a live domain into the `Snapshot` shape the resolver consumes. Routing subset only:
104
+ * domain, timeframes, users, callqueues, phonenumbers, autoattendants, per-user answerrules,
105
+ * per-queue agents, and (by default) per-AA menu detail.
106
+ *
107
+ * A per-item read that fails is treated as "absent" (empty) so one missing child never aborts the
108
+ * whole flow — the resolver tolerates gaps. A failing top-level read (e.g. 401) DOES throw.
109
+ */
110
+ export async function fetchDomainSnapshot(client, domain, opts = {}) {
111
+ const conc = opts.concurrency ?? 5;
112
+ const base = `/domains/${enc(domain)}`;
113
+ const soft = async (p) => {
114
+ try {
115
+ return asArray(await client.get(p));
116
+ }
117
+ catch (err) {
118
+ if (err instanceof NsApiError && err.status === 404)
119
+ return [];
120
+ throw err;
121
+ }
122
+ };
123
+ const domainRec = asArray(await client.get(base))[0] ?? { domain };
124
+ const [timeframes, users, callqueues, phonenumbers, autoattendants] = await Promise.all([
125
+ soft(`${base}/timeframes`),
126
+ soft(`${base}/users`),
127
+ soft(`${base}/callqueues`),
128
+ soft(`${base}/phonenumbers`),
129
+ soft(`${base}/autoattendants`),
130
+ ]);
131
+ // Shallow mode stops here — enough for listEntities() (the picker).
132
+ if (opts.shallow) {
133
+ let answerrulesByUser;
134
+ if (opts.includeDidDestRules) {
135
+ const dests = [...new Set(phonenumbers.map((p) => String(p['dial-rule-translation-destination-user'] ?? '')).filter(Boolean))];
136
+ answerrulesByUser = {};
137
+ await mapLimit(dests, conc, async (u) => {
138
+ const rules = await soft(`${base}/users/${enc(u)}/answerrules`).catch(() => []);
139
+ if (rules.length)
140
+ answerrulesByUser[u] = rules;
141
+ });
142
+ }
143
+ return { meta: { domain }, domain: domainRec, timeframes, users, callqueues, phonenumbers, autoattendants, ...(answerrulesByUser ? { answerrulesByUser } : {}) };
144
+ }
145
+ const answerrulesByUser = {};
146
+ await mapLimit(users, conc, async (u) => {
147
+ const ext = String(u.user ?? '');
148
+ if (!ext)
149
+ return;
150
+ const rules = await soft(`${base}/users/${enc(ext)}/answerrules`).catch(() => []);
151
+ if (rules.length)
152
+ answerrulesByUser[ext] = rules;
153
+ });
154
+ const agentsByQueue = {};
155
+ await mapLimit(callqueues, conc, async (q) => {
156
+ const ext = String(q.callqueue ?? '');
157
+ if (!ext)
158
+ return;
159
+ const ags = await soft(`${base}/callqueues/${enc(ext)}/agents`).catch(() => []);
160
+ if (ags.length)
161
+ agentsByQueue[ext] = ags;
162
+ });
163
+ // One list row per (user, prompt); an AA may have several (multi-timeframe). Collect ALL detail
164
+ // records per user (array) so the resolver can flag multi-prompt deviations, not just last-wins.
165
+ // Also fetch each AA's OWN dialplan dialrules ({domain}_{ext}) — the authoritative menu/default
166
+ // routing the /autoattendants detail omits (no-key/star/option). See CLAUDE.md → API notes.
167
+ let attendantDetailsByUser;
168
+ let attendantDialrulesByExt;
169
+ if (opts.includeAttendantMenus ?? true) {
170
+ const rows = autoattendants.map((aa) => ({ ext: String(aa.user ?? ''), prompt: String(aa['starting-prompt'] ?? '') })).filter((r) => r.ext && r.prompt);
171
+ attendantDetailsByUser = {};
172
+ for (const r of rows)
173
+ attendantDetailsByUser[r.ext] ??= []; // pre-init (avoid concurrent-init race)
174
+ await mapLimit(rows, conc, async (r) => {
175
+ const detail = (await soft(`${base}/users/${enc(r.ext)}/autoattendants/${enc(r.prompt)}`).catch(() => []))[0];
176
+ if (detail)
177
+ attendantDetailsByUser[r.ext].push(detail);
178
+ });
179
+ attendantDialrulesByExt = {};
180
+ await mapLimit([...new Set(rows.map((r) => r.ext))], conc, async (ext) => {
181
+ const dr = await soft(`${base}/dialplans/${enc(`${domain}_${ext}`)}/dialrules`).catch(() => []);
182
+ if (dr.length)
183
+ attendantDialrulesByExt[ext] = dr;
184
+ });
185
+ }
186
+ let dialrulesByPlan;
187
+ if (opts.includeDialrules) {
188
+ dialrulesByPlan = { [domain]: await soft(`${base}/dialplans/${enc(domain)}/dialrules`).catch(() => []) };
189
+ }
190
+ return {
191
+ meta: { domain },
192
+ domain: domainRec,
193
+ timeframes,
194
+ users,
195
+ callqueues,
196
+ phonenumbers,
197
+ autoattendants,
198
+ answerrulesByUser,
199
+ agentsByQueue,
200
+ ...(attendantDetailsByUser && Object.keys(attendantDetailsByUser).length ? { attendantDetailsByUser } : {}),
201
+ ...(attendantDialrulesByExt && Object.keys(attendantDialrulesByExt).length ? { attendantDialrulesByExt } : {}),
202
+ ...(dialrulesByPlan ? { dialrulesByPlan } : {}),
203
+ };
204
+ }
205
+ //# sourceMappingURL=nsClient.js.map
@@ -0,0 +1,49 @@
1
+ /**
2
+ * A small, declarative allow-list policy engine over `Principal` — the extensible knob for gating
3
+ * features by who's asking. Designed so new gates are one object, not new code.
4
+ *
5
+ * A `Policy` is a list of `PolicyRule`s; a principal is granted the feature if **any** rule matches
6
+ * (default DENY — an empty policy or an unknown feature denies). Within a rule, every specified
7
+ * condition must hold (AND); an omitted condition is a wildcard. All string comparisons are
8
+ * case-insensitive.
9
+ *
10
+ * Conditions, and the shapes they're meant to express:
11
+ * - by scope, per-domain: { scopes: ['Office Manager'] } // + domain-locked elsewhere
12
+ * - one reseller vs all resellers: { scopes:['Reseller'], users:['admin@0000.12345.service'] } vs { scopes:['Reseller'] }
13
+ * - all users in some domains: { domains: ['acme','acme42'] }
14
+ * - …optionally with scopes: { domains:['acme'], scopes:['Office Manager','Basic User'] }
15
+ * - specific users: { users: ['100@acme','101@acme'] }
16
+ * - only when a given operator is masked in: { operators: ['admin@0000.12345.service'] }
17
+ * - only while (not) masking: { masking: true } / { masking: false }
18
+ *
19
+ * Matching considers the EFFECTIVE principal (scope/domain/id = the masked user when masking);
20
+ * `operators` matches the mask_chain operator, so you can gate on the real reseller behind a mask.
21
+ *
22
+ * Portable (no Node). Pure functions.
23
+ */
24
+ import type { Principal } from './principal.js';
25
+ export interface PolicyRule {
26
+ /** Effective scope must be one of these (case-insensitive). */
27
+ scopes?: string[];
28
+ /** Effective domain must be one of these. Use '*' to match any domain. */
29
+ domains?: string[];
30
+ /** Effective identity (`user@domain`) must be one of these. */
31
+ users?: string[];
32
+ /** Requires masking, AND the operator's `user@domain` (mask_chain) is one of these. */
33
+ operators?: string[];
34
+ /** Require the masking state to equal this (true = masked, false = not masked). */
35
+ masking?: boolean;
36
+ /** Optional human note (documentation / audit; ignored by matching). */
37
+ description?: string;
38
+ }
39
+ /** ANY rule matching grants the feature; `[]` denies. */
40
+ export type Policy = PolicyRule[];
41
+ /** Named features → their policy. Unknown feature ⇒ deny (see `can`). */
42
+ export type FeaturePolicies = Record<string, Policy>;
43
+ /** Does the principal satisfy every condition in this single rule? */
44
+ export declare function ruleMatches(p: Principal, rule: PolicyRule): boolean;
45
+ /** Allowed if any rule matches. Empty/absent policy ⇒ deny (fail closed). */
46
+ export declare function isAllowed(p: Principal, policy: Policy | undefined): boolean;
47
+ /** Check a named feature against a registry. Unknown feature ⇒ deny (fail closed). */
48
+ export declare function can(p: Principal, feature: string, policies: FeaturePolicies): boolean;
49
+ //# sourceMappingURL=policy.d.ts.map
package/dist/policy.js ADDED
@@ -0,0 +1,39 @@
1
+ const lc = (s) => s.trim().toLowerCase();
2
+ const inList = (value, list) => {
3
+ const v = lc(value);
4
+ return list.some((x) => lc(x) === v);
5
+ };
6
+ /** Does the principal satisfy every condition in this single rule? */
7
+ export function ruleMatches(p, rule) {
8
+ // A rule with NO matchable condition (e.g. `{}` or only `description`) is NOT allow-all — that would
9
+ // silently grant everyone. Require at least one real condition; a conditionless rule never matches.
10
+ const hasCondition = rule.scopes !== undefined ||
11
+ rule.domains !== undefined ||
12
+ rule.users !== undefined ||
13
+ rule.operators !== undefined ||
14
+ rule.masking !== undefined;
15
+ if (!hasCondition)
16
+ return false;
17
+ if (rule.scopes && !inList(p.scope, rule.scopes))
18
+ return false;
19
+ if (rule.domains && !(rule.domains.includes('*') || inList(p.domain, rule.domains)))
20
+ return false;
21
+ if (rule.users && !inList(p.id, rule.users))
22
+ return false;
23
+ if (rule.operators) {
24
+ if (!p.operator || !inList(p.operator.id, rule.operators))
25
+ return false;
26
+ }
27
+ if (rule.masking !== undefined && p.masking !== rule.masking)
28
+ return false;
29
+ return true;
30
+ }
31
+ /** Allowed if any rule matches. Empty/absent policy ⇒ deny (fail closed). */
32
+ export function isAllowed(p, policy) {
33
+ return !!policy && policy.some((rule) => ruleMatches(p, rule));
34
+ }
35
+ /** Check a named feature against a registry. Unknown feature ⇒ deny (fail closed). */
36
+ export function can(p, feature, policies) {
37
+ return isAllowed(p, policies[feature]);
38
+ }
39
+ //# sourceMappingURL=policy.js.map
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Normalize an ns_t JWT's claims into a `Principal` — the identity + role the portal authorizes
3
+ * against. Handles NetSapiens **masking** (impersonation): when a reseller/admin is masked-in as a
4
+ * domain user, the token's `domain`/`user`/`scope`/`sub` describe the MASKED user, while `mask_chain`
5
+ * names the real operator. `Principal` keeps both, plus a `masking` flag.
6
+ *
7
+ * Two things the caller must keep straight (see policy.ts):
8
+ * - **Auth scope** (what data is reachable) follows the EFFECTIVE identity: a masked token is
9
+ * NS-scoped to the masked user's domain; only an un-masked reseller/super-user reads cross-domain.
10
+ * - **Role gating** may consider the OPERATOR (mask_chain) too — e.g. "allow my reseller while
11
+ * developing" — since the effective scope reads "Office Manager" while masked.
12
+ *
13
+ * Portable (no Node). Pure functions.
14
+ */
15
+ import type { JwtContext } from './jwt.js';
16
+ /** Known NetSapiens user scopes (the string is open — unknown scopes pass through as-is). */
17
+ export type Scope = 'Super User' | 'Reseller' | 'Office Manager' | 'Site Manager' | 'Call Center Supervisor' | 'Call Center Agent' | 'Basic User' | (string & {});
18
+ /** The real operator behind a mask (parsed from `mask_chain`). */
19
+ export interface Operator {
20
+ /** `user@domain` (verbatim mask_chain, lowercased id below). */
21
+ raw: string;
22
+ user: string;
23
+ domain: string;
24
+ /** Lowercased `user@domain` for comparisons. */
25
+ id: string;
26
+ }
27
+ export interface Principal {
28
+ /** Effective domain — the masked user's when masking; the token is NS-scoped to it. */
29
+ domain: string;
30
+ /** Effective user / extension. */
31
+ user: string;
32
+ /** Effective identity `user@domain`, lowercased (from sub, or user+domain). */
33
+ id: string;
34
+ /** Effective user_scope (the masked user's scope when masking). */
35
+ scope: string;
36
+ email?: string;
37
+ displayName?: string;
38
+ territory?: string;
39
+ /** True when a mask is in effect (mask_chain present). */
40
+ masking: boolean;
41
+ /** The real operator behind the mask, or null when not masking. */
42
+ operator: Operator | null;
43
+ }
44
+ /** Reseller / super-user — the only scopes that legitimately read cross-domain. */
45
+ export declare function isResellerScope(scope: string | undefined): boolean;
46
+ /** Domain-admin-or-higher (office manager / site manager / supervisor / reseller / super user). */
47
+ export declare function isAdminScope(scope: string | undefined): boolean;
48
+ /** Parse a `mask_chain` value ("user@domain") into an Operator, or null if empty/malformed. */
49
+ export declare function parseOperator(maskChain: string | undefined): Operator | null;
50
+ /** Build a Principal from decoded ns_t context (a JwtContext / JwtVerdict — both carry the claims). */
51
+ export declare function toPrincipal(ctx: JwtContext): Principal;
52
+ //# sourceMappingURL=principal.d.ts.map
@@ -0,0 +1,45 @@
1
+ const lc = (s) => (s ?? '').trim().toLowerCase();
2
+ /** Scopes that can read/act across the whole reseller/fleet (cross-domain). */
3
+ const RESELLER_SCOPES = new Set(['reseller', 'super user', 'superuser', 'super-user']);
4
+ /** Scopes with domain-admin authority (a superset that includes reseller-level). */
5
+ const ADMIN_SCOPES = new Set([...RESELLER_SCOPES, 'office manager', 'site manager', 'call center supervisor']);
6
+ /** Reseller / super-user — the only scopes that legitimately read cross-domain. */
7
+ export function isResellerScope(scope) {
8
+ return RESELLER_SCOPES.has(lc(scope));
9
+ }
10
+ /** Domain-admin-or-higher (office manager / site manager / supervisor / reseller / super user). */
11
+ export function isAdminScope(scope) {
12
+ return ADMIN_SCOPES.has(lc(scope));
13
+ }
14
+ /** Parse a `mask_chain` value ("user@domain") into an Operator, or null if empty/malformed. */
15
+ export function parseOperator(maskChain) {
16
+ const raw = (maskChain ?? '').trim();
17
+ if (!raw || !raw.includes('@'))
18
+ return null;
19
+ const at = raw.lastIndexOf('@');
20
+ const user = raw.slice(0, at);
21
+ const domain = raw.slice(at + 1);
22
+ if (!user || !domain)
23
+ return null;
24
+ return { raw, user, domain, id: `${user}@${domain}`.toLowerCase() };
25
+ }
26
+ /** Build a Principal from decoded ns_t context (a JwtContext / JwtVerdict — both carry the claims). */
27
+ export function toPrincipal(ctx) {
28
+ const domain = (ctx.domain ?? '').trim();
29
+ const user = (ctx.user ?? '').trim();
30
+ const sub = (ctx.sub ?? '').trim();
31
+ const id = (sub.includes('@') ? sub : user && domain ? `${user}@${domain}` : sub || user).toLowerCase();
32
+ const operator = parseOperator(ctx.maskChain);
33
+ return {
34
+ domain,
35
+ user,
36
+ id,
37
+ scope: (ctx.scope ?? '').trim(),
38
+ ...(ctx.email ? { email: ctx.email } : {}),
39
+ ...(ctx.displayName ? { displayName: ctx.displayName } : {}),
40
+ ...(ctx.territory ? { territory: ctx.territory } : {}),
41
+ masking: operator != null,
42
+ operator,
43
+ };
44
+ }
45
+ //# sourceMappingURL=principal.js.map
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Browser PNG rasterizer for call-flow diagrams — Node-free, Web-APIs-only. The library never holds
3
+ * an SVG (Mermaid renders it client-side), so this is a BROWSER helper: `resolveSvgSize` is a pure,
4
+ * testable size reader, and `rasterizerScript()` returns injectable JS defining `svgToPngBlob`.
5
+ * A host page (dia today; the lib's gallery HTML later) injects the script and calls the global.
6
+ */
7
+ /** Intrinsic pixel size of an SVG string: explicit px width/height, else viewBox, else a default. */
8
+ export declare function resolveSvgSize(svgString: string): {
9
+ width: number;
10
+ height: number;
11
+ };
12
+ /**
13
+ * Injectable browser JS (a function declaration) defining `svgToPngBlob(svgEl, { scale, background })`.
14
+ * A host injects this string into an inline <script> (no bundler needed) and calls the function.
15
+ * Rasterizes a LIVE, already-rendered <svg>: clone → force explicit px size (from the same algorithm
16
+ * as resolveSvgSize) → data-URL into an Image → draw onto a scaled canvas (optional opaque background)
17
+ * → PNG Blob. Scale is clamped so the larger dimension stays ≤ 8192px (browser canvas caps). CSP-safe
18
+ * (no eval/new Function; uses a data: image URL — ensure any host CSP allows `img-src data:`).
19
+ */
20
+ export declare function rasterizerScript(): string;
21
+ //# sourceMappingURL=raster.d.ts.map
package/dist/raster.js ADDED
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Browser PNG rasterizer for call-flow diagrams — Node-free, Web-APIs-only. The library never holds
3
+ * an SVG (Mermaid renders it client-side), so this is a BROWSER helper: `resolveSvgSize` is a pure,
4
+ * testable size reader, and `rasterizerScript()` returns injectable JS defining `svgToPngBlob`.
5
+ * A host page (dia today; the lib's gallery HTML later) injects the script and calls the global.
6
+ */
7
+ /** Intrinsic pixel size of an SVG string: explicit px width/height, else viewBox, else a default. */
8
+ export function resolveSvgSize(svgString) {
9
+ const DEFAULT = { width: 800, height: 600 };
10
+ const attr = (name) => {
11
+ const m = new RegExp(`\\b${name}\\s*=\\s*["']([^"']*)["']`).exec(svgString);
12
+ return m ? m[1] : undefined;
13
+ };
14
+ const px = (s) => {
15
+ if (!s || s.includes('%'))
16
+ return null; // reject "100%" etc.
17
+ const n = parseFloat(s); // "120px" → 120
18
+ return Number.isFinite(n) && n > 0 ? n : null;
19
+ };
20
+ const w = px(attr('width'));
21
+ const h = px(attr('height'));
22
+ if (w && h)
23
+ return { width: w, height: h };
24
+ const vb = attr('viewBox');
25
+ if (vb) {
26
+ const p = vb.trim().split(/[\s,]+/).map(Number);
27
+ if (p.length === 4 && p[2] > 0 && p[3] > 0)
28
+ return { width: w ?? p[2], height: h ?? p[3] };
29
+ }
30
+ return { width: w ?? DEFAULT.width, height: h ?? DEFAULT.height };
31
+ }
32
+ /**
33
+ * Injectable browser JS (a function declaration) defining `svgToPngBlob(svgEl, { scale, background })`.
34
+ * A host injects this string into an inline <script> (no bundler needed) and calls the function.
35
+ * Rasterizes a LIVE, already-rendered <svg>: clone → force explicit px size (from the same algorithm
36
+ * as resolveSvgSize) → data-URL into an Image → draw onto a scaled canvas (optional opaque background)
37
+ * → PNG Blob. Scale is clamped so the larger dimension stays ≤ 8192px (browser canvas caps). CSP-safe
38
+ * (no eval/new Function; uses a data: image URL — ensure any host CSP allows `img-src data:`).
39
+ */
40
+ export function rasterizerScript() {
41
+ return `
42
+ // size reader mirroring resolveSvgSize (viewBox is authoritative for Mermaid). Top-level so it is
43
+ // unit-testable outside a browser (see raster.selftest.ts) — the DOM path below is browser-only.
44
+ function __svgSize(str) {
45
+ function attr(name){ var m = new RegExp('\\\\b'+name+'\\\\s*=\\\\s*["\\']([^"\\']*)["\\']').exec(str); return m ? m[1] : undefined; }
46
+ function px(s){ if(!s || s.indexOf('%')>=0) return null; var n=parseFloat(s); return (isFinite(n)&&n>0)?n:null; }
47
+ var w=px(attr('width')), h=px(attr('height'));
48
+ if(w&&h) return {width:w,height:h};
49
+ var vb=attr('viewBox');
50
+ if(vb){ var p=vb.trim().split(new RegExp('[\\\\s,]+')).map(Number); if(p.length===4&&p[2]>0&&p[3]>0) return {width:(w||p[2]),height:(h||p[3])}; }
51
+ return {width:(w||800),height:(h||600)};
52
+ }
53
+ async function svgToPngBlob(svg, opts) {
54
+ opts = opts || {};
55
+ var scale = opts.scale || 2;
56
+ var background = (opts.background == null) ? null : opts.background;
57
+ var clone = svg.cloneNode(true);
58
+ if(!clone.getAttribute('xmlns')) clone.setAttribute('xmlns','http://www.w3.org/2000/svg');
59
+ var size = __svgSize(new XMLSerializer().serializeToString(clone));
60
+ var W = size.width, H = size.height;
61
+ clone.setAttribute('width', W);
62
+ clone.setAttribute('height', H);
63
+ var str = new XMLSerializer().serializeToString(clone);
64
+ // --- clamp effective scale to the canvas cap ---
65
+ var eff = scale, MAX = 8192, big = Math.max(W, H);
66
+ if (big * eff > MAX) eff = MAX / big;
67
+ // --- SVG → Image → canvas → PNG ---
68
+ var url = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(str);
69
+ var img = new Image();
70
+ await new Promise(function(res, rej){ img.onload = res; img.onerror = function(){ rej(new Error('SVG failed to load for rasterization')); }; img.src = url; });
71
+ var canvas = document.createElement('canvas');
72
+ canvas.width = Math.max(1, Math.round(W * eff));
73
+ canvas.height = Math.max(1, Math.round(H * eff));
74
+ var ctx = canvas.getContext('2d');
75
+ if (background) { ctx.fillStyle = background; ctx.fillRect(0, 0, canvas.width, canvas.height); }
76
+ ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
77
+ return await new Promise(function(res, rej){ canvas.toBlob(function(b){ b ? res(b) : rej(new Error('canvas.toBlob returned null')); }, 'image/png'); });
78
+ }
79
+ `.trim();
80
+ }
81
+ //# sourceMappingURL=raster.js.map
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Flow resolver — deterministic walker over a NetSapiens domain snapshot that emits a
3
+ * normalized FlowGraph. Rule-set driven; no Node-only deps (runtime-portable).
4
+ *
5
+ * NetSapiens routing model, as decoded from real snapshots:
6
+ * - An inbound DID (phonenumber) has a `dial-rule-application` (to-user[-residential],
7
+ * to-callqueue, to-voicemail, to-connection) + a destination extension/host.
8
+ * - Every "extension" is a user record. Some users are virtual: a queue (in callqueues),
9
+ * an auto attendant (in autoattendants), a time-of-day router (TOD), or a shared mailbox.
10
+ * - A user's routing is its answer rules, one per time-frame, ordered by ordinal-priority:
11
+ * forward-always (unconditional) | simultaneous-ring/<OwnDevices> then
12
+ * forward-no-answer (RNA timeout) | forward-on-busy | forward-when-unregistered.
13
+ * - Answer-rule / dial-rule params speak an alias language:
14
+ * <did>_callqueue_<ext>, queue_<ext> -> queue
15
+ * <did>_attendant_<ext> -> auto attendant
16
+ * user_<ext> -> user
17
+ * vmail_<ext> / <did>_voicemail_<ext> -> voicemail box
18
+ * <did>_pstn_<num> / bare 10-11 digits -> external / off-net
19
+ * Prompt_<id> -> played greeting
20
+ * <OwnDevices> -> ring the user's registered devices
21
+ * - A queue dispatches to its agents (dispatch-type) then overflows via its own answer
22
+ * rule (forward-no-answer -> if-unanswered, forward-on-busy -> if-unavailable).
23
+ * - Auto-attendant keypress menus are NOT in the backup (inventory-only) — flagged as a gap.
24
+ */
25
+ import type { FlowGraph, Snapshot } from './model.js';
26
+ export interface EntityRef {
27
+ kind: 'did' | 'user' | 'queue' | 'attendant';
28
+ ref: string;
29
+ }
30
+ export declare function resolveFlow(snap: Snapshot, entity: EntityRef): FlowGraph;
31
+ /** DID action categories for the entity picker, in display order. */
32
+ export declare const DID_ACTIONS: Record<string, {
33
+ order: number;
34
+ label: string;
35
+ }>;
36
+ export declare function listEntities(snap: Snapshot): {
37
+ dids: {
38
+ ref: string;
39
+ label: string;
40
+ desc: string;
41
+ action: string;
42
+ actionLabel: string;
43
+ order: number;
44
+ }[];
45
+ users: {
46
+ ref: string;
47
+ label: string;
48
+ }[];
49
+ queues: {
50
+ ref: string;
51
+ label: string;
52
+ }[];
53
+ attendants: {
54
+ ref: string;
55
+ label: string;
56
+ }[];
57
+ };
58
+ //# sourceMappingURL=resolver.d.ts.map