@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.
@@ -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
- /** POST with `synchronous:'yes'` injected → 200 + created resource inline. */
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, { synchronous: 'yes', ...body });
23
+ return this.#request('POST', path, this.#withSynchronous('POST', path, body));
19
24
  }
20
- /** PUT with `synchronous:'yes'` injected. */
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, { synchronous: 'yes', ...body });
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
- target = b.node(`aaannounce_${ext}_${o.dest}`, 'prompt', `🔊 ${script ? `“${trim(script)}”` : 'Play message'}`, undefined, undefined, script.length > GREET_MAX ? script : undefined).id;
813
- else if (/^prompt/i.test(o.app))
814
- target = o.dest === promptId ? b.node(`aarepeat_${ext}`, 'prompt', '🔁 Repeat greeting', 're-plays the menu').id : b.node(`aaprompt_${ext}_${o.dest}`, 'prompt', '🔊 Play prompt', o.dest || undefined).id;
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.5",
4
- "description": "Portable, Node-free NetSapiens toolkit: read-only API client, JWT (ns_t) validation, and a snapshot -> FlowGraph -> Mermaid call-flow resolver/renderer. Runs unchanged in a Cloudflare Worker, Node, or the browser.",
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": { "url": "https://github.com/dszp/netsapiens-lib/issues" },
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": { "access": "public" },
19
- "engines": { "node": ">=20" },
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 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.",
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",