@dszp/netsapiens-lib 0.1.5 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/mermaid.js +0 -0
- package/dist/nsClient.d.ts +9 -5
- package/dist/nsClient.js +9 -5
- package/dist/nsDevice.d.ts +89 -0
- package/dist/nsDevice.js +101 -0
- package/dist/nsSubscriptions.d.ts +268 -0
- package/dist/nsSubscriptions.js +444 -0
- package/dist/nsSynchronous.d.ts +54 -0
- package/dist/nsSynchronous.js +78 -0
- package/dist/nsWriteClient.d.ts +39 -5
- package/dist/nsWriteClient.js +46 -4
- package/dist/resolver.js +69 -5
- package/package.json +17 -8
package/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,7 +17,10 @@ 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';
|
|
21
|
+
export { ensureNsDevice, generateSipPassword, SIP_PW_FIELD, type NsDeviceWriter, type EnsureNsDeviceOptions, type EnsureNsDeviceResult, } from './nsDevice.js';
|
|
20
22
|
export { NsAuthClient, NsAuthError, type NsAuthClientConfig, type NsTokenResponse } from './nsAuthClient.js';
|
|
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';
|
|
21
24
|
export { verify, validateJwtFormat, extractContext, assertClaims, verifyHs256Signature, normalizeToken, tokenKey, MemoryVerdictCache, type JwtVerdict, type JwtContext, type ClaimExpectations, type VerdictCache, type VerifyOptions, type FormatResult, } from './jwt.js';
|
|
22
25
|
export { type CallSensitivity, needsFreshAuth, SENSITIVITY_NOTE } from './sensitivity.js';
|
|
23
26
|
export { toPrincipal, parseOperator, isResellerScope, isAdminScope, type Principal, type Operator, type Scope, } from './principal.js';
|
package/dist/index.js
CHANGED
|
@@ -16,7 +16,10 @@ 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';
|
|
20
|
+
export { ensureNsDevice, generateSipPassword, SIP_PW_FIELD, } from './nsDevice.js';
|
|
19
21
|
export { NsAuthClient, NsAuthError } from './nsAuthClient.js';
|
|
22
|
+
export { NsSubscriptionsClient, NsSubscriptionConflictError, SUBSCRIPTION_MODELS, isSubscriptionModel, nsDatetime, parseNsDatetime, subscriptionFromWire, createInputToWire, updateInputToWire, planSubscriptions, } from './nsSubscriptions.js';
|
|
20
23
|
export { verify, validateJwtFormat, extractContext, assertClaims, verifyHs256Signature, normalizeToken, tokenKey, MemoryVerdictCache, } from './jwt.js';
|
|
21
24
|
export { needsFreshAuth, SENSITIVITY_NOTE } from './sensitivity.js';
|
|
22
25
|
export { toPrincipal, parseOperator, isResellerScope, isAdminScope, } from './principal.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
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NetSapiens device orchestration — ensure a named device exists and hand back its SIP registration
|
|
3
|
+
* password, optionally rotating it.
|
|
4
|
+
*
|
|
5
|
+
* This lives in the library because two separate consumers had grown their own copy of it, and a
|
|
6
|
+
* divergence between them is expensive: they both provision the same softphone device for the same
|
|
7
|
+
* extension, and disagreeing about whether to reuse or replace its credentials produces bugs that look
|
|
8
|
+
* like a phone problem rather than a code problem. One implementation, one set of tests.
|
|
9
|
+
*
|
|
10
|
+
* Mechanism only — no policy. The device NAME is a caller-supplied string (a consumer's `<ext><suffix>`
|
|
11
|
+
* convention is its own business), *whether* creation is permitted is the caller's decision, and *when*
|
|
12
|
+
* rotation is appropriate is very much the caller's decision. See {@link ensureNsDevice}.
|
|
13
|
+
*/
|
|
14
|
+
import type { Rec } from './model.js';
|
|
15
|
+
/** The NS device field carrying the auto-generated SIP registration password (API v2). */
|
|
16
|
+
export declare const SIP_PW_FIELD = "device-sip-registration-password";
|
|
17
|
+
/** The subset of a write client this needs. Structural, so a consumer can inject a mock or a subset. */
|
|
18
|
+
export interface NsDeviceWriter {
|
|
19
|
+
getDevices(domain: string, user: string): Promise<Rec[]>;
|
|
20
|
+
getDevice(domain: string, user: string, device: string): Promise<Rec>;
|
|
21
|
+
createDevice(domain: string, user: string, device: string, extra?: Rec): Promise<Rec>;
|
|
22
|
+
updateDevice(domain: string, user: string, device: string, changes: Rec): Promise<Rec>;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Generate a SIP registration password.
|
|
26
|
+
*
|
|
27
|
+
* Alphanumeric only: the value travels through SIP digest auth, device provisioning templates, and
|
|
28
|
+
* whatever the consuming app stores it in, and punctuation buys no meaningful entropy while risking an
|
|
29
|
+
* escaping bug in any one of those. Characters are rejection-sampled rather than modulo-reduced, so every
|
|
30
|
+
* symbol is equally likely.
|
|
31
|
+
*
|
|
32
|
+
* **Guarantees at least one uppercase, one lowercase, and one digit** (for `length >= 3`). A uniform draw
|
|
33
|
+
* from a 62-symbol alphabet omits digits entirely about 3% of the time at length 20, which looks like a
|
|
34
|
+
* bug to anyone who eyeballs one and can trip a downstream password-complexity rule. The whole candidate
|
|
35
|
+
* is redrawn until it qualifies — never patched in place, which would bias the positions it patched.
|
|
36
|
+
*/
|
|
37
|
+
export declare function generateSipPassword(length?: number): string;
|
|
38
|
+
export interface EnsureNsDeviceOptions {
|
|
39
|
+
domain: string;
|
|
40
|
+
/** The NS user / extension that owns the device. */
|
|
41
|
+
user: string;
|
|
42
|
+
/** The device name, e.g. `100r`. */
|
|
43
|
+
device: string;
|
|
44
|
+
/**
|
|
45
|
+
* May this create the device when it is absent? Default `true`. Pass `false` to look without creating —
|
|
46
|
+
* the result's `password` is then `''` for a missing device, which a caller can treat as "refuse".
|
|
47
|
+
*/
|
|
48
|
+
mayCreate?: boolean;
|
|
49
|
+
/**
|
|
50
|
+
* Replace the password of a device that **already existed**.
|
|
51
|
+
*
|
|
52
|
+
* This closes a subtle and genuinely hard-to-diagnose failure: reusing the stored password leaves any
|
|
53
|
+
* *other* endpoint still holding it with valid credentials for the same address-of-record. Both clients
|
|
54
|
+
* then register, the most recent wins, and they trade the registration back and forth — intermittent
|
|
55
|
+
* call failures with nothing obviously wrong in either system.
|
|
56
|
+
*
|
|
57
|
+
* Rotate only where something has just declared this device to belong to one client — a deliberate
|
|
58
|
+
* activation or a first-time provision. **Do not rotate on a per-login or per-request path**: concurrent
|
|
59
|
+
* runs would churn the credential and can race a re-registration.
|
|
60
|
+
*
|
|
61
|
+
* Rotation is **best-effort** and never throws: on failure the result carries the pre-existing password
|
|
62
|
+
* plus `rotated: false` and `rotateError`, because failing the whole operation over a hardening step
|
|
63
|
+
* would be worse than the contention it prevents. Notably a NetSapiens release without the device `PUT`
|
|
64
|
+
* lands here.
|
|
65
|
+
*/
|
|
66
|
+
rotateExisting?: boolean;
|
|
67
|
+
/** Length for a rotated password. Default 20. */
|
|
68
|
+
passwordLength?: number;
|
|
69
|
+
}
|
|
70
|
+
export interface EnsureNsDeviceResult {
|
|
71
|
+
/** The SIP password to give the client. `''` means "absent and not created" — treat as a refusal. */
|
|
72
|
+
password: string;
|
|
73
|
+
/** True when this call created the device. */
|
|
74
|
+
created: boolean;
|
|
75
|
+
/** Present only when `rotateExisting` was requested: whether the rotation actually happened. */
|
|
76
|
+
rotated?: boolean;
|
|
77
|
+
/** Why rotation failed, when it did. */
|
|
78
|
+
rotateError?: string;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Ensure the device exists and return its SIP password.
|
|
82
|
+
*
|
|
83
|
+
* Present: read it back with a per-device GET, because a device *list* may omit the password. Absent:
|
|
84
|
+
* create it (NetSapiens generates the password; a `synchronous` write returns it inline) unless
|
|
85
|
+
* `mayCreate` is false.
|
|
86
|
+
*
|
|
87
|
+
* A newly created device is never rotated — it already has a fresh, exclusive password.
|
|
88
|
+
*/
|
|
89
|
+
export declare function ensureNsDevice(writer: NsDeviceWriter, opts: EnsureNsDeviceOptions): Promise<EnsureNsDeviceResult>;
|
package/dist/nsDevice.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/** The NS device field carrying the auto-generated SIP registration password (API v2). */
|
|
2
|
+
export const SIP_PW_FIELD = 'device-sip-registration-password';
|
|
3
|
+
const PW_UPPER = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
|
4
|
+
const PW_LOWER = 'abcdefghijklmnopqrstuvwxyz';
|
|
5
|
+
const PW_DIGIT = '0123456789';
|
|
6
|
+
const PW_ALPHABET = PW_UPPER + PW_LOWER + PW_DIGIT;
|
|
7
|
+
/** Uniformly-drawn characters from the alphabet, rejection-sampled so no symbol is over-represented. */
|
|
8
|
+
function randomChars(n) {
|
|
9
|
+
const out = [];
|
|
10
|
+
const buf = new Uint8Array(n * 2);
|
|
11
|
+
const limit = Math.floor(256 / PW_ALPHABET.length) * PW_ALPHABET.length; // 248 for a 62-symbol alphabet
|
|
12
|
+
while (out.length < n) {
|
|
13
|
+
crypto.getRandomValues(buf);
|
|
14
|
+
for (const b of buf) {
|
|
15
|
+
if (b >= limit)
|
|
16
|
+
continue; // reject, to keep the distribution uniform
|
|
17
|
+
out.push(PW_ALPHABET[b % PW_ALPHABET.length]);
|
|
18
|
+
if (out.length === n)
|
|
19
|
+
break;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
const hasEach = (s) => /[A-Z]/.test(s) && /[a-z]/.test(s) && /[0-9]/.test(s);
|
|
25
|
+
/**
|
|
26
|
+
* Generate a SIP registration password.
|
|
27
|
+
*
|
|
28
|
+
* Alphanumeric only: the value travels through SIP digest auth, device provisioning templates, and
|
|
29
|
+
* whatever the consuming app stores it in, and punctuation buys no meaningful entropy while risking an
|
|
30
|
+
* escaping bug in any one of those. Characters are rejection-sampled rather than modulo-reduced, so every
|
|
31
|
+
* symbol is equally likely.
|
|
32
|
+
*
|
|
33
|
+
* **Guarantees at least one uppercase, one lowercase, and one digit** (for `length >= 3`). A uniform draw
|
|
34
|
+
* from a 62-symbol alphabet omits digits entirely about 3% of the time at length 20, which looks like a
|
|
35
|
+
* bug to anyone who eyeballs one and can trip a downstream password-complexity rule. The whole candidate
|
|
36
|
+
* is redrawn until it qualifies — never patched in place, which would bias the positions it patched.
|
|
37
|
+
*/
|
|
38
|
+
export function generateSipPassword(length = 20) {
|
|
39
|
+
if (!Number.isInteger(length) || length < 1)
|
|
40
|
+
throw new Error('generateSipPassword: length must be a positive integer');
|
|
41
|
+
// Below 3 characters the guarantee is arithmetically impossible; return a uniform draw.
|
|
42
|
+
if (length < 3)
|
|
43
|
+
return randomChars(length).join('');
|
|
44
|
+
for (let attempt = 0; attempt < 100; attempt++) {
|
|
45
|
+
const candidate = randomChars(length).join('');
|
|
46
|
+
if (hasEach(candidate))
|
|
47
|
+
return candidate;
|
|
48
|
+
}
|
|
49
|
+
// Unreachable in practice (the odds compound to ~0). Draw one character from each class DIRECTLY —
|
|
50
|
+
// upper-casing an arbitrary draw is not a guarantee, since upper-casing a digit yields the same digit.
|
|
51
|
+
const pick = (alphabet) => {
|
|
52
|
+
const b = new Uint8Array(1);
|
|
53
|
+
const limit = Math.floor(256 / alphabet.length) * alphabet.length;
|
|
54
|
+
for (;;) {
|
|
55
|
+
crypto.getRandomValues(b);
|
|
56
|
+
if (b[0] < limit)
|
|
57
|
+
return alphabet[b[0] % alphabet.length];
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
return [pick(PW_UPPER), pick(PW_LOWER), pick(PW_DIGIT), ...randomChars(length - 3)].join('');
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Ensure the device exists and return its SIP password.
|
|
64
|
+
*
|
|
65
|
+
* Present: read it back with a per-device GET, because a device *list* may omit the password. Absent:
|
|
66
|
+
* create it (NetSapiens generates the password; a `synchronous` write returns it inline) unless
|
|
67
|
+
* `mayCreate` is false.
|
|
68
|
+
*
|
|
69
|
+
* A newly created device is never rotated — it already has a fresh, exclusive password.
|
|
70
|
+
*/
|
|
71
|
+
export async function ensureNsDevice(writer, opts) {
|
|
72
|
+
const { domain, user, device } = opts;
|
|
73
|
+
const devices = await writer.getDevices(domain, user);
|
|
74
|
+
const existing = Array.isArray(devices) ? devices.find((d) => String(d['device'] ?? '') === device) : undefined;
|
|
75
|
+
if (existing) {
|
|
76
|
+
const dev = await writer.getDevice(domain, user, device);
|
|
77
|
+
const current = String(dev[SIP_PW_FIELD] ?? existing[SIP_PW_FIELD] ?? '');
|
|
78
|
+
if (!opts.rotateExisting)
|
|
79
|
+
return { password: current, created: false };
|
|
80
|
+
const fresh = generateSipPassword(opts.passwordLength ?? 20);
|
|
81
|
+
try {
|
|
82
|
+
const updated = await writer.updateDevice(domain, user, device, { [SIP_PW_FIELD]: fresh });
|
|
83
|
+
// Prefer what NS echoes back if it echoes anything; otherwise the value we just set.
|
|
84
|
+
// `||` not `??`: an echoed empty string would otherwise be handed back as the password, and the
|
|
85
|
+
// caller's blank-password guard would refuse AFTER the device was already rotated.
|
|
86
|
+
return { password: String(updated?.[SIP_PW_FIELD] || fresh), created: false, rotated: true };
|
|
87
|
+
}
|
|
88
|
+
catch (e) {
|
|
89
|
+
return {
|
|
90
|
+
password: current,
|
|
91
|
+
created: false,
|
|
92
|
+
rotated: false,
|
|
93
|
+
rotateError: String(e?.message ?? e).slice(0, 200),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (opts.mayCreate === false)
|
|
98
|
+
return { password: '', created: false };
|
|
99
|
+
const created = await writer.createDevice(domain, user, device);
|
|
100
|
+
return { password: String(created?.[SIP_PW_FIELD] ?? ''), created: true };
|
|
101
|
+
}
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NetSapiens API v2 **Event Subscriptions** — a separate client for the `/subscriptions` surface, plus a
|
|
3
|
+
* pure reconciliation planner.
|
|
4
|
+
*
|
|
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`'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.
|
|
12
|
+
*
|
|
13
|
+
* An event subscription tells NetSapiens to POST change events to a URL you own. Notable API properties
|
|
14
|
+
* that shape this module:
|
|
15
|
+
*
|
|
16
|
+
* - **`id` is server-generated**, so a subscription cannot be tagged by the client. Ours are therefore
|
|
17
|
+
* identified by `post-url`, which makes the URL both the address *and* the label.
|
|
18
|
+
* - **Filters are immutable.** `PUT` accepts `post-url` and `subscription-expires-datetime` but not
|
|
19
|
+
* `domain`/`user`/`reseller`, so a filter change means a new subscription.
|
|
20
|
+
* - **Always send an explicit `expiresAt`.** Observed behaviour: an explicit expiry is stored verbatim
|
|
21
|
+
* *even when the request is authenticated with a one-hour OAuth access token* — the expiry is not
|
|
22
|
+
* clamped to the credential's lifetime. Omitting it yields a ~20-year expiry for an API key but only the
|
|
23
|
+
* token's expiry for a timed token, so relying on the default makes lifetime depend on how you
|
|
24
|
+
* authenticated. Renewal, when needed, is a `PUT`, never delete-and-recreate.
|
|
25
|
+
* - ⚠️ **`subscription-geo-support` behaves as `no` when omitted**, despite the API describing the default
|
|
26
|
+
* as `yes`. Send it explicitly if you want geo-redundant delivery (you almost certainly do — otherwise
|
|
27
|
+
* delivery is pinned and stops when that node is down).
|
|
28
|
+
* - ⚠️ **The domain-scoped routes (`/domains/{domain}/subscriptions`, API v45+) are not present on every
|
|
29
|
+
* cluster** — a v44 cluster answers `404 No Route Found` while the flat `/subscriptions` paths work.
|
|
30
|
+
* Prefer the flat methods and treat the domain-scoped ones as an opt-in optimization.
|
|
31
|
+
* - **Datetimes are asymmetric.** Reads observably return ISO-8601 with an offset; the documented *write*
|
|
32
|
+
* format is `YYYY-MM-DD HH:MM:SS`. {@link parseNsDatetime} accepts both; {@link nsDatetime} emits the
|
|
33
|
+
* documented form.
|
|
34
|
+
* - **`error-count` > 0 is normal on a healthy subscription** — a live example sat at 7 errors across 7195
|
|
35
|
+
* posts while `status` stayed `active`. Treat `status === 'error'` or a sustained error *rate* as the
|
|
36
|
+
* signal, and never reset the counters as routine maintenance: they are the only history the API keeps.
|
|
37
|
+
*/
|
|
38
|
+
import type { Rec } from './model.js';
|
|
39
|
+
import { NsApiError } from './nsClient.js';
|
|
40
|
+
/** Event types a subscription can carry. One subscription carries exactly one model. */
|
|
41
|
+
export type SubscriptionModel = 'agent' | 'auditlog' | 'auditlog_lite' | 'call' | 'call_origid' | 'cdr' | 'message' | 'messagesession' | 'subscriber' | 'presence' | 'voicemail';
|
|
42
|
+
/** Every valid `model` value, for validating configuration before it reaches the API. */
|
|
43
|
+
export declare const SUBSCRIPTION_MODELS: readonly SubscriptionModel[];
|
|
44
|
+
/** Narrowing guard for a configured model string. */
|
|
45
|
+
export declare function isSubscriptionModel(v: unknown): v is SubscriptionModel;
|
|
46
|
+
/** Server-reported delivery health. `pending` until the first successful post. */
|
|
47
|
+
export type SubscriptionStatus = 'pending' | 'active' | 'error';
|
|
48
|
+
/**
|
|
49
|
+
* A subscription, with the API's hyphenated wire keys mapped to camelCase. Datetimes are kept as the
|
|
50
|
+
* **raw strings** the API returned (parse with {@link parseNsDatetime} when you need a `Date`), and `raw`
|
|
51
|
+
* carries the untouched record so a caller never loses a field this type hasn't modelled.
|
|
52
|
+
*/
|
|
53
|
+
export interface Subscription {
|
|
54
|
+
id: string;
|
|
55
|
+
model?: string;
|
|
56
|
+
postUrl?: string;
|
|
57
|
+
geoSupport?: string;
|
|
58
|
+
userScope?: string;
|
|
59
|
+
reseller?: string;
|
|
60
|
+
domain?: string;
|
|
61
|
+
user?: string;
|
|
62
|
+
/** Raw `subscription-creation-datetime`. */
|
|
63
|
+
createdAt?: string;
|
|
64
|
+
/** Raw `subscription-expires-datetime`. */
|
|
65
|
+
expiresAt?: string;
|
|
66
|
+
preferredServer?: string;
|
|
67
|
+
/** Read-only: the node currently delivering. Changes on failover. */
|
|
68
|
+
currentActiveServer?: string;
|
|
69
|
+
status?: string;
|
|
70
|
+
errorCount?: number;
|
|
71
|
+
postsCount?: number;
|
|
72
|
+
/** The untouched API record. */
|
|
73
|
+
raw: Rec;
|
|
74
|
+
}
|
|
75
|
+
/** Fields accepted when creating. `domain`/`user`/`reseller` are the (immutable) event filters. */
|
|
76
|
+
export interface CreateSubscriptionInput {
|
|
77
|
+
model: SubscriptionModel;
|
|
78
|
+
/** Absolute https URL NetSapiens will POST to. */
|
|
79
|
+
postUrl: string;
|
|
80
|
+
/** Restrict to one domain. `'*'` means all domains and requires Super User scope. */
|
|
81
|
+
domain?: string;
|
|
82
|
+
/** Restrict to one user/extension. Defaults to all. */
|
|
83
|
+
user?: string;
|
|
84
|
+
/** Restrict to one reseller. `'*'` requires Super User scope. */
|
|
85
|
+
reseller?: string;
|
|
86
|
+
/**
|
|
87
|
+
* Geo-redundant delivery across nodes. ⚠️ Behaves as `'no'` when omitted, despite the API documenting
|
|
88
|
+
* `'yes'` as the default — send `'yes'` explicitly unless you deliberately want delivery pinned.
|
|
89
|
+
*/
|
|
90
|
+
geoSupport?: 'yes' | 'no';
|
|
91
|
+
/**
|
|
92
|
+
* Explicit expiry, and you should always set one. It is honoured verbatim even when the request is
|
|
93
|
+
* authenticated with a short-lived OAuth token. Omitting it makes the lifetime depend on the credential
|
|
94
|
+
* (API key ⇒ ~20 years; timed token ⇒ that token's expiry).
|
|
95
|
+
*/
|
|
96
|
+
expiresAt?: Date | string;
|
|
97
|
+
/** Preferred delivering node. A preference, not a pin — other nodes deliver during instability. */
|
|
98
|
+
preferredServer?: string;
|
|
99
|
+
}
|
|
100
|
+
/** Fields `PUT` accepts. The event filters are deliberately absent — they cannot be changed. */
|
|
101
|
+
export interface UpdateSubscriptionInput {
|
|
102
|
+
model?: SubscriptionModel;
|
|
103
|
+
postUrl?: string;
|
|
104
|
+
geoSupport?: 'yes' | 'no';
|
|
105
|
+
expiresAt?: Date | string;
|
|
106
|
+
preferredServer?: string;
|
|
107
|
+
/** Only `0` is accepted — a reset. Prefer leaving counters alone; they are the only history kept. */
|
|
108
|
+
errorCount?: 0;
|
|
109
|
+
/** Only `0` is accepted — a reset. */
|
|
110
|
+
postsCount?: 0;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Format a `Date` as the documented write format `YYYY-MM-DD HH:MM:SS`, **in UTC**.
|
|
114
|
+
*
|
|
115
|
+
* The API documents no timezone for this field. Emitting UTC is the only self-consistent choice, and it
|
|
116
|
+
* round-trips with {@link parseNsDatetime}, which also reads a bare timestamp as UTC.
|
|
117
|
+
*/
|
|
118
|
+
export declare function nsDatetime(d: Date): string;
|
|
119
|
+
/**
|
|
120
|
+
* Parse either datetime shape the API uses: the documented `YYYY-MM-DD HH:MM:SS` (read as **UTC**) or the
|
|
121
|
+
* ISO-8601-with-offset form that reads actually return. Returns `undefined` rather than an Invalid Date so
|
|
122
|
+
* callers fail closed on a value they can't interpret.
|
|
123
|
+
*/
|
|
124
|
+
export declare function parseNsDatetime(s: string | undefined | null): Date | undefined;
|
|
125
|
+
/** Map one API record to {@link Subscription}. Tolerant: an unmodelled or missing field is simply absent. */
|
|
126
|
+
export declare function subscriptionFromWire(rec: Rec): Subscription;
|
|
127
|
+
/** Map {@link CreateSubscriptionInput} to the hyphenated request body. */
|
|
128
|
+
export declare function createInputToWire(input: CreateSubscriptionInput): Rec;
|
|
129
|
+
/** Map {@link UpdateSubscriptionInput} to the hyphenated request body. */
|
|
130
|
+
export declare function updateInputToWire(changes: UpdateSubscriptionInput): Rec;
|
|
131
|
+
export interface NsSubscriptionsClientConfig {
|
|
132
|
+
/** API host, e.g. `"api.example.com"`. Base URL becomes `https://{server}/ns-api/v2`. */
|
|
133
|
+
server: string;
|
|
134
|
+
/** Bearer token — an API key, or an OAuth access token, with scope to manage subscriptions. */
|
|
135
|
+
token: string;
|
|
136
|
+
/** Injectable for tests / non-global fetch. */
|
|
137
|
+
fetchImpl?: typeof fetch;
|
|
138
|
+
/** Page size for list calls. Default 500. */
|
|
139
|
+
pageSize?: number;
|
|
140
|
+
}
|
|
141
|
+
/** Thrown by {@link NsSubscriptionsClient.create} on the API's 409 "already exists" response. */
|
|
142
|
+
export declare class NsSubscriptionConflictError extends NsApiError {
|
|
143
|
+
constructor(message: string, path: string, body: unknown);
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Read/write client for `/subscriptions`.
|
|
147
|
+
*
|
|
148
|
+
* ```ts
|
|
149
|
+
* const subs = new NsSubscriptionsClient({ server: 'api.example.com', token: key });
|
|
150
|
+
* const mine = (await subs.list()).filter((s) => s.postUrl?.startsWith('https://hooks.example.com/'));
|
|
151
|
+
* ```
|
|
152
|
+
*/
|
|
153
|
+
export declare class NsSubscriptionsClient {
|
|
154
|
+
#private;
|
|
155
|
+
constructor(cfg: NsSubscriptionsClientConfig);
|
|
156
|
+
/**
|
|
157
|
+
* Every subscription the credential can see, paged to completion.
|
|
158
|
+
*
|
|
159
|
+
* Paging matters: with no local registry this list *is* the source of truth, so a partial read would
|
|
160
|
+
* make a reconciler create duplicates or skip renewals. The loop is defensive in both directions — it
|
|
161
|
+
* stops on a short page, and also if a server that ignores the paging parameters returns the same
|
|
162
|
+
* records again.
|
|
163
|
+
*/
|
|
164
|
+
list(): Promise<Subscription[]>;
|
|
165
|
+
/** Subscriptions filtered to one domain. ⚠️ Requires API v45+; a v44 cluster returns 404. */
|
|
166
|
+
listForDomain(domain: string): Promise<Subscription[]>;
|
|
167
|
+
/** Read one subscription by id. */
|
|
168
|
+
get(id: string): Promise<Subscription>;
|
|
169
|
+
/**
|
|
170
|
+
* Create a subscription. Throws {@link NsSubscriptionConflictError} on 409, which the API returns when a
|
|
171
|
+
* subscription with a matching set of parameters already exists — usually meaning the desired state is
|
|
172
|
+
* already in place.
|
|
173
|
+
*/
|
|
174
|
+
create(input: CreateSubscriptionInput): Promise<Subscription>;
|
|
175
|
+
/** Create against the domain-scoped path. ⚠️ Requires API v45+; a v44 cluster returns 404. */
|
|
176
|
+
createForDomain(domain: string, input: CreateSubscriptionInput): Promise<Subscription>;
|
|
177
|
+
/**
|
|
178
|
+
* Update a subscription — this is how renewal works (a new `subscription-expires-datetime`) and how a
|
|
179
|
+
* callback URL is rotated (`post-url`). The event filters cannot be changed.
|
|
180
|
+
*/
|
|
181
|
+
update(id: string, changes: UpdateSubscriptionInput): Promise<unknown>;
|
|
182
|
+
/** Update against the domain-scoped path. ⚠️ Requires API v45+; a v44 cluster returns 404. */
|
|
183
|
+
updateForDomain(domain: string, id: string, changes: UpdateSubscriptionInput): Promise<unknown>;
|
|
184
|
+
/**
|
|
185
|
+
* Delete a subscription.
|
|
186
|
+
*
|
|
187
|
+
* Note the body: this endpoint takes `subscription_id` (and `domain`, required for scopes below Super
|
|
188
|
+
* User) *in addition to* the path id. That is why this client exists rather than reusing a generic write
|
|
189
|
+
* client whose `delete()` sends no body.
|
|
190
|
+
*/
|
|
191
|
+
remove(id: string, opts?: {
|
|
192
|
+
domain?: string;
|
|
193
|
+
}): Promise<unknown>;
|
|
194
|
+
}
|
|
195
|
+
/** One subscription we want to exist. */
|
|
196
|
+
export interface DesiredSubscription {
|
|
197
|
+
domain: string;
|
|
198
|
+
model: SubscriptionModel;
|
|
199
|
+
/** The exact callback URL this (domain, model) should post to. */
|
|
200
|
+
postUrl: string;
|
|
201
|
+
}
|
|
202
|
+
/** An action the caller should execute. Every variant carries a human-readable `reason` for logging. */
|
|
203
|
+
export type SubscriptionAction = {
|
|
204
|
+
kind: 'create';
|
|
205
|
+
domain: string;
|
|
206
|
+
model: SubscriptionModel;
|
|
207
|
+
postUrl: string;
|
|
208
|
+
expiresAt: string;
|
|
209
|
+
reason: string;
|
|
210
|
+
} | {
|
|
211
|
+
kind: 'renew';
|
|
212
|
+
id: string;
|
|
213
|
+
domain: string;
|
|
214
|
+
expiresAt: string;
|
|
215
|
+
reason: string;
|
|
216
|
+
} | {
|
|
217
|
+
kind: 'repair-url';
|
|
218
|
+
id: string;
|
|
219
|
+
domain: string;
|
|
220
|
+
postUrl: string;
|
|
221
|
+
reason: string;
|
|
222
|
+
} | {
|
|
223
|
+
kind: 'delete';
|
|
224
|
+
id: string;
|
|
225
|
+
domain: string;
|
|
226
|
+
reason: string;
|
|
227
|
+
} | {
|
|
228
|
+
kind: 'report';
|
|
229
|
+
id: string;
|
|
230
|
+
domain: string;
|
|
231
|
+
reason: string;
|
|
232
|
+
status?: string;
|
|
233
|
+
errorCount?: number;
|
|
234
|
+
postsCount?: number;
|
|
235
|
+
} | {
|
|
236
|
+
kind: 'noop';
|
|
237
|
+
id: string;
|
|
238
|
+
domain: string;
|
|
239
|
+
reason: string;
|
|
240
|
+
};
|
|
241
|
+
export interface PlanSubscriptionsOptions {
|
|
242
|
+
/**
|
|
243
|
+
* Only subscriptions whose `postUrl` starts with this prefix are considered ours. Everything else is
|
|
244
|
+
* left strictly alone — other integrations legitimately subscribe to the same domains.
|
|
245
|
+
*/
|
|
246
|
+
ownedPrefix: string;
|
|
247
|
+
/** Renew when the remaining lifetime is below this. */
|
|
248
|
+
renewHorizonSeconds: number;
|
|
249
|
+
/** Lifetime to request on create and renew. */
|
|
250
|
+
targetLifetimeSeconds: number;
|
|
251
|
+
/** Report when `errorCount / postsCount` exceeds this. Default 0.5. */
|
|
252
|
+
errorRateThreshold?: number;
|
|
253
|
+
/** Don't judge an error rate below this many posts — small samples are noise. Default 25. */
|
|
254
|
+
minPostsForRate?: number;
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Decide what to do, given what we want and what the API currently reports. Pure: no I/O, no clock, no
|
|
258
|
+
* configuration beyond {@link PlanSubscriptionsOptions} — so the whole decision surface is unit-testable
|
|
259
|
+
* and shareable by any consumer that manages its own subscriptions.
|
|
260
|
+
*
|
|
261
|
+
* Deliberate behaviours worth knowing:
|
|
262
|
+
* - Subscriptions outside `ownedPrefix` are **never** modified. If one collides with a desired
|
|
263
|
+
* (domain, model) it is *reported*, because two subscriptions on one domain double-deliver.
|
|
264
|
+
* - `errorCount > 0` alone is **not** a fault — see this module's header.
|
|
265
|
+
* - An already-expired subscription yields `renew`, not `delete`+`create`; the caller should fall back to
|
|
266
|
+
* `create` only if the `PUT` reports the subscription is gone.
|
|
267
|
+
*/
|
|
268
|
+
export declare function planSubscriptions(desired: DesiredSubscription[], actual: Subscription[], nowMs: number, opts: PlanSubscriptionsOptions): SubscriptionAction[];
|