@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
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Offline test for the `synchronous` capability table — that concrete, URI-encoded paths match the
3
+ * templated operations, that the near-misses (user update, device update) answer false, and that the
4
+ * table itself stays consistent. tsx src/nsSynchronous.selftest.ts
5
+ */
6
+ import { supportsSynchronous, SYNCHRONOUS_OPERATIONS } from './index.js';
7
+
8
+ let pass = 0, fail = 0;
9
+ const ok = (c: boolean, m: string) => { c ? pass++ : fail++; console.log(`${c ? '✓' : '✗ FAIL'} ${m}`); };
10
+
11
+ // ── supported operations ──────────────────────────────────────────────────────
12
+ ok(supportsSynchronous('POST', '/domains'), 'POST /domains (domain create) is supported');
13
+ ok(supportsSynchronous('POST', '/domains/acme.example/users'), 'POST users (user CREATE) is supported');
14
+ ok(supportsSynchronous('POST', '/domains/acme.example/users/100/devices'), 'POST devices (device create) is supported');
15
+ ok(supportsSynchronous('PUT', '/domains/acme.example/sites/HQ'), 'PUT sites is supported (one of the few PUTs)');
16
+ ok(supportsSynchronous('PUT', '/domains/acme.example/users/100/greetings/1'), 'PUT greetings/{index} is supported');
17
+ ok(supportsSynchronous('PUT', '/domains/acme.example/moh/2'), 'PUT domain moh/{index} is supported');
18
+
19
+ // ── the near-misses that motivate an explicit table ───────────────────────────
20
+ ok(!supportsSynchronous('PUT', '/domains/acme.example/users/100'), 'PUT user (UPDATE) is NOT supported — the case that started this');
21
+ ok(!supportsSynchronous('PUT', '/domains/acme.example/users/100/devices/100r'), 'PUT device (update) is NOT supported');
22
+ ok(!supportsSynchronous('DELETE', '/domains/acme.example/users/100'), 'DELETE is never supported');
23
+ ok(!supportsSynchronous('GET', '/domains/acme.example/users'), 'GET is never supported');
24
+ ok(!supportsSynchronous('POST', '/domains/acme.example/phonenumbers'), 'an unlisted POST is not supported');
25
+
26
+ // ── path-shape handling ───────────────────────────────────────────────────────
27
+ ok(supportsSynchronous('post', '/domains/acme.example/users'), 'method is case-insensitive');
28
+ ok(!supportsSynchronous('POST', '/domains/acme.example/users/100'), 'segment COUNT must match — collection vs member');
29
+ ok(!supportsSynchronous('POST', '/domains/acme.example/users/100/devices/extra'), 'a deeper path does not match a shorter template');
30
+ ok(supportsSynchronous('POST', '/domains/acme.example/users?foo=bar'), 'a query string is ignored');
31
+ ok(supportsSynchronous('POST', '/domains/acme.example/users/'), 'a trailing slash is ignored');
32
+
33
+ // An encoded value stays ONE segment, so it cannot masquerade as a deeper path.
34
+ ok(
35
+ supportsSynchronous('POST', `/domains/${encodeURIComponent('a/b.example')}/users`),
36
+ 'an encoded slash inside a segment still matches the single-segment template',
37
+ );
38
+ ok(
39
+ !supportsSynchronous('POST', '/domains/a/b.example/users'),
40
+ 'an UNencoded slash does not match — extra segment, correctly rejected',
41
+ );
42
+
43
+ // ── table integrity ───────────────────────────────────────────────────────────
44
+ ok(SYNCHRONOUS_OPERATIONS.length === 17, `table holds all 17 spec operations (got ${SYNCHRONOUS_OPERATIONS.length})`);
45
+ ok(
46
+ SYNCHRONOUS_OPERATIONS.every((op) => op.method === 'POST' || op.method === 'PUT'),
47
+ 'every entry is a POST or PUT',
48
+ );
49
+ ok(
50
+ SYNCHRONOUS_OPERATIONS.every((op) => op.path.startsWith('/')),
51
+ 'every path is rooted (relative to the /ns-api/v2 base)',
52
+ );
53
+ ok(
54
+ new Set(SYNCHRONOUS_OPERATIONS.map((op) => `${op.method} ${op.path}`)).size === SYNCHRONOUS_OPERATIONS.length,
55
+ 'no duplicate (method, path) entries',
56
+ );
57
+ ok(
58
+ SYNCHRONOUS_OPERATIONS.every((op) => supportsSynchronous(op.method, op.path.replace(/\{[^}]+\}/g, 'x'))),
59
+ 'every table entry matches its own path with the templates filled in',
60
+ );
61
+
62
+ console.log(`\n${pass} passed, ${fail} failed`);
63
+ process.exit(fail ? 1 : 0);
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Which NetSapiens v2 write operations accept the `synchronous` request-body flag.
3
+ *
4
+ * `synchronous: 'yes'` asks the API to complete the write before replying, so the response is
5
+ * **200 with the resulting resource inline** — including server-generated fields a caller cannot
6
+ * otherwise learn without a second read (a new device's SIP registration password is the worked
7
+ * example). Without it, or on an operation that does not support it, the API replies
8
+ * **202 Accepted** with a bare `{code, message}` acknowledgement and applies the write behind the
9
+ * scenes.
10
+ *
11
+ * **It is a per-operation capability, not a global one.** Only the operations listed below declare
12
+ * a `synchronous` property in the v2 OpenAPI specification (core 44.4.10) — 17 of them, almost all
13
+ * creates. Sending the flag to any other endpoint is inert: NetSapiens ignores unrecognized body
14
+ * fields and still answers 202. That is harmless but misleading, because it makes code look as
15
+ * though it has a synchronous guarantee it never had.
16
+ *
17
+ * The most consequential absence is **`PUT /domains/{domain}/users/{user}`** — a user *update*
18
+ * cannot be made synchronous, though a user *create* can. Verified live 2026-08-03: the flag in the
19
+ * body, as `?synchronous=yes`, as `?synchronous=true`, both at once, and omitted entirely all return
20
+ * an identical 202 on that endpoint. Any confirmation of a user update has to come from reading the
21
+ * record back, not from the response.
22
+ *
23
+ * Kept as data rather than folded into each method so both this library's write client and other
24
+ * NetSapiens clients can share one answer instead of drifting apart.
25
+ */
26
+
27
+ /** The HTTP methods any `synchronous`-capable operation uses. */
28
+ export type SynchronousMethod = 'POST' | 'PUT';
29
+
30
+ /** One operation that accepts `synchronous`, as a method plus an OpenAPI-style templated path. */
31
+ export interface SynchronousOperation {
32
+ method: SynchronousMethod;
33
+ /** Templated path, e.g. `/domains/{domain}/users`. A `{...}` segment matches exactly one path segment. */
34
+ path: string;
35
+ }
36
+
37
+ /**
38
+ * Every operation declaring `synchronous` in the v2 spec (core 44.4.10), deduplicated — the
39
+ * specification lists several of these more than once under `#1`…`#4` suffixes for differing
40
+ * request shapes, which are the same HTTP operation.
41
+ *
42
+ * Note the near-misses, which are the whole reason this list is explicit: user and domain **create**
43
+ * are here, user **update** is not; greeting and MOH **update** are here, device update is not.
44
+ */
45
+ export const SYNCHRONOUS_OPERATIONS: readonly SynchronousOperation[] = [
46
+ { method: 'POST', path: '/domains' },
47
+ { method: 'POST', path: '/domains/{domain}/callqueues' },
48
+ { method: 'POST', path: '/domains/{domain}/callqueues/{callqueue}/agents' },
49
+ { method: 'POST', path: '/domains/{domain}/dialplans/{dialplan}/dialrules' },
50
+ { method: 'POST', path: '/domains/{domain}/moh' },
51
+ { method: 'PUT', path: '/domains/{domain}/moh/{index}' },
52
+ { method: 'PUT', path: '/domains/{domain}/sites/{site}' },
53
+ { method: 'POST', path: '/domains/{domain}/timeframes' },
54
+ { method: 'POST', path: '/domains/{domain}/users' },
55
+ { method: 'POST', path: '/domains/{domain}/users/{user}/answerrules' },
56
+ { method: 'POST', path: '/domains/{domain}/users/{user}/calls' },
57
+ { method: 'POST', path: '/domains/{domain}/users/{user}/devices' },
58
+ { method: 'POST', path: '/domains/{domain}/users/{user}/greetings' },
59
+ { method: 'PUT', path: '/domains/{domain}/users/{user}/greetings/{index}' },
60
+ { method: 'POST', path: '/domains/{domain}/users/{user}/moh' },
61
+ { method: 'PUT', path: '/domains/{domain}/users/{user}/moh/{index}' },
62
+ { method: 'POST', path: '/domains/{domain}/users/{user}/timeframes' },
63
+ ] as const;
64
+
65
+ /** Split a path into non-empty segments, ignoring any query string and leading/trailing slashes. */
66
+ const segmentsOf = (path: string): string[] => (path.split('?')[0] ?? '').split('/').filter(Boolean);
67
+
68
+ /** Precomputed segment forms, so a lookup is a comparison rather than a re-parse per call. */
69
+ const TABLE: ReadonlyArray<{ method: SynchronousMethod; segments: readonly string[] }> =
70
+ SYNCHRONOUS_OPERATIONS.map((op) => ({ method: op.method, segments: segmentsOf(op.path) }));
71
+
72
+ /**
73
+ * Does `method path` accept `synchronous`?
74
+ *
75
+ * `path` is a concrete request path relative to the `/ns-api/v2` base, with its dynamic segments
76
+ * already filled in and URI-encoded — exactly what a client passes to `post()`/`put()`. Encoding is
77
+ * what makes the match safe: a value containing a slash arrives as `%2F` and stays one segment, so
78
+ * it cannot masquerade as a deeper path.
79
+ *
80
+ * Unknown paths answer `false`. That is the safe direction: the flag is then omitted, and the caller
81
+ * gets the 202 it would have received anyway — rather than a promise of a 200 that never arrives.
82
+ */
83
+ export function supportsSynchronous(method: string, path: string): boolean {
84
+ const verb = method.toUpperCase();
85
+ if (verb !== 'POST' && verb !== 'PUT') return false;
86
+
87
+ const actual = segmentsOf(path);
88
+ return TABLE.some(
89
+ (op) =>
90
+ op.method === verb &&
91
+ op.segments.length === actual.length &&
92
+ op.segments.every((seg, i) =>
93
+ seg.startsWith('{') && seg.endsWith('}')
94
+ ? true // a template segment matches any single segment
95
+ : seg.toLowerCase() === actual[i]!.toLowerCase(),
96
+ ),
97
+ );
98
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Offline test for NsWriteClient — device create/get/delete against a recording mock fetch (no live
3
+ * creds). Asserts the exact v2 paths, methods, bodies (incl. where synchronous:'yes' is and is NOT
4
+ * injected — see nsSynchronous.ts), auth header, URI
5
+ * encoding, the inline created-device return (with its generated SIP password), error shape, and the
6
+ * bare-server SSRF guard. tsx src/nsWriteClient.selftest.ts
7
+ */
8
+ import { NsWriteClient } from './index.js';
9
+ import { NsApiError } from './nsClient.js';
10
+
11
+ let pass = 0, fail = 0;
12
+ const ok = (c: boolean, m: string) => { c ? pass++ : fail++; console.log(`${c ? '✓' : '✗ FAIL'} ${m}`); };
13
+
14
+ interface Recorded { method: string; url: string; headers: Record<string, string>; body?: any }
15
+ let last: Recorded = { method: '', url: '', headers: {} };
16
+ const mk = (status: number, body: unknown) =>
17
+ (async (input: any, init: any = {}) => {
18
+ last = {
19
+ method: init.method ?? 'GET',
20
+ url: String(input),
21
+ headers: (init.headers ?? {}) as Record<string, string>,
22
+ body: init.body ? JSON.parse(init.body) : undefined,
23
+ };
24
+ return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
25
+ }) as unknown as typeof fetch;
26
+
27
+ const client = (status = 200, body: unknown = {}) =>
28
+ new NsWriteClient({ server: 'api.example.com', token: 'tok', fetchImpl: mk(status, body) });
29
+
30
+ const B = 'https://api.example.com/ns-api/v2';
31
+
32
+ (async () => {
33
+ // createDevice → POST .../devices with { device, synchronous:'yes' }, returns the created device inline.
34
+ const created = await client(200, { device: '100r', 'device-sip-registration-password': 'SEKRET1234567890' })
35
+ .createDevice('acme.example', '100', '100r');
36
+ ok(last.method === 'POST', 'createDevice uses POST');
37
+ ok(last.url === `${B}/domains/acme.example/users/100/devices`, 'createDevice hits the devices collection path');
38
+ ok(last.body?.device === '100r', 'createDevice body carries the device id');
39
+ ok(last.body?.synchronous === 'yes', 'createDevice injects synchronous:yes (200 + inline resource, no 202 lag)');
40
+ ok(last.headers.Authorization === 'Bearer tok', 'createDevice sends bearer auth');
41
+ ok((created as any)['device-sip-registration-password'] === 'SEKRET1234567890', 'createDevice returns the inline device incl. the generated SIP password');
42
+
43
+ // getDevices → GET .../devices (returns an array even for a single object)
44
+ const list = await client(200, { device: '100r' }).getDevices('acme.example', '100');
45
+ ok(last.method === 'GET' && last.url === `${B}/domains/acme.example/users/100/devices`, 'getDevices GETs the collection');
46
+ ok(Array.isArray(list) && (list[0] as any).device === '100r', 'getDevices normalizes a single object to an array');
47
+
48
+ // getDevice → GET .../devices/{device}
49
+ await client(200, { device: '100r' }).getDevice('acme.example', '100', '100r');
50
+ ok(last.method === 'GET' && last.url === `${B}/domains/acme.example/users/100/devices/100r`, 'getDevice GETs the specific device');
51
+
52
+ // deleteDevice → DELETE .../devices/{device}
53
+ await client(200, {}).deleteDevice('acme.example', '100', '100r');
54
+ ok(last.method === 'DELETE' && last.url === `${B}/domains/acme.example/users/100/devices/100r`, 'deleteDevice DELETEs the specific device');
55
+ ok(last.body === undefined, 'deleteDevice sends no body');
56
+
57
+ // Path params are URI-encoded.
58
+ await client(200, []).getDevices('a b.example', '10@0');
59
+ ok(last.url === `${B}/domains/a%20b.example/users/10%400/devices`, 'path params are URI-encoded');
60
+
61
+ // A non-2xx write throws NsApiError carrying status + method.
62
+ let err: any;
63
+ try { await client(403, { message: 'nope' }).createDevice('acme.example', '100', '100r'); } catch (e) { err = e; }
64
+ ok(err instanceof NsApiError && err.status === 403 && err.method === 'POST', 'a non-ok write throws NsApiError with status + method');
65
+
66
+ // Bare-server SSRF guard (reused from the read client).
67
+ let guarded = false;
68
+ try { new NsWriteClient({ server: 'api.example.com@evil.example', token: 't' }); } catch { guarded = true; }
69
+ ok(guarded, 'NsWriteClient rejects a non-bare server (SSRF guard)');
70
+
71
+
72
+ // updateDevice → PUT the specific device path, rotating one field in place.
73
+ await client(200, { device: '100r' }).updateDevice('acme.example', '100', '100r', {
74
+ 'device-sip-registration-password': 'NEWPASSWORD123456',
75
+ });
76
+ ok(last.method === 'PUT', 'updateDevice uses PUT');
77
+ ok(last.url === `${B}/domains/acme.example/users/100/devices/100r`, 'updateDevice hits the specific device path');
78
+ ok(last.body?.['device-sip-registration-password'] === 'NEWPASSWORD123456', 'updateDevice sends the rotated password');
79
+ // PUT .../devices/{device} is NOT one of the operations that accept `synchronous`, so the flag is
80
+ // omitted rather than sent-and-ignored. ensureNsDevice already falls back to the value it sent.
81
+ ok(!('synchronous' in (last.body ?? {})), 'updateDevice does NOT inject synchronous — device update does not accept it');
82
+ ok(!('device' in (last.body ?? {})), 'updateDevice does not resend the device id in the body — it is in the path');
83
+ {
84
+ // The point of PUT over delete+create: nothing else on the device is touched.
85
+ await client(200, {}).updateDevice('acme.example', '100', '100r', { 'device-sip-registration-password': 'X' });
86
+ const keys = Object.keys(last.body ?? {});
87
+ ok(keys.length === 1 && keys[0] === 'device-sip-registration-password', 'only the named field is sent, so unrelated device settings survive');
88
+ }
89
+ {
90
+ let threw = 0;
91
+ try {
92
+ await client(404, { code: 404, message: 'No Route Found' }).updateDevice('acme.example', '100', '100r', { x: 1 });
93
+ } catch (e: any) {
94
+ threw = e.status;
95
+ }
96
+ ok(threw === 404, 'a 404 (endpoint absent on the release) surfaces as NsApiError so a caller can fall back');
97
+ }
98
+ ok(encodeURIComponent('a/b') === 'a%2Fb', 'sanity: path params are encoded by enc()');
99
+ await client(200, {}).updateDevice('acme.example', '10/0', '10/0r', {});
100
+ ok(last.url.includes('10%2F0'), 'updateDevice percent-encodes the user and device path segments');
101
+
102
+ console.log(`\n${pass} passed, ${fail} failed`);
103
+ process.exit(fail ? 1 : 0);
104
+ })();
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Portable NetSapiens API v2 WRITE client — the separate, explicitly-imported write surface the read-only
3
+ * `NsClient` deliberately does not have (a consumer holds `NsClient` precisely to know it cannot write).
4
+ * This realizes the lib's planned split: read and write are two classes; only this one mutates. Node-free
5
+ * (fetch/URL/crypto only), so it runs unchanged in a Cloudflare Worker.
6
+ *
7
+ * Starts with the device methods the portal's Ringotel activation needs (create/get/delete), over a
8
+ * generic post/put/delete core, and is meant to GROW into the full NS write surface (users, DIDs, …) —
9
+ * porting the endpoint/body shapes from the onboarding tool's resource defs as they're needed.
10
+ *
11
+ * POST/PUT inject `synchronous: 'yes'` **only on the operations that accept it** (see
12
+ * {@link supportsSynchronous}), where it makes the API return 200 + the resulting resource inline —
13
+ * with server-generated fields such as a device's `device-sip-registration-password` — instead of a
14
+ * bare 202 acknowledgement. Everywhere else the flag is omitted, because sending it there is inert:
15
+ * NetSapiens ignores it and still answers 202, which previously made this client look as though all
16
+ * of its writes were confirmed when most were not. Shares the read client's SSRF guard and `NsApiError`.
17
+ */
18
+ import type { Rec } from './model.js';
19
+ import { NsApiError, assertBareServer, asArray } from './nsClient.js';
20
+ import { ensureNsDevice, type EnsureNsDeviceOptions, type EnsureNsDeviceResult } from './nsDevice.js';
21
+ import { supportsSynchronous } from './nsSynchronous.js';
22
+
23
+ export interface NsWriteClientConfig {
24
+ /** API host, e.g. "api.example.com". Base URL becomes https://{server}/ns-api/v2. */
25
+ server: string;
26
+ /** Bearer token (an API key with write scope). */
27
+ token: string;
28
+ /** Injectable for tests / non-global fetch. */
29
+ fetchImpl?: typeof fetch;
30
+ }
31
+
32
+ const enc = encodeURIComponent;
33
+
34
+ export class NsWriteClient {
35
+ readonly #baseUrl: string;
36
+ readonly #token: string;
37
+ readonly #fetchImpl: typeof fetch;
38
+
39
+ constructor(cfg: NsWriteClientConfig) {
40
+ this.#baseUrl = `https://${assertBareServer(cfg.server)}/ns-api/v2`;
41
+ this.#token = cfg.token;
42
+ this.#fetchImpl = cfg.fetchImpl ?? fetch;
43
+ }
44
+
45
+ // ── generic verbs (the growth surface) ──────────────────────────────────────
46
+ get<T = unknown>(path: string, query?: Record<string, string | number>): Promise<T> {
47
+ return this.#request<T>('GET', path, undefined, query);
48
+ }
49
+ /**
50
+ * POST. On an operation that accepts it, `synchronous:'yes'` is injected → 200 + the created
51
+ * resource inline; otherwise the flag is omitted and the API answers 202 Accepted.
52
+ */
53
+ post<T = unknown>(path: string, body: Rec): Promise<T> {
54
+ return this.#request<T>('POST', path, this.#withSynchronous('POST', path, body));
55
+ }
56
+ /**
57
+ * PUT. Same rule as {@link post} — and note most updates do NOT accept the flag, so their
58
+ * response is a 202 acknowledgement with no resource body. Confirm those by reading back.
59
+ */
60
+ put<T = unknown>(path: string, body: Rec): Promise<T> {
61
+ return this.#request<T>('PUT', path, this.#withSynchronous('PUT', path, body));
62
+ }
63
+
64
+ /** Add `synchronous:'yes'` only where the API declares support. An explicit caller value wins. */
65
+ #withSynchronous(method: string, path: string, body: Rec): Rec {
66
+ if (!supportsSynchronous(method, path)) return body;
67
+ return { synchronous: 'yes', ...body };
68
+ }
69
+ delete<T = unknown>(path: string): Promise<T> {
70
+ return this.#request<T>('DELETE', path);
71
+ }
72
+
73
+ // ── typed device helpers ────────────────────────────────────────────────────
74
+ /** List a user's devices (normalized to an array). */
75
+ getDevices(domain: string, user: string): Promise<Rec[]> {
76
+ return this.get<unknown>(`/domains/${enc(domain)}/users/${enc(user)}/devices`).then(asArray);
77
+ }
78
+ /** Read one device (e.g. to fetch its `device-sip-registration-password`). */
79
+ getDevice(domain: string, user: string, device: string): Promise<Rec> {
80
+ return this.get<Rec>(`/domains/${enc(domain)}/users/${enc(user)}/devices/${enc(device)}`);
81
+ }
82
+ /**
83
+ * Create a device (softphone when named `<ext><suffix>`, e.g. `100r`). NS auto-generates the SIP
84
+ * password when unset; with `synchronous:'yes'` it comes back inline in the response. `extra` allows
85
+ * optional fields (e.g. an emergency caller-id).
86
+ */
87
+ createDevice(domain: string, user: string, device: string, extra: Rec = {}): Promise<Rec> {
88
+ return this.post<Rec>(`/domains/${enc(domain)}/users/${enc(user)}/devices`, { device, ...extra });
89
+ }
90
+ /**
91
+ * Update a device in place.
92
+ *
93
+ * `PUT .../devices/{device}` does **not** accept `synchronous`, so this returns a 202
94
+ * acknowledgement, not the updated device. Callers must not depend on the response echoing their
95
+ * change back — {@link ensureNsDevice} falls back to the value it just sent for exactly this reason.
96
+ *
97
+ * The reason this exists rather than callers using `put()`: rotating
98
+ * `device-sip-registration-password` must **not** be done by deleting and recreating the device, which
99
+ * would discard everything else on it — emergency caller id, the provisioning MAC/model link, SRTP and
100
+ * transport settings. A PUT changes the one field and preserves the rest.
101
+ */
102
+ updateDevice(domain: string, user: string, device: string, changes: Rec): Promise<Rec> {
103
+ return this.put<Rec>(`/domains/${enc(domain)}/users/${enc(user)}/devices/${enc(device)}`, changes);
104
+ }
105
+
106
+ /** Delete a device. */
107
+ deleteDevice(domain: string, user: string, device: string): Promise<Rec> {
108
+ return this.delete<Rec>(`/domains/${enc(domain)}/users/${enc(user)}/devices/${enc(device)}`);
109
+ }
110
+
111
+ /**
112
+ * Convenience wrapper over {@link ensureNsDevice} — ensure a device exists and return its SIP password,
113
+ * optionally rotating it. See that function for the semantics, and for why rotation matters.
114
+ *
115
+ * Deliberately a **one-line delegation, not an implementation**. Every other method on this class is
116
+ * exactly one HTTP request; this one is several with branching, so the logic lives in a standalone
117
+ * function that composes over any writer (a consumer may have its own client) and that consumers can mock as
118
+ * a plain 4-method object instead of stubbing a whole client. This method exists only so the capability
119
+ * is discoverable from the client you already hold.
120
+ */
121
+ ensureDevice(opts: EnsureNsDeviceOptions): Promise<EnsureNsDeviceResult> {
122
+ return ensureNsDevice(this, opts);
123
+ }
124
+
125
+ async #request<T>(method: string, path: string, body?: Rec, query?: Record<string, string | number>): Promise<T> {
126
+ const url = new URL(this.#baseUrl + path);
127
+ for (const [k, v] of Object.entries(query ?? {})) url.searchParams.set(k, String(v));
128
+
129
+ // Call via a local, NOT `this.#fetchImpl(...)`: invoking the global fetch as a method of this
130
+ // instance throws "Illegal invocation" in workerd (the global fetch requires a global `this`).
131
+ const doFetch = this.#fetchImpl;
132
+ const res = await doFetch(url.toString(), {
133
+ method,
134
+ headers: {
135
+ Authorization: `Bearer ${this.#token}`,
136
+ Accept: 'application/json',
137
+ ...(body ? { 'Content-Type': 'application/json' } : {}),
138
+ },
139
+ ...(body ? { body: JSON.stringify(body) } : {}),
140
+ });
141
+ const text = await res.text();
142
+ let parsed: unknown = text;
143
+ if (text) {
144
+ try {
145
+ parsed = JSON.parse(text);
146
+ } catch {
147
+ /* some endpoints return empty / plain bodies */
148
+ }
149
+ }
150
+ if (!res.ok) {
151
+ const detail = (typeof parsed === 'object' && parsed !== null ? JSON.stringify(parsed) : String(parsed)).slice(0, 500);
152
+ const hint = res.status === 401 ? ' (token expired/invalid or domain out of scope)' : res.status === 403 ? ' (token lacks permission)' : '';
153
+ throw new NsApiError(`${method} ${path} → ${res.status}${hint}: ${detail}`, res.status, path, parsed, method);
154
+ }
155
+ return parsed as T;
156
+ }
157
+ }
package/src/policy.ts ADDED
@@ -0,0 +1,123 @@
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
+ * - a scope MINUS a few accounts: { scopes:['Reseller'], notUsers:['105@acme'] }
17
+ * - only when a given operator is masked in: { operators: ['admin@0000.12345.service'] }
18
+ * - only while (not) masking: { masking: true } / { masking: false }
19
+ *
20
+ * `notUsers` is the one NEGATIVE condition, and it exists because the positive form cannot express
21
+ * "everyone at this scope except these accounts" without enumerating the complement — a list that is
22
+ * wrong the moment an account is added, and wrong silently. It ANDs with the rest of the rule like
23
+ * every other condition, so it narrows the rule it sits on and nothing else.
24
+ *
25
+ * Matching considers the EFFECTIVE principal (scope/domain/id = the masked user when masking);
26
+ * `operators` matches the mask_chain operator, so you can gate on the real reseller behind a mask.
27
+ *
28
+ * Portable (no Node). Pure functions.
29
+ */
30
+ import type { Principal } from './principal.js';
31
+
32
+ export interface PolicyRule {
33
+ /** Effective scope must be one of these (case-insensitive). */
34
+ scopes?: string[];
35
+ /** Effective domain must be one of these. Use '*' to match any domain. */
36
+ domains?: string[];
37
+ /** Effective identity (`user@domain`) must be one of these. */
38
+ users?: string[];
39
+ /**
40
+ * Accounts this rule EXCLUDES — a denial that ANDs with the rest of the rule, narrowing it.
41
+ *
42
+ * ⚠️ It is NOT a condition on its own. A rule carrying only `notUsers` never matches, deliberately:
43
+ * "everybody except X" as a standalone rule would be an allow-all wearing an exception, and this
44
+ * engine's whole shape is that a rule must say who it admits before it says who it doesn't. Pair it
45
+ * with `scopes`/`domains`/`users` — see `hasCondition` in {@link ruleMatches}.
46
+ *
47
+ * ⚠️ **It matches the effective identity OR the operator behind a mask** — the one place this engine
48
+ * is deliberately asymmetric. Every positive condition sees the EFFECTIVE principal, so a grant
49
+ * follows the role currently being performed; masquerading is full impersonation and is meant to be.
50
+ * A denial is not about a role. It names a person, and a denial that evaporates the moment that
51
+ * person masquerades into someone else is not a denial — it is a suggestion. So this one condition
52
+ * asks both "who is acting" and "who is behind this", and refuses if either is named.
53
+ */
54
+ notUsers?: string[];
55
+ /** Requires masking, AND the operator's `user@domain` (mask_chain) is one of these. */
56
+ operators?: string[];
57
+ /** Require the masking state to equal this (true = masked, false = not masked). */
58
+ masking?: boolean;
59
+ /** Optional human note (documentation / audit; ignored by matching). */
60
+ description?: string;
61
+ }
62
+
63
+ /** ANY rule matching grants the feature; `[]` denies. */
64
+ export type Policy = PolicyRule[];
65
+
66
+ /** Named features → their policy. Unknown feature ⇒ deny (see `can`). */
67
+ export type FeaturePolicies = Record<string, Policy>;
68
+
69
+ const lc = (s: string) => s.trim().toLowerCase();
70
+ const inList = (value: string, list: string[]): boolean => {
71
+ const v = lc(value);
72
+ return list.some((x) => lc(x) === v);
73
+ };
74
+
75
+ /** Collapse the interchangeable Super User spellings a NetSapiens core may emit ("Super User",
76
+ * "superuser", "super-user") to one canonical token. None of these is a valid OTHER scope, so this
77
+ * only ever unifies synonyms — a policy written with any one spelling matches a token carrying
78
+ * another, closing a fail-closed lockout where e.g. `user_scope: "superuser"` was denied at a rule
79
+ * listing `"Super User"`. */
80
+ const canonScope = (s: string): string => {
81
+ const v = lc(s);
82
+ return v === 'superuser' || v === 'super-user' || v === 'super user' ? 'super user' : v;
83
+ };
84
+ const scopeInList = (value: string, list: string[]): boolean => {
85
+ const v = canonScope(value);
86
+ return list.some((x) => canonScope(x) === v);
87
+ };
88
+
89
+ /** Does the principal satisfy every condition in this single rule? */
90
+ export function ruleMatches(p: Principal, rule: PolicyRule): boolean {
91
+ // A rule with NO matchable condition (e.g. `{}` or only `description`) is NOT allow-all — that would
92
+ // silently grant everyone. Require at least one real condition; a conditionless rule never matches.
93
+ // `notUsers` is deliberately absent from this list: it subtracts, so counting it would let
94
+ // `{notUsers:[…]}` mean "everyone else", which is the allow-all this guard exists to prevent.
95
+ const hasCondition =
96
+ rule.scopes !== undefined ||
97
+ rule.domains !== undefined ||
98
+ rule.users !== undefined ||
99
+ rule.operators !== undefined ||
100
+ rule.masking !== undefined;
101
+ if (!hasCondition) return false;
102
+ if (rule.scopes && !scopeInList(p.scope, rule.scopes)) return false;
103
+ if (rule.domains && !(rule.domains.includes('*') || inList(p.domain, rule.domains))) return false;
104
+ if (rule.users && !inList(p.id, rule.users)) return false;
105
+ // Both identities, unlike every positive condition above — see the field's own note. A grant follows
106
+ // the role being performed; a denial follows the person performing it, through a mask.
107
+ if (rule.notUsers && (inList(p.id, rule.notUsers) || (p.operator && inList(p.operator.id, rule.notUsers)))) return false;
108
+ if (rule.operators) {
109
+ if (!p.operator || !inList(p.operator.id, rule.operators)) return false;
110
+ }
111
+ if (rule.masking !== undefined && p.masking !== rule.masking) return false;
112
+ return true;
113
+ }
114
+
115
+ /** Allowed if any rule matches. Empty/absent policy ⇒ deny (fail closed). */
116
+ export function isAllowed(p: Principal, policy: Policy | undefined): boolean {
117
+ return !!policy && policy.some((rule) => ruleMatches(p, rule));
118
+ }
119
+
120
+ /** Check a named feature against a registry. Unknown feature ⇒ deny (fail closed). */
121
+ export function can(p: Principal, feature: string, policies: FeaturePolicies): boolean {
122
+ return isAllowed(p, policies[feature]);
123
+ }