@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 +26 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/mermaid.js +0 -0
- package/dist/nsClient.d.ts +9 -5
- package/dist/nsClient.js +9 -5
- package/dist/nsSubscriptions.d.ts +6 -4
- package/dist/nsSubscriptions.js +1 -1
- package/dist/nsSynchronous.d.ts +54 -0
- package/dist/nsSynchronous.js +78 -0
- package/dist/nsWriteClient.d.ts +18 -5
- package/dist/nsWriteClient.js +21 -4
- package/dist/policy.d.ts +16 -0
- package/dist/policy.js +4 -0
- package/package.json +5 -4
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
|
package/dist/nsClient.d.ts
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
|
|
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
|
|
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
|
|
36
|
-
*
|
|
37
|
-
*
|
|
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
|
|
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
|
|
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
|
|
33
|
-
*
|
|
34
|
-
*
|
|
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`
|
|
7
|
-
* `
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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:
|
package/dist/nsSubscriptions.js
CHANGED
|
@@ -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()}
|
|
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
|
+
}
|
package/dist/nsWriteClient.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
package/dist/nsWriteClient.js
CHANGED
|
@@ -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
|
-
/**
|
|
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,
|
|
23
|
+
return this.#request('POST', path, this.#withSynchronous('POST', path, body));
|
|
20
24
|
}
|
|
21
|
-
/**
|
|
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,
|
|
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.
|
|
4
|
-
"description": "Portable, Node-free NetSapiens toolkit: read
|
|
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
|
|
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",
|