@dszp/netsapiens-lib 0.1.5 → 0.1.7
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 +26 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/mermaid.js +0 -0
- package/dist/nsClient.d.ts +9 -5
- package/dist/nsClient.js +9 -5
- package/dist/nsDevice.d.ts +89 -0
- package/dist/nsDevice.js +101 -0
- package/dist/nsSubscriptions.d.ts +268 -0
- package/dist/nsSubscriptions.js +444 -0
- package/dist/nsSynchronous.d.ts +54 -0
- package/dist/nsSynchronous.js +78 -0
- package/dist/nsWriteClient.d.ts +39 -5
- package/dist/nsWriteClient.js +46 -4
- package/dist/resolver.js +69 -5
- package/package.json +17 -8
package/dist/nsWriteClient.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { NsApiError, assertBareServer, asArray } from './nsClient.js';
|
|
2
|
+
import { ensureNsDevice } from './nsDevice.js';
|
|
3
|
+
import { supportsSynchronous } from './nsSynchronous.js';
|
|
2
4
|
const enc = encodeURIComponent;
|
|
3
5
|
export class NsWriteClient {
|
|
4
6
|
#baseUrl;
|
|
@@ -13,13 +15,25 @@ export class NsWriteClient {
|
|
|
13
15
|
get(path, query) {
|
|
14
16
|
return this.#request('GET', path, undefined, query);
|
|
15
17
|
}
|
|
16
|
-
/**
|
|
18
|
+
/**
|
|
19
|
+
* POST. On an operation that accepts it, `synchronous:'yes'` is injected → 200 + the created
|
|
20
|
+
* resource inline; otherwise the flag is omitted and the API answers 202 Accepted.
|
|
21
|
+
*/
|
|
17
22
|
post(path, body) {
|
|
18
|
-
return this.#request('POST', path,
|
|
23
|
+
return this.#request('POST', path, this.#withSynchronous('POST', path, body));
|
|
19
24
|
}
|
|
20
|
-
/**
|
|
25
|
+
/**
|
|
26
|
+
* PUT. Same rule as {@link post} — and note most updates do NOT accept the flag, so their
|
|
27
|
+
* response is a 202 acknowledgement with no resource body. Confirm those by reading back.
|
|
28
|
+
*/
|
|
21
29
|
put(path, body) {
|
|
22
|
-
return this.#request('PUT', path,
|
|
30
|
+
return this.#request('PUT', path, this.#withSynchronous('PUT', path, body));
|
|
31
|
+
}
|
|
32
|
+
/** Add `synchronous:'yes'` only where the API declares support. An explicit caller value wins. */
|
|
33
|
+
#withSynchronous(method, path, body) {
|
|
34
|
+
if (!supportsSynchronous(method, path))
|
|
35
|
+
return body;
|
|
36
|
+
return { synchronous: 'yes', ...body };
|
|
23
37
|
}
|
|
24
38
|
delete(path) {
|
|
25
39
|
return this.#request('DELETE', path);
|
|
@@ -41,10 +55,38 @@ export class NsWriteClient {
|
|
|
41
55
|
createDevice(domain, user, device, extra = {}) {
|
|
42
56
|
return this.post(`/domains/${enc(domain)}/users/${enc(user)}/devices`, { device, ...extra });
|
|
43
57
|
}
|
|
58
|
+
/**
|
|
59
|
+
* Update a device in place.
|
|
60
|
+
*
|
|
61
|
+
* `PUT .../devices/{device}` does **not** accept `synchronous`, so this returns a 202
|
|
62
|
+
* acknowledgement, not the updated device. Callers must not depend on the response echoing their
|
|
63
|
+
* change back — {@link ensureNsDevice} falls back to the value it just sent for exactly this reason.
|
|
64
|
+
*
|
|
65
|
+
* The reason this exists rather than callers using `put()`: rotating
|
|
66
|
+
* `device-sip-registration-password` must **not** be done by deleting and recreating the device, which
|
|
67
|
+
* would discard everything else on it — emergency caller id, the provisioning MAC/model link, SRTP and
|
|
68
|
+
* transport settings. A PUT changes the one field and preserves the rest.
|
|
69
|
+
*/
|
|
70
|
+
updateDevice(domain, user, device, changes) {
|
|
71
|
+
return this.put(`/domains/${enc(domain)}/users/${enc(user)}/devices/${enc(device)}`, changes);
|
|
72
|
+
}
|
|
44
73
|
/** Delete a device. */
|
|
45
74
|
deleteDevice(domain, user, device) {
|
|
46
75
|
return this.delete(`/domains/${enc(domain)}/users/${enc(user)}/devices/${enc(device)}`);
|
|
47
76
|
}
|
|
77
|
+
/**
|
|
78
|
+
* Convenience wrapper over {@link ensureNsDevice} — ensure a device exists and return its SIP password,
|
|
79
|
+
* optionally rotating it. See that function for the semantics, and for why rotation matters.
|
|
80
|
+
*
|
|
81
|
+
* Deliberately a **one-line delegation, not an implementation**. Every other method on this class is
|
|
82
|
+
* exactly one HTTP request; this one is several with branching, so the logic lives in a standalone
|
|
83
|
+
* function that composes over any writer (a consumer may have its own client) and that consumers can mock as
|
|
84
|
+
* a plain 4-method object instead of stubbing a whole client. This method exists only so the capability
|
|
85
|
+
* is discoverable from the client you already hold.
|
|
86
|
+
*/
|
|
87
|
+
ensureDevice(opts) {
|
|
88
|
+
return ensureNsDevice(this, opts);
|
|
89
|
+
}
|
|
48
90
|
async #request(method, path, body, query) {
|
|
49
91
|
const url = new URL(this.#baseUrl + path);
|
|
50
92
|
for (const [k, v] of Object.entries(query ?? {}))
|
package/dist/resolver.js
CHANGED
|
@@ -764,12 +764,25 @@ function aaApp(app, dest, idx, b) {
|
|
|
764
764
|
* * → unassigned key ("Unknown Input")
|
|
765
765
|
* Default → no-key timeout
|
|
766
766
|
* Apps: Announce → play-message; Prompt→own prompt id → repeat greeting; else via aaApp().
|
|
767
|
+
*
|
|
768
|
+
* A `Prompt` option pointing at a DIFFERENT prompt id that has its own `Prompt_<id>.` rule family in
|
|
769
|
+
* this same dialplan is a SECOND-LEVEL MENU — the portal's "Add Tier" on a keypress. Recurse into it
|
|
770
|
+
* rather than rendering a dead-end "Play prompt <id>" leaf. The tier's prompt id exists ONLY in the
|
|
771
|
+
* dialplan; the /autoattendants detail nests the tier as `option-N.auto-attendant` with no id at all,
|
|
772
|
+
* so the two are joined by the keypress digit. Portal-created tiers are one level deep, but the
|
|
773
|
+
* dialplan grammar is not, so this recurses without a depth bound; claim()/enter() make a back-link
|
|
774
|
+
* to an ancestor tier draw as a "loops back" reference leaf instead of recursing forever.
|
|
775
|
+
*
|
|
767
776
|
* `detailTier` (the /autoattendants option-N structure, when present) enriches each key with its
|
|
768
777
|
* CNAM prefix + play-message script/audio, which the dialplan lacks.
|
|
769
778
|
*/
|
|
770
|
-
function renderAaFromDialrules(rules, startingPrompt, fromId, ext, idx, b, detailTier) {
|
|
779
|
+
function renderAaFromDialrules(rules, startingPrompt, fromId, ext, idx, b, detailTier, tiers = new Map()) {
|
|
771
780
|
const prefix = `${startingPrompt}.`;
|
|
772
781
|
const promptId = startingPrompt.replace(/^Prompt_/i, ''); // e.g. "912201"
|
|
782
|
+
// prompt id -> the node whose menu it is. A deeper tier keyed back to an earlier prompt ("9 for the
|
|
783
|
+
// main menu") is a jump to THAT node, not a second copy of it; Builder.edge turns it into a
|
|
784
|
+
// loops-back leaf when the target is an ancestor still being expanded.
|
|
785
|
+
tiers.set(promptId, fromId);
|
|
773
786
|
let dialByExt = false;
|
|
774
787
|
const opts = [];
|
|
775
788
|
let noKey = null;
|
|
@@ -808,14 +821,65 @@ function renderAaFromDialrules(rules, startingPrompt, fromId, ext, idx, b, detai
|
|
|
808
821
|
const script = opt ? s(opt.audio?.['file-script-text']) : '';
|
|
809
822
|
const label = o.label + (cnam && cnam !== '[*]' ? ` · ${cnam}` : '');
|
|
810
823
|
let target;
|
|
811
|
-
if (/^announce/i.test(o.app))
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
824
|
+
if (/^announce/i.test(o.app)) {
|
|
825
|
+
const n = b.node(`aaannounce_${ext}_${o.dest}`, 'prompt', `🔊 ${script ? `“${trim(script)}”` : 'Play message'}`, undefined, undefined, script.length > GREET_MAX ? script : undefined);
|
|
826
|
+
if (n.isNew)
|
|
827
|
+
announceReturn(o.dest, n.id); // only once — two keys may play the same message
|
|
828
|
+
target = n.id;
|
|
829
|
+
}
|
|
830
|
+
else if (/^prompt/i.test(o.app)) {
|
|
831
|
+
if (o.dest === promptId)
|
|
832
|
+
target = b.node(`aarepeat_${ext}_${promptId}`, 'prompt', '🔁 Repeat greeting', 're-plays the menu').id;
|
|
833
|
+
else if (tiers.has(o.dest))
|
|
834
|
+
target = tiers.get(o.dest);
|
|
835
|
+
else if (hasTier(o.dest))
|
|
836
|
+
return renderSubTier(o, opt, label);
|
|
837
|
+
else
|
|
838
|
+
target = b.node(`aaprompt_${ext}_${o.dest}`, 'prompt', '🔊 Play prompt', o.dest || undefined).id;
|
|
839
|
+
}
|
|
815
840
|
else
|
|
816
841
|
target = aaApp(o.app, o.dest, idx, b);
|
|
817
842
|
b.edge(fromId, target, 'menu', label);
|
|
818
843
|
};
|
|
844
|
+
/**
|
|
845
|
+
* Where the call goes once a played message finishes: the dialplan's `Announce_<id>.Done` rule.
|
|
846
|
+
* It is almost always `Prompt <this menu>` — i.e. the caller hears the message, then the menu again.
|
|
847
|
+
* Without this edge a message is drawn as a dead end, which is the one thing it never is.
|
|
848
|
+
* `Prompt <this menu>` reuses the shared "Repeat greeting" node (same node the no-key default lands
|
|
849
|
+
* on — identical behavior, so it should be identical on the diagram); a jump to another tier points
|
|
850
|
+
* at that tier's node. Anything else routes normally. An unrecognized prompt id gets no edge rather
|
|
851
|
+
* than an invented node.
|
|
852
|
+
*/
|
|
853
|
+
const announceReturn = (announceId, fromAnnounce) => {
|
|
854
|
+
const done = rules.find((r) => s(r['dial-rule-matching-to-uri']) === `Announce_${announceId}.Done`);
|
|
855
|
+
if (!done)
|
|
856
|
+
return;
|
|
857
|
+
const app = s(done['dial-rule-application']);
|
|
858
|
+
const dest = s(done['dial-rule-translation-destination-user']);
|
|
859
|
+
if (/^announce/i.test(app))
|
|
860
|
+
return; // message → message chain: not seen in the wild, don't guess
|
|
861
|
+
let back;
|
|
862
|
+
if (/^prompt/i.test(app))
|
|
863
|
+
back = dest === promptId ? b.node(`aarepeat_${ext}_${promptId}`, 'prompt', '🔁 Repeat greeting', 're-plays the menu').id : tiers.get(dest);
|
|
864
|
+
else
|
|
865
|
+
back = aaApp(app, dest, idx, b);
|
|
866
|
+
if (back)
|
|
867
|
+
b.edge(fromAnnounce, back, 'menu', 'then');
|
|
868
|
+
};
|
|
869
|
+
/** Does `dest` have its own rule family here — i.e. is it a nested menu tier, not a bare prompt? */
|
|
870
|
+
const hasTier = (dest) => !!dest && rules.some((r) => s(r['dial-rule-matching-to-uri']).startsWith(`Prompt_${dest}.`));
|
|
871
|
+
/** A second-level menu: its own node, then the same grammar again from that tier's prompt. */
|
|
872
|
+
const renderSubTier = (o, opt, label) => {
|
|
873
|
+
// Keyed by prompt id, not keypress: two keys may open the same tier, and that should be one node.
|
|
874
|
+
const greet = s(opt?.audio?.['file-script-text']);
|
|
875
|
+
const subId = b.node(`aa_${ext}_p${o.dest}`, 'attendant', `🔀 Submenu${o.dtmf ? ` (press ${o.dtmf})` : ''}`, greet ? `“${trim(greet)}”` : 'nested menu', undefined, greet.length > GREET_MAX ? greet : undefined).id;
|
|
876
|
+
b.edge(fromId, subId, 'menu', label);
|
|
877
|
+
if (!b.claim(subId))
|
|
878
|
+
return;
|
|
879
|
+
b.enter(subId);
|
|
880
|
+
renderAaFromDialrules(rules, `Prompt_${o.dest}`, subId, ext, idx, b, opt?.['auto-attendant'], tiers);
|
|
881
|
+
b.leave(null);
|
|
882
|
+
};
|
|
819
883
|
for (const o of opts.sort((a, c) => a.sort.localeCompare(c.sort)))
|
|
820
884
|
route(o);
|
|
821
885
|
if (noKey)
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dszp/netsapiens-lib",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "Portable, Node-free NetSapiens toolkit: read
|
|
3
|
+
"version": "0.1.7",
|
|
4
|
+
"description": "Portable, Node-free NetSapiens toolkit: split read/write API clients, JWT (ns_t) validation, and a snapshot -> FlowGraph -> Mermaid call-flow resolver/renderer. Runs unchanged in a Cloudflare Worker, Node, or the browser.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": {
|
|
@@ -12,11 +12,17 @@
|
|
|
12
12
|
"type": "git",
|
|
13
13
|
"url": "git+https://github.com/dszp/netsapiens-lib.git"
|
|
14
14
|
},
|
|
15
|
-
"bugs": {
|
|
15
|
+
"bugs": {
|
|
16
|
+
"url": "https://github.com/dszp/netsapiens-lib/issues"
|
|
17
|
+
},
|
|
16
18
|
"homepage": "https://github.com/dszp/netsapiens-lib#readme",
|
|
17
19
|
"//publishConfig": "No `provenance: true` here on purpose: it would force provenance on EVERY publish, including the manual first one that a brand-new package requires (npm can only configure trusted publishing on a package that already exists). Provenance comes from the workflow's explicit `npm publish --provenance` in CI.",
|
|
18
|
-
"publishConfig": {
|
|
19
|
-
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"access": "public"
|
|
22
|
+
},
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=20"
|
|
25
|
+
},
|
|
20
26
|
"keywords": [
|
|
21
27
|
"netsapiens",
|
|
22
28
|
"voip",
|
|
@@ -49,16 +55,19 @@
|
|
|
49
55
|
"build:watch": "tsc -p tsconfig.json --watch",
|
|
50
56
|
"//prepublishOnly": "Publish-only build with sourcemaps OFF. The `files` globs exclude dist/**/*.map on purpose (they point at src/, which does not ship), but tsc still emits a //# sourceMappingURL pointer into every .js/.d.ts -- so consumers' devtools 404 chasing maps that were never published. Dropping the pointer at publish time is what the exclusion always meant. A normal `pnpm build` keeps maps for link: consumers.",
|
|
51
57
|
"prepublishOnly": "tsc -p tsconfig.json --sourceMap false --declarationMap false",
|
|
52
|
-
"//test": "The offline suite
|
|
53
|
-
"test": "pnpm run test:jwt && pnpm run test:principal && pnpm run test:resolver && pnpm run test:raster && pnpm run test:nswrite && pnpm run test:eligibility && pnpm run test:nsauth",
|
|
58
|
+
"//test": "The offline suite \u2014 green on a fresh clone with no credentials and no fixtures. test:ns is NOT included: it needs a domain snapshot that (correctly) isn't in the repo.",
|
|
59
|
+
"test": "pnpm run test:jwt && pnpm run test:principal && pnpm run test:resolver && pnpm run test:raster && pnpm run test:nswrite && pnpm run test:nssync && pnpm run test:eligibility && pnpm run test:nsauth && pnpm test:nssubs && pnpm test:nsdevice",
|
|
54
60
|
"test:jwt": "tsx src/jwt.selftest.ts",
|
|
55
61
|
"test:ns": "tsx src/nsClient.selftest.ts",
|
|
56
62
|
"test:nswrite": "tsx src/nsWriteClient.selftest.ts",
|
|
63
|
+
"test:nssync": "tsx src/nsSynchronous.selftest.ts",
|
|
57
64
|
"test:nsauth": "tsx src/nsAuthClient.selftest.ts",
|
|
58
65
|
"test:principal": "tsx src/principal.selftest.ts",
|
|
59
66
|
"test:resolver": "tsx src/resolver.selftest.ts",
|
|
60
67
|
"test:raster": "tsx src/raster.selftest.ts",
|
|
61
|
-
"test:eligibility": "tsx src/eligibility.selftest.ts"
|
|
68
|
+
"test:eligibility": "tsx src/eligibility.selftest.ts",
|
|
69
|
+
"test:nssubs": "tsx src/nsSubscriptions.selftest.ts",
|
|
70
|
+
"test:nsdevice": "tsx src/nsDevice.selftest.ts"
|
|
62
71
|
},
|
|
63
72
|
"devDependencies": {
|
|
64
73
|
"tsx": "^4.22.4",
|