@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 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
@@ -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
@@ -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>;
@@ -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[];