@dszp/netsapiens-lib 0.1.6 → 0.1.8

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 CHANGED
@@ -72,6 +72,32 @@ than by convention. Writes live in a **separate** class — `NsWriteClient`, a s
72
72
  surface (device provisioning) — never as new methods on `NsClient`. So a consumer that holds the read
73
73
  client still cannot write; that guarantee holds by construction, not by convention.
74
74
 
75
+ ### Which writes actually confirm: `synchronous`
76
+
77
+ `synchronous: 'yes'` asks the API to finish the write before replying, so you get **200 with the
78
+ resulting resource inline** — including server-generated fields you could not otherwise learn without a
79
+ second read, a new device's SIP registration password being the worked example. Without it you get
80
+ **202 Accepted** and a bare `{code, message}`.
81
+
82
+ It is a **per-operation capability, not a global one**: exactly 17 operations declare it in the v2
83
+ specification (core 44.4.10), and almost all of them are creates. Sending it anywhere else is inert —
84
+ NetSapiens ignores unrecognized body fields and still answers 202 — so code that adds it everywhere
85
+ merely *looks* as though its writes are confirmed.
86
+
87
+ `NsWriteClient` therefore injects the flag only where it is accepted, and exports the table so other
88
+ NetSapiens clients can share one answer instead of each keeping a copy that drifts:
89
+
90
+ ```ts
91
+ import { supportsSynchronous, SYNCHRONOUS_OPERATIONS } from '@dszp/netsapiens-lib';
92
+
93
+ supportsSynchronous('POST', '/domains/acme.example/users'); // true — user CREATE
94
+ supportsSynchronous('PUT', '/domains/acme.example/users/100'); // false — user UPDATE
95
+ ```
96
+
97
+ `path` is the concrete request path relative to `/ns-api/v2`, dynamic segments already URI-encoded.
98
+ The most consequential absence is that **user update** is not on the list even though user create is:
99
+ there is no response that can confirm a user update, so confirm it by reading the record back.
100
+
75
101
  ### Configuration binds to *your* deployment
76
102
 
77
103
  Two values are required and have no defaults, on purpose — a default would silently bind you to
package/dist/index.d.ts CHANGED
@@ -17,6 +17,7 @@ export { renderGalleryHtml, renderFlowCards, renderFlowCard, mermaidBootstrap, f
17
17
  export { resolveSvgSize, rasterizerScript } from './raster.js';
18
18
  export { NsClient, NsApiError, assertBareServer, fetchDomainSnapshot, listDomains, asArray, type NsClientConfig, type FetchSnapshotOptions } from './nsClient.js';
19
19
  export { NsWriteClient, type NsWriteClientConfig } from './nsWriteClient.js';
20
+ export { supportsSynchronous, SYNCHRONOUS_OPERATIONS, type SynchronousMethod, type SynchronousOperation, } from './nsSynchronous.js';
20
21
  export { ensureNsDevice, generateSipPassword, SIP_PW_FIELD, type NsDeviceWriter, type EnsureNsDeviceOptions, type EnsureNsDeviceResult, } from './nsDevice.js';
21
22
  export { NsAuthClient, NsAuthError, type NsAuthClientConfig, type NsTokenResponse } from './nsAuthClient.js';
22
23
  export { NsSubscriptionsClient, NsSubscriptionConflictError, SUBSCRIPTION_MODELS, isSubscriptionModel, nsDatetime, parseNsDatetime, subscriptionFromWire, createInputToWire, updateInputToWire, planSubscriptions, type SubscriptionModel, type SubscriptionStatus, type Subscription, type CreateSubscriptionInput, type UpdateSubscriptionInput, type NsSubscriptionsClientConfig, type DesiredSubscription, type SubscriptionAction, type PlanSubscriptionsOptions, } from './nsSubscriptions.js';
package/dist/index.js CHANGED
@@ -16,6 +16,7 @@ export { renderGalleryHtml, renderFlowCards, renderFlowCard, mermaidBootstrap, f
16
16
  export { resolveSvgSize, rasterizerScript } from './raster.js';
17
17
  export { NsClient, NsApiError, assertBareServer, fetchDomainSnapshot, listDomains, asArray } from './nsClient.js';
18
18
  export { NsWriteClient } from './nsWriteClient.js';
19
+ export { supportsSynchronous, SYNCHRONOUS_OPERATIONS, } from './nsSynchronous.js';
19
20
  export { ensureNsDevice, generateSipPassword, SIP_PW_FIELD, } from './nsDevice.js';
20
21
  export { NsAuthClient, NsAuthError } from './nsAuthClient.js';
21
22
  export { NsSubscriptionsClient, NsSubscriptionConflictError, SUBSCRIPTION_MODELS, isSubscriptionModel, nsDatetime, parseNsDatetime, subscriptionFromWire, createInputToWire, updateInputToWire, planSubscriptions, } from './nsSubscriptions.js';
package/dist/mermaid.js CHANGED
Binary file
@@ -7,7 +7,7 @@
7
7
  * `fetchDomainSnapshot()` assembles the same `Snapshot` shape the resolver already consumes, so a
8
8
  * live domain flows end-to-end: domain + token → Snapshot → resolveFlow → FlowGraph.
9
9
  *
10
- * This tool never writes to NetSapiens — only GET is exposed.
10
+ * This client never writes — only GET is exposed. Writes live in the separate `NsWriteClient`.
11
11
  */
12
12
  import type { Rec, Snapshot } from './model.js';
13
13
  export declare class NsApiError extends Error {
@@ -29,12 +29,16 @@ export interface NsClientConfig {
29
29
  fetchImpl?: typeof fetch;
30
30
  }
31
31
  /**
32
- * NS API v2 client — READ-ONLY BY DESIGN (the central gate for this whole tool).
32
+ * NS API v2 client — READ-ONLY BY DESIGN.
33
33
  *
34
34
  * The ONLY method is `get()`, which hardcodes `method: 'GET'`. There is deliberately no
35
- * post/put/delete/patch — the viewer must never mutate NetSapiens. Keep it that way: do not add a
36
- * mutating method here. Any write capability must be a separate, explicitly-reviewed client, not a
37
- * quiet addition to this one. This is the single choke point every NS call in the Worker flows through.
35
+ * post/put/delete/patch. Keep it that way: **do not add a mutating method here.** The point of this
36
+ * class is that holding one is proof you cannot write — a guarantee by construction, not convention,
37
+ * and adding a single mutating method destroys it for every consumer at once.
38
+ *
39
+ * Writes are a separate, explicitly-imported class: `NsWriteClient` (see `nsWriteClient.ts`). New
40
+ * write capability extends that one, never this one. `NsSubscriptionsClient` is a third client for
41
+ * the same reason — its `DELETE` semantics differ from `NsWriteClient`'s.
38
42
  */
39
43
  /**
40
44
  * Reject a `server` that isn't a bare host or `host:port`. A caller that derives `server` from
package/dist/nsClient.js CHANGED
@@ -7,7 +7,7 @@
7
7
  * `fetchDomainSnapshot()` assembles the same `Snapshot` shape the resolver already consumes, so a
8
8
  * live domain flows end-to-end: domain + token → Snapshot → resolveFlow → FlowGraph.
9
9
  *
10
- * This tool never writes to NetSapiens — only GET is exposed.
10
+ * This client never writes — only GET is exposed. Writes live in the separate `NsWriteClient`.
11
11
  */
12
12
  export class NsApiError extends Error {
13
13
  status;
@@ -26,12 +26,16 @@ export class NsApiError extends Error {
26
26
  }
27
27
  }
28
28
  /**
29
- * NS API v2 client — READ-ONLY BY DESIGN (the central gate for this whole tool).
29
+ * NS API v2 client — READ-ONLY BY DESIGN.
30
30
  *
31
31
  * The ONLY method is `get()`, which hardcodes `method: 'GET'`. There is deliberately no
32
- * post/put/delete/patch — the viewer must never mutate NetSapiens. Keep it that way: do not add a
33
- * mutating method here. Any write capability must be a separate, explicitly-reviewed client, not a
34
- * quiet addition to this one. This is the single choke point every NS call in the Worker flows through.
32
+ * post/put/delete/patch. Keep it that way: **do not add a mutating method here.** The point of this
33
+ * class is that holding one is proof you cannot write — a guarantee by construction, not convention,
34
+ * and adding a single mutating method destroys it for every consumer at once.
35
+ *
36
+ * Writes are a separate, explicitly-imported class: `NsWriteClient` (see `nsWriteClient.ts`). New
37
+ * write capability extends that one, never this one. `NsSubscriptionsClient` is a third client for
38
+ * the same reason — its `DELETE` semantics differ from `NsWriteClient`'s.
35
39
  */
36
40
  /**
37
41
  * Reject a `server` that isn't a bare host or `host:port`. A caller that derives `server` from
@@ -3,10 +3,12 @@
3
3
  * pure reconciliation planner.
4
4
  *
5
5
  * This is its own class on purpose. `NsClient` is read-only by charter (a consumer holds one precisely to
6
- * know it cannot write), and `NsWriteClient` is unsuitable here for two concrete reasons: it injects
7
- * `synchronous: 'yes'` into every POST/PUT, and its `delete()` sends no body — while
8
- * `DELETE /subscriptions/{id}` *requires* a body (`subscription_id`, plus `domain` for scopes below Super
9
- * User). Node-free (fetch/URL/crypto only), so it runs unchanged in a Cloudflare Worker.
6
+ * know it cannot write), and `NsWriteClient`'s `delete()` sends no body while
7
+ * `DELETE /subscriptions/{id}` *requires* one (`subscription_id`, plus `domain` for scopes below Super
8
+ * User). (A second reason applied until 0.1.7: `NsWriteClient` injected `synchronous: 'yes'` into *every*
9
+ * POST/PUT, which no `/subscriptions` operation accepts. It now injects only where the API declares
10
+ * support, so that objection is gone — the `delete()` body is what still makes the split necessary.)
11
+ * Node-free (fetch/URL/crypto only), so it runs unchanged in a Cloudflare Worker.
10
12
  *
11
13
  * An event subscription tells NetSapiens to POST change events to a URL you own. Notable API properties
12
14
  * that shape this module:
@@ -314,7 +314,7 @@ export function planSubscriptions(desired, actual, nowMs, opts) {
314
314
  const isOurs = (s) => typeof s.postUrl === 'string' && s.postUrl.startsWith(opts.ownedPrefix);
315
315
  const ours = actual.filter(isOurs);
316
316
  const foreign = actual.filter((s) => !isOurs(s));
317
- const key = (domain, model) => `${domain.toLowerCase()}${model}`;
317
+ const key = (domain, model) => `${domain.toLowerCase()}\u0000${model}`;
318
318
  const actions = [];
319
319
  const claimed = new Set();
320
320
  for (const want of desired) {
@@ -0,0 +1,54 @@
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
+ /** The HTTP methods any `synchronous`-capable operation uses. */
27
+ export type SynchronousMethod = 'POST' | 'PUT';
28
+ /** One operation that accepts `synchronous`, as a method plus an OpenAPI-style templated path. */
29
+ export interface SynchronousOperation {
30
+ method: SynchronousMethod;
31
+ /** Templated path, e.g. `/domains/{domain}/users`. A `{...}` segment matches exactly one path segment. */
32
+ path: string;
33
+ }
34
+ /**
35
+ * Every operation declaring `synchronous` in the v2 spec (core 44.4.10), deduplicated — the
36
+ * specification lists several of these more than once under `#1`…`#4` suffixes for differing
37
+ * request shapes, which are the same HTTP operation.
38
+ *
39
+ * Note the near-misses, which are the whole reason this list is explicit: user and domain **create**
40
+ * are here, user **update** is not; greeting and MOH **update** are here, device update is not.
41
+ */
42
+ export declare const SYNCHRONOUS_OPERATIONS: readonly SynchronousOperation[];
43
+ /**
44
+ * Does `method path` accept `synchronous`?
45
+ *
46
+ * `path` is a concrete request path relative to the `/ns-api/v2` base, with its dynamic segments
47
+ * already filled in and URI-encoded — exactly what a client passes to `post()`/`put()`. Encoding is
48
+ * what makes the match safe: a value containing a slash arrives as `%2F` and stays one segment, so
49
+ * it cannot masquerade as a deeper path.
50
+ *
51
+ * Unknown paths answer `false`. That is the safe direction: the flag is then omitted, and the caller
52
+ * gets the 202 it would have received anyway — rather than a promise of a 200 that never arrives.
53
+ */
54
+ export declare function supportsSynchronous(method: string, path: string): boolean;
@@ -0,0 +1,78 @@
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
+ * Every operation declaring `synchronous` in the v2 spec (core 44.4.10), deduplicated — the
28
+ * specification lists several of these more than once under `#1`…`#4` suffixes for differing
29
+ * request shapes, which are the same HTTP operation.
30
+ *
31
+ * Note the near-misses, which are the whole reason this list is explicit: user and domain **create**
32
+ * are here, user **update** is not; greeting and MOH **update** are here, device update is not.
33
+ */
34
+ export const SYNCHRONOUS_OPERATIONS = [
35
+ { method: 'POST', path: '/domains' },
36
+ { method: 'POST', path: '/domains/{domain}/callqueues' },
37
+ { method: 'POST', path: '/domains/{domain}/callqueues/{callqueue}/agents' },
38
+ { method: 'POST', path: '/domains/{domain}/dialplans/{dialplan}/dialrules' },
39
+ { method: 'POST', path: '/domains/{domain}/moh' },
40
+ { method: 'PUT', path: '/domains/{domain}/moh/{index}' },
41
+ { method: 'PUT', path: '/domains/{domain}/sites/{site}' },
42
+ { method: 'POST', path: '/domains/{domain}/timeframes' },
43
+ { method: 'POST', path: '/domains/{domain}/users' },
44
+ { method: 'POST', path: '/domains/{domain}/users/{user}/answerrules' },
45
+ { method: 'POST', path: '/domains/{domain}/users/{user}/calls' },
46
+ { method: 'POST', path: '/domains/{domain}/users/{user}/devices' },
47
+ { method: 'POST', path: '/domains/{domain}/users/{user}/greetings' },
48
+ { method: 'PUT', path: '/domains/{domain}/users/{user}/greetings/{index}' },
49
+ { method: 'POST', path: '/domains/{domain}/users/{user}/moh' },
50
+ { method: 'PUT', path: '/domains/{domain}/users/{user}/moh/{index}' },
51
+ { method: 'POST', path: '/domains/{domain}/users/{user}/timeframes' },
52
+ ];
53
+ /** Split a path into non-empty segments, ignoring any query string and leading/trailing slashes. */
54
+ const segmentsOf = (path) => (path.split('?')[0] ?? '').split('/').filter(Boolean);
55
+ /** Precomputed segment forms, so a lookup is a comparison rather than a re-parse per call. */
56
+ const TABLE = SYNCHRONOUS_OPERATIONS.map((op) => ({ method: op.method, segments: segmentsOf(op.path) }));
57
+ /**
58
+ * Does `method path` accept `synchronous`?
59
+ *
60
+ * `path` is a concrete request path relative to the `/ns-api/v2` base, with its dynamic segments
61
+ * already filled in and URI-encoded — exactly what a client passes to `post()`/`put()`. Encoding is
62
+ * what makes the match safe: a value containing a slash arrives as `%2F` and stays one segment, so
63
+ * it cannot masquerade as a deeper path.
64
+ *
65
+ * Unknown paths answer `false`. That is the safe direction: the flag is then omitted, and the caller
66
+ * gets the 202 it would have received anyway — rather than a promise of a 200 that never arrives.
67
+ */
68
+ export function supportsSynchronous(method, path) {
69
+ const verb = method.toUpperCase();
70
+ if (verb !== 'POST' && verb !== 'PUT')
71
+ return false;
72
+ const actual = segmentsOf(path);
73
+ return TABLE.some((op) => op.method === verb &&
74
+ op.segments.length === actual.length &&
75
+ op.segments.every((seg, i) => seg.startsWith('{') && seg.endsWith('}')
76
+ ? true // a template segment matches any single segment
77
+ : seg.toLowerCase() === actual[i].toLowerCase()));
78
+ }
@@ -8,9 +8,12 @@
8
8
  * generic post/put/delete core, and is meant to GROW into the full NS write surface (users, DIDs, …) —
9
9
  * porting the endpoint/body shapes from the onboarding tool's resource defs as they're needed.
10
10
  *
11
- * Like the onboarding client, POST/PUT inject `synchronous: 'yes'` so a create returns 200 + the created
12
- * resource inline (with server-generated fields e.g. a device's `device-sip-registration-password`)
13
- * instead of a 202 with replication lag. Shares the read client's SSRF guard and `NsApiError`.
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`.
14
17
  */
15
18
  import type { Rec } from './model.js';
16
19
  import { type EnsureNsDeviceOptions, type EnsureNsDeviceResult } from './nsDevice.js';
@@ -26,9 +29,15 @@ export declare class NsWriteClient {
26
29
  #private;
27
30
  constructor(cfg: NsWriteClientConfig);
28
31
  get<T = unknown>(path: string, query?: Record<string, string | number>): Promise<T>;
29
- /** POST with `synchronous:'yes'` injected → 200 + created resource inline. */
32
+ /**
33
+ * POST. On an operation that accepts it, `synchronous:'yes'` is injected → 200 + the created
34
+ * resource inline; otherwise the flag is omitted and the API answers 202 Accepted.
35
+ */
30
36
  post<T = unknown>(path: string, body: Rec): Promise<T>;
31
- /** PUT with `synchronous:'yes'` injected. */
37
+ /**
38
+ * PUT. Same rule as {@link post} — and note most updates do NOT accept the flag, so their
39
+ * response is a 202 acknowledgement with no resource body. Confirm those by reading back.
40
+ */
32
41
  put<T = unknown>(path: string, body: Rec): Promise<T>;
33
42
  delete<T = unknown>(path: string): Promise<T>;
34
43
  /** List a user's devices (normalized to an array). */
@@ -44,6 +53,10 @@ export declare class NsWriteClient {
44
53
  /**
45
54
  * Update a device in place.
46
55
  *
56
+ * `PUT .../devices/{device}` does **not** accept `synchronous`, so this returns a 202
57
+ * acknowledgement, not the updated device. Callers must not depend on the response echoing their
58
+ * change back — {@link ensureNsDevice} falls back to the value it just sent for exactly this reason.
59
+ *
47
60
  * The reason this exists rather than callers using `put()`: rotating
48
61
  * `device-sip-registration-password` must **not** be done by deleting and recreating the device, which
49
62
  * would discard everything else on it — emergency caller id, the provisioning MAC/model link, SRTP and
@@ -1,5 +1,6 @@
1
1
  import { NsApiError, assertBareServer, asArray } from './nsClient.js';
2
2
  import { ensureNsDevice } from './nsDevice.js';
3
+ import { supportsSynchronous } from './nsSynchronous.js';
3
4
  const enc = encodeURIComponent;
4
5
  export class NsWriteClient {
5
6
  #baseUrl;
@@ -14,13 +15,25 @@ export class NsWriteClient {
14
15
  get(path, query) {
15
16
  return this.#request('GET', path, undefined, query);
16
17
  }
17
- /** 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
+ */
18
22
  post(path, body) {
19
- return this.#request('POST', path, { synchronous: 'yes', ...body });
23
+ return this.#request('POST', path, this.#withSynchronous('POST', path, body));
20
24
  }
21
- /** 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
+ */
22
29
  put(path, body) {
23
- 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 };
24
37
  }
25
38
  delete(path) {
26
39
  return this.#request('DELETE', path);
@@ -45,6 +58,10 @@ export class NsWriteClient {
45
58
  /**
46
59
  * Update a device in place.
47
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
+ *
48
65
  * The reason this exists rather than callers using `put()`: rotating
49
66
  * `device-sip-registration-password` must **not** be done by deleting and recreating the device, which
50
67
  * would discard everything else on it — emergency caller id, the provisioning MAC/model link, SRTP and
package/dist/policy.d.ts CHANGED
@@ -13,9 +13,15 @@
13
13
  * - all users in some domains: { domains: ['acme','acme42'] }
14
14
  * - …optionally with scopes: { domains:['acme'], scopes:['Office Manager','Basic User'] }
15
15
  * - specific users: { users: ['100@acme','101@acme'] }
16
+ * - a scope MINUS a few accounts: { scopes:['Reseller'], notUsers:['105@acme'] }
16
17
  * - only when a given operator is masked in: { operators: ['admin@0000.12345.service'] }
17
18
  * - only while (not) masking: { masking: true } / { masking: false }
18
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
+ *
19
25
  * Matching considers the EFFECTIVE principal (scope/domain/id = the masked user when masking);
20
26
  * `operators` matches the mask_chain operator, so you can gate on the real reseller behind a mask.
21
27
  *
@@ -29,6 +35,16 @@ export interface PolicyRule {
29
35
  domains?: string[];
30
36
  /** Effective identity (`user@domain`) must be one of these. */
31
37
  users?: string[];
38
+ /**
39
+ * Effective identity (`user@domain`) must NOT be one of these — a denial that ANDs with the rest of
40
+ * 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
+ notUsers?: string[];
32
48
  /** Requires masking, AND the operator's `user@domain` (mask_chain) is one of these. */
33
49
  operators?: string[];
34
50
  /** Require the masking state to equal this (true = masked, false = not masked). */
package/dist/policy.js CHANGED
@@ -20,6 +20,8 @@ const scopeInList = (value, list) => {
20
20
  export function ruleMatches(p, rule) {
21
21
  // A rule with NO matchable condition (e.g. `{}` or only `description`) is NOT allow-all — that would
22
22
  // silently grant everyone. Require at least one real condition; a conditionless rule never matches.
23
+ // `notUsers` is deliberately absent from this list: it subtracts, so counting it would let
24
+ // `{notUsers:[…]}` mean "everyone else", which is the allow-all this guard exists to prevent.
23
25
  const hasCondition = rule.scopes !== undefined ||
24
26
  rule.domains !== undefined ||
25
27
  rule.users !== undefined ||
@@ -33,6 +35,8 @@ export function ruleMatches(p, rule) {
33
35
  return false;
34
36
  if (rule.users && !inList(p.id, rule.users))
35
37
  return false;
38
+ if (rule.notUsers && inList(p.id, rule.notUsers))
39
+ return false;
36
40
  if (rule.operators) {
37
41
  if (!p.operator || !inList(p.operator.id, rule.operators))
38
42
  return false;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@dszp/netsapiens-lib",
3
- "version": "0.1.6",
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.8",
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": {
@@ -55,11 +55,12 @@
55
55
  "build:watch": "tsc -p tsconfig.json --watch",
56
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.",
57
57
  "prepublishOnly": "tsc -p tsconfig.json --sourceMap false --declarationMap false",
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:eligibility && pnpm run test:nsauth && pnpm test:nssubs && pnpm test:nsdevice",
58
+ "//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.",
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",
60
60
  "test:jwt": "tsx src/jwt.selftest.ts",
61
61
  "test:ns": "tsx src/nsClient.selftest.ts",
62
62
  "test:nswrite": "tsx src/nsWriteClient.selftest.ts",
63
+ "test:nssync": "tsx src/nsSynchronous.selftest.ts",
63
64
  "test:nsauth": "tsx src/nsAuthClient.selftest.ts",
64
65
  "test:principal": "tsx src/principal.selftest.ts",
65
66
  "test:resolver": "tsx src/resolver.selftest.ts",