@dszp/netsapiens-lib 0.1.4 → 0.1.6

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.
@@ -38,11 +38,27 @@ export interface EligContext {
38
38
  isReseller: boolean;
39
39
  /** Reseller RUNTIME force: bypass ALL soft categories — never HARD, never the email precondition. */
40
40
  force?: boolean;
41
+ /**
42
+ * Credentials are delivered by LOGIN, not email, so the email precondition does not apply. Set this on
43
+ * an SSO/JIT path, where the account is created from the user's own directory credentials on first
44
+ * sign-in and nothing is mailed. It waives ONLY the email precondition — never HARD, never SOFT.
45
+ *
46
+ * The caller decides WHEN to set it; the engine only guarantees the outcome is the same everywhere it
47
+ * is set. A waived result stays distinguishable via `emailWaived`, so a caller can still branch on
48
+ * "eligible, but there is no address to mail anything to".
49
+ */
50
+ emailNotRequired?: boolean;
41
51
  }
42
52
  export type EligTier = 'ok' | 'hard' | 'soft' | 'precondition';
43
53
  export interface EligResult {
44
54
  activatable: boolean;
45
55
  tier: EligTier;
46
56
  reasons: string[];
57
+ /**
58
+ * The user has no email address and `emailNotRequired` waived the precondition. `tier` is `'ok'` —
59
+ * they are eligible — but there is no address, so a caller must not try to mail them credentials.
60
+ * Absent whenever an address is present or the precondition was not reached.
61
+ */
62
+ emailWaived?: true;
47
63
  }
48
64
  export declare function evaluateEligibility(user: EligUser, ctx: EligContext, config: EligibilityConfig): EligResult;
@@ -42,7 +42,17 @@ export function evaluateEligibility(user, ctx, config) {
42
42
  return { activatable: false, tier: 'soft', reasons: [`extension "${user.ext}" matches excluded pattern "${extHit}"`] };
43
43
  }
44
44
  if (blank(user.email)) {
45
- return { activatable: false, tier: 'precondition', reasons: ['an email address is required to activate'] };
45
+ if (!ctx.emailNotRequired) {
46
+ return { activatable: false, tier: 'precondition', reasons: ['an email address is required to activate'] };
47
+ }
48
+ // Waived: credentials arrive by login, not mail. Eligible — but say the address is missing, so a
49
+ // caller that WOULD have mailed something can still tell.
50
+ return {
51
+ activatable: true,
52
+ tier: 'ok',
53
+ reasons: ['no email address (precondition waived: credentials are not emailed)'],
54
+ emailWaived: true,
55
+ };
46
56
  }
47
57
  return { activatable: true, tier: 'ok', reasons: [] };
48
58
  }
package/dist/index.d.ts CHANGED
@@ -17,7 +17,9 @@ 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 { ensureNsDevice, generateSipPassword, SIP_PW_FIELD, type NsDeviceWriter, type EnsureNsDeviceOptions, type EnsureNsDeviceResult, } from './nsDevice.js';
20
21
  export { NsAuthClient, NsAuthError, type NsAuthClientConfig, type NsTokenResponse } from './nsAuthClient.js';
22
+ 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
23
  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
24
  export { type CallSensitivity, needsFreshAuth, SENSITIVITY_NOTE } from './sensitivity.js';
23
25
  export { toPrincipal, parseOperator, isResellerScope, isAdminScope, type Principal, type Operator, type Scope, } from './principal.js';
package/dist/index.js CHANGED
@@ -16,7 +16,9 @@ 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 { ensureNsDevice, generateSipPassword, SIP_PW_FIELD, } from './nsDevice.js';
19
20
  export { NsAuthClient, NsAuthError } from './nsAuthClient.js';
21
+ export { NsSubscriptionsClient, NsSubscriptionConflictError, SUBSCRIPTION_MODELS, isSubscriptionModel, nsDatetime, parseNsDatetime, subscriptionFromWire, createInputToWire, updateInputToWire, planSubscriptions, } from './nsSubscriptions.js';
20
22
  export { verify, validateJwtFormat, extractContext, assertClaims, verifyHs256Signature, normalizeToken, tokenKey, MemoryVerdictCache, } from './jwt.js';
21
23
  export { needsFreshAuth, SENSITIVITY_NOTE } from './sensitivity.js';
22
24
  export { toPrincipal, parseOperator, isResellerScope, isAdminScope, } from './principal.js';
@@ -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,266 @@
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` is unsuitable here for two concrete reasons: it injects
7
+ * `synchronous: 'yes'` into every POST/PUT, and its `delete()` sends no body — while
8
+ * `DELETE /subscriptions/{id}` *requires* a body (`subscription_id`, plus `domain` for scopes below Super
9
+ * User). Node-free (fetch/URL/crypto only), so it runs unchanged in a Cloudflare Worker.
10
+ *
11
+ * An event subscription tells NetSapiens to POST change events to a URL you own. Notable API properties
12
+ * that shape this module:
13
+ *
14
+ * - **`id` is server-generated**, so a subscription cannot be tagged by the client. Ours are therefore
15
+ * identified by `post-url`, which makes the URL both the address *and* the label.
16
+ * - **Filters are immutable.** `PUT` accepts `post-url` and `subscription-expires-datetime` but not
17
+ * `domain`/`user`/`reseller`, so a filter change means a new subscription.
18
+ * - **Always send an explicit `expiresAt`.** Observed behaviour: an explicit expiry is stored verbatim
19
+ * *even when the request is authenticated with a one-hour OAuth access token* — the expiry is not
20
+ * clamped to the credential's lifetime. Omitting it yields a ~20-year expiry for an API key but only the
21
+ * token's expiry for a timed token, so relying on the default makes lifetime depend on how you
22
+ * authenticated. Renewal, when needed, is a `PUT`, never delete-and-recreate.
23
+ * - ⚠️ **`subscription-geo-support` behaves as `no` when omitted**, despite the API describing the default
24
+ * as `yes`. Send it explicitly if you want geo-redundant delivery (you almost certainly do — otherwise
25
+ * delivery is pinned and stops when that node is down).
26
+ * - ⚠️ **The domain-scoped routes (`/domains/{domain}/subscriptions`, API v45+) are not present on every
27
+ * cluster** — a v44 cluster answers `404 No Route Found` while the flat `/subscriptions` paths work.
28
+ * Prefer the flat methods and treat the domain-scoped ones as an opt-in optimization.
29
+ * - **Datetimes are asymmetric.** Reads observably return ISO-8601 with an offset; the documented *write*
30
+ * format is `YYYY-MM-DD HH:MM:SS`. {@link parseNsDatetime} accepts both; {@link nsDatetime} emits the
31
+ * documented form.
32
+ * - **`error-count` > 0 is normal on a healthy subscription** — a live example sat at 7 errors across 7195
33
+ * posts while `status` stayed `active`. Treat `status === 'error'` or a sustained error *rate* as the
34
+ * signal, and never reset the counters as routine maintenance: they are the only history the API keeps.
35
+ */
36
+ import type { Rec } from './model.js';
37
+ import { NsApiError } from './nsClient.js';
38
+ /** Event types a subscription can carry. One subscription carries exactly one model. */
39
+ export type SubscriptionModel = 'agent' | 'auditlog' | 'auditlog_lite' | 'call' | 'call_origid' | 'cdr' | 'message' | 'messagesession' | 'subscriber' | 'presence' | 'voicemail';
40
+ /** Every valid `model` value, for validating configuration before it reaches the API. */
41
+ export declare const SUBSCRIPTION_MODELS: readonly SubscriptionModel[];
42
+ /** Narrowing guard for a configured model string. */
43
+ export declare function isSubscriptionModel(v: unknown): v is SubscriptionModel;
44
+ /** Server-reported delivery health. `pending` until the first successful post. */
45
+ export type SubscriptionStatus = 'pending' | 'active' | 'error';
46
+ /**
47
+ * A subscription, with the API's hyphenated wire keys mapped to camelCase. Datetimes are kept as the
48
+ * **raw strings** the API returned (parse with {@link parseNsDatetime} when you need a `Date`), and `raw`
49
+ * carries the untouched record so a caller never loses a field this type hasn't modelled.
50
+ */
51
+ export interface Subscription {
52
+ id: string;
53
+ model?: string;
54
+ postUrl?: string;
55
+ geoSupport?: string;
56
+ userScope?: string;
57
+ reseller?: string;
58
+ domain?: string;
59
+ user?: string;
60
+ /** Raw `subscription-creation-datetime`. */
61
+ createdAt?: string;
62
+ /** Raw `subscription-expires-datetime`. */
63
+ expiresAt?: string;
64
+ preferredServer?: string;
65
+ /** Read-only: the node currently delivering. Changes on failover. */
66
+ currentActiveServer?: string;
67
+ status?: string;
68
+ errorCount?: number;
69
+ postsCount?: number;
70
+ /** The untouched API record. */
71
+ raw: Rec;
72
+ }
73
+ /** Fields accepted when creating. `domain`/`user`/`reseller` are the (immutable) event filters. */
74
+ export interface CreateSubscriptionInput {
75
+ model: SubscriptionModel;
76
+ /** Absolute https URL NetSapiens will POST to. */
77
+ postUrl: string;
78
+ /** Restrict to one domain. `'*'` means all domains and requires Super User scope. */
79
+ domain?: string;
80
+ /** Restrict to one user/extension. Defaults to all. */
81
+ user?: string;
82
+ /** Restrict to one reseller. `'*'` requires Super User scope. */
83
+ reseller?: string;
84
+ /**
85
+ * Geo-redundant delivery across nodes. ⚠️ Behaves as `'no'` when omitted, despite the API documenting
86
+ * `'yes'` as the default — send `'yes'` explicitly unless you deliberately want delivery pinned.
87
+ */
88
+ geoSupport?: 'yes' | 'no';
89
+ /**
90
+ * Explicit expiry, and you should always set one. It is honoured verbatim even when the request is
91
+ * authenticated with a short-lived OAuth token. Omitting it makes the lifetime depend on the credential
92
+ * (API key ⇒ ~20 years; timed token ⇒ that token's expiry).
93
+ */
94
+ expiresAt?: Date | string;
95
+ /** Preferred delivering node. A preference, not a pin — other nodes deliver during instability. */
96
+ preferredServer?: string;
97
+ }
98
+ /** Fields `PUT` accepts. The event filters are deliberately absent — they cannot be changed. */
99
+ export interface UpdateSubscriptionInput {
100
+ model?: SubscriptionModel;
101
+ postUrl?: string;
102
+ geoSupport?: 'yes' | 'no';
103
+ expiresAt?: Date | string;
104
+ preferredServer?: string;
105
+ /** Only `0` is accepted — a reset. Prefer leaving counters alone; they are the only history kept. */
106
+ errorCount?: 0;
107
+ /** Only `0` is accepted — a reset. */
108
+ postsCount?: 0;
109
+ }
110
+ /**
111
+ * Format a `Date` as the documented write format `YYYY-MM-DD HH:MM:SS`, **in UTC**.
112
+ *
113
+ * The API documents no timezone for this field. Emitting UTC is the only self-consistent choice, and it
114
+ * round-trips with {@link parseNsDatetime}, which also reads a bare timestamp as UTC.
115
+ */
116
+ export declare function nsDatetime(d: Date): string;
117
+ /**
118
+ * Parse either datetime shape the API uses: the documented `YYYY-MM-DD HH:MM:SS` (read as **UTC**) or the
119
+ * ISO-8601-with-offset form that reads actually return. Returns `undefined` rather than an Invalid Date so
120
+ * callers fail closed on a value they can't interpret.
121
+ */
122
+ export declare function parseNsDatetime(s: string | undefined | null): Date | undefined;
123
+ /** Map one API record to {@link Subscription}. Tolerant: an unmodelled or missing field is simply absent. */
124
+ export declare function subscriptionFromWire(rec: Rec): Subscription;
125
+ /** Map {@link CreateSubscriptionInput} to the hyphenated request body. */
126
+ export declare function createInputToWire(input: CreateSubscriptionInput): Rec;
127
+ /** Map {@link UpdateSubscriptionInput} to the hyphenated request body. */
128
+ export declare function updateInputToWire(changes: UpdateSubscriptionInput): Rec;
129
+ export interface NsSubscriptionsClientConfig {
130
+ /** API host, e.g. `"api.example.com"`. Base URL becomes `https://{server}/ns-api/v2`. */
131
+ server: string;
132
+ /** Bearer token — an API key, or an OAuth access token, with scope to manage subscriptions. */
133
+ token: string;
134
+ /** Injectable for tests / non-global fetch. */
135
+ fetchImpl?: typeof fetch;
136
+ /** Page size for list calls. Default 500. */
137
+ pageSize?: number;
138
+ }
139
+ /** Thrown by {@link NsSubscriptionsClient.create} on the API's 409 "already exists" response. */
140
+ export declare class NsSubscriptionConflictError extends NsApiError {
141
+ constructor(message: string, path: string, body: unknown);
142
+ }
143
+ /**
144
+ * Read/write client for `/subscriptions`.
145
+ *
146
+ * ```ts
147
+ * const subs = new NsSubscriptionsClient({ server: 'api.example.com', token: key });
148
+ * const mine = (await subs.list()).filter((s) => s.postUrl?.startsWith('https://hooks.example.com/'));
149
+ * ```
150
+ */
151
+ export declare class NsSubscriptionsClient {
152
+ #private;
153
+ constructor(cfg: NsSubscriptionsClientConfig);
154
+ /**
155
+ * Every subscription the credential can see, paged to completion.
156
+ *
157
+ * Paging matters: with no local registry this list *is* the source of truth, so a partial read would
158
+ * make a reconciler create duplicates or skip renewals. The loop is defensive in both directions — it
159
+ * stops on a short page, and also if a server that ignores the paging parameters returns the same
160
+ * records again.
161
+ */
162
+ list(): Promise<Subscription[]>;
163
+ /** Subscriptions filtered to one domain. ⚠️ Requires API v45+; a v44 cluster returns 404. */
164
+ listForDomain(domain: string): Promise<Subscription[]>;
165
+ /** Read one subscription by id. */
166
+ get(id: string): Promise<Subscription>;
167
+ /**
168
+ * Create a subscription. Throws {@link NsSubscriptionConflictError} on 409, which the API returns when a
169
+ * subscription with a matching set of parameters already exists — usually meaning the desired state is
170
+ * already in place.
171
+ */
172
+ create(input: CreateSubscriptionInput): Promise<Subscription>;
173
+ /** Create against the domain-scoped path. ⚠️ Requires API v45+; a v44 cluster returns 404. */
174
+ createForDomain(domain: string, input: CreateSubscriptionInput): Promise<Subscription>;
175
+ /**
176
+ * Update a subscription — this is how renewal works (a new `subscription-expires-datetime`) and how a
177
+ * callback URL is rotated (`post-url`). The event filters cannot be changed.
178
+ */
179
+ update(id: string, changes: UpdateSubscriptionInput): Promise<unknown>;
180
+ /** Update against the domain-scoped path. ⚠️ Requires API v45+; a v44 cluster returns 404. */
181
+ updateForDomain(domain: string, id: string, changes: UpdateSubscriptionInput): Promise<unknown>;
182
+ /**
183
+ * Delete a subscription.
184
+ *
185
+ * Note the body: this endpoint takes `subscription_id` (and `domain`, required for scopes below Super
186
+ * User) *in addition to* the path id. That is why this client exists rather than reusing a generic write
187
+ * client whose `delete()` sends no body.
188
+ */
189
+ remove(id: string, opts?: {
190
+ domain?: string;
191
+ }): Promise<unknown>;
192
+ }
193
+ /** One subscription we want to exist. */
194
+ export interface DesiredSubscription {
195
+ domain: string;
196
+ model: SubscriptionModel;
197
+ /** The exact callback URL this (domain, model) should post to. */
198
+ postUrl: string;
199
+ }
200
+ /** An action the caller should execute. Every variant carries a human-readable `reason` for logging. */
201
+ export type SubscriptionAction = {
202
+ kind: 'create';
203
+ domain: string;
204
+ model: SubscriptionModel;
205
+ postUrl: string;
206
+ expiresAt: string;
207
+ reason: string;
208
+ } | {
209
+ kind: 'renew';
210
+ id: string;
211
+ domain: string;
212
+ expiresAt: string;
213
+ reason: string;
214
+ } | {
215
+ kind: 'repair-url';
216
+ id: string;
217
+ domain: string;
218
+ postUrl: string;
219
+ reason: string;
220
+ } | {
221
+ kind: 'delete';
222
+ id: string;
223
+ domain: string;
224
+ reason: string;
225
+ } | {
226
+ kind: 'report';
227
+ id: string;
228
+ domain: string;
229
+ reason: string;
230
+ status?: string;
231
+ errorCount?: number;
232
+ postsCount?: number;
233
+ } | {
234
+ kind: 'noop';
235
+ id: string;
236
+ domain: string;
237
+ reason: string;
238
+ };
239
+ export interface PlanSubscriptionsOptions {
240
+ /**
241
+ * Only subscriptions whose `postUrl` starts with this prefix are considered ours. Everything else is
242
+ * left strictly alone — other integrations legitimately subscribe to the same domains.
243
+ */
244
+ ownedPrefix: string;
245
+ /** Renew when the remaining lifetime is below this. */
246
+ renewHorizonSeconds: number;
247
+ /** Lifetime to request on create and renew. */
248
+ targetLifetimeSeconds: number;
249
+ /** Report when `errorCount / postsCount` exceeds this. Default 0.5. */
250
+ errorRateThreshold?: number;
251
+ /** Don't judge an error rate below this many posts — small samples are noise. Default 25. */
252
+ minPostsForRate?: number;
253
+ }
254
+ /**
255
+ * Decide what to do, given what we want and what the API currently reports. Pure: no I/O, no clock, no
256
+ * configuration beyond {@link PlanSubscriptionsOptions} — so the whole decision surface is unit-testable
257
+ * and shareable by any consumer that manages its own subscriptions.
258
+ *
259
+ * Deliberate behaviours worth knowing:
260
+ * - Subscriptions outside `ownedPrefix` are **never** modified. If one collides with a desired
261
+ * (domain, model) it is *reported*, because two subscriptions on one domain double-deliver.
262
+ * - `errorCount > 0` alone is **not** a fault — see this module's header.
263
+ * - An already-expired subscription yields `renew`, not `delete`+`create`; the caller should fall back to
264
+ * `create` only if the `PUT` reports the subscription is gone.
265
+ */
266
+ export declare function planSubscriptions(desired: DesiredSubscription[], actual: Subscription[], nowMs: number, opts: PlanSubscriptionsOptions): SubscriptionAction[];
@@ -0,0 +1,444 @@
1
+ import { NsApiError, assertBareServer, asArray } from './nsClient.js';
2
+ /** Every valid `model` value, for validating configuration before it reaches the API. */
3
+ export const SUBSCRIPTION_MODELS = [
4
+ 'agent',
5
+ 'auditlog',
6
+ 'auditlog_lite',
7
+ 'call',
8
+ 'call_origid',
9
+ 'cdr',
10
+ 'message',
11
+ 'messagesession',
12
+ 'subscriber',
13
+ 'presence',
14
+ 'voicemail',
15
+ ];
16
+ /** Narrowing guard for a configured model string. */
17
+ export function isSubscriptionModel(v) {
18
+ return typeof v === 'string' && SUBSCRIPTION_MODELS.includes(v);
19
+ }
20
+ const enc = encodeURIComponent;
21
+ const two = (n) => String(n).padStart(2, '0');
22
+ /**
23
+ * Format a `Date` as the documented write format `YYYY-MM-DD HH:MM:SS`, **in UTC**.
24
+ *
25
+ * The API documents no timezone for this field. Emitting UTC is the only self-consistent choice, and it
26
+ * round-trips with {@link parseNsDatetime}, which also reads a bare timestamp as UTC.
27
+ */
28
+ export function nsDatetime(d) {
29
+ return (`${d.getUTCFullYear()}-${two(d.getUTCMonth() + 1)}-${two(d.getUTCDate())}` +
30
+ ` ${two(d.getUTCHours())}:${two(d.getUTCMinutes())}:${two(d.getUTCSeconds())}`);
31
+ }
32
+ /**
33
+ * Parse either datetime shape the API uses: the documented `YYYY-MM-DD HH:MM:SS` (read as **UTC**) or the
34
+ * ISO-8601-with-offset form that reads actually return. Returns `undefined` rather than an Invalid Date so
35
+ * callers fail closed on a value they can't interpret.
36
+ */
37
+ export function parseNsDatetime(s) {
38
+ if (typeof s !== 'string')
39
+ return undefined;
40
+ const t = s.trim();
41
+ if (!t)
42
+ return undefined;
43
+ // Bare "YYYY-MM-DD HH:MM:SS" (optionally with fractional seconds) carries no zone → treat as UTC.
44
+ const bare = /^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})(?::(\d{2}))?(?:\.\d+)?$/.exec(t);
45
+ if (bare) {
46
+ const ms = Date.UTC(Number(bare[1]), Number(bare[2]) - 1, Number(bare[3]), Number(bare[4]), Number(bare[5]), Number(bare[6] ?? 0));
47
+ return Number.isNaN(ms) ? undefined : new Date(ms);
48
+ }
49
+ const ms = Date.parse(t);
50
+ return Number.isNaN(ms) ? undefined : new Date(ms);
51
+ }
52
+ function num(v) {
53
+ if (typeof v === 'number' && Number.isFinite(v))
54
+ return v;
55
+ if (typeof v === 'string' && v.trim() !== '') {
56
+ const n = Number(v);
57
+ if (Number.isFinite(n))
58
+ return n;
59
+ }
60
+ return undefined;
61
+ }
62
+ function str(v) {
63
+ return typeof v === 'string' && v !== '' ? v : undefined;
64
+ }
65
+ /** Map one API record to {@link Subscription}. Tolerant: an unmodelled or missing field is simply absent. */
66
+ export function subscriptionFromWire(rec) {
67
+ return {
68
+ id: String(rec['id'] ?? ''),
69
+ ...(str(rec['model']) ? { model: str(rec['model']) } : {}),
70
+ ...(str(rec['post-url']) ? { postUrl: str(rec['post-url']) } : {}),
71
+ ...(str(rec['subscription-geo-support']) ? { geoSupport: str(rec['subscription-geo-support']) } : {}),
72
+ ...(str(rec['user-scope']) ? { userScope: str(rec['user-scope']) } : {}),
73
+ ...(str(rec['reseller']) ? { reseller: str(rec['reseller']) } : {}),
74
+ ...(str(rec['domain']) ? { domain: str(rec['domain']) } : {}),
75
+ ...(str(rec['user']) ? { user: str(rec['user']) } : {}),
76
+ ...(str(rec['subscription-creation-datetime']) ? { createdAt: str(rec['subscription-creation-datetime']) } : {}),
77
+ ...(str(rec['subscription-expires-datetime']) ? { expiresAt: str(rec['subscription-expires-datetime']) } : {}),
78
+ ...(str(rec['preferred-server']) ? { preferredServer: str(rec['preferred-server']) } : {}),
79
+ ...(str(rec['current-active-server']) ? { currentActiveServer: str(rec['current-active-server']) } : {}),
80
+ ...(str(rec['status']) ? { status: str(rec['status']) } : {}),
81
+ ...(num(rec['error-count']) !== undefined ? { errorCount: num(rec['error-count']) } : {}),
82
+ ...(num(rec['posts-count']) !== undefined ? { postsCount: num(rec['posts-count']) } : {}),
83
+ raw: rec,
84
+ };
85
+ }
86
+ function expiryToWire(v) {
87
+ if (v === undefined)
88
+ return undefined;
89
+ return typeof v === 'string' ? v : nsDatetime(v);
90
+ }
91
+ /** Map {@link CreateSubscriptionInput} to the hyphenated request body. */
92
+ export function createInputToWire(input) {
93
+ const body = { model: input.model, 'post-url': input.postUrl };
94
+ if (input.domain !== undefined)
95
+ body['domain'] = input.domain;
96
+ if (input.user !== undefined)
97
+ body['user'] = input.user;
98
+ if (input.reseller !== undefined)
99
+ body['reseller'] = input.reseller;
100
+ if (input.geoSupport !== undefined)
101
+ body['subscription-geo-support'] = input.geoSupport;
102
+ const exp = expiryToWire(input.expiresAt);
103
+ if (exp !== undefined)
104
+ body['subscription-expires-datetime'] = exp;
105
+ if (input.preferredServer !== undefined)
106
+ body['preferred-server'] = input.preferredServer;
107
+ return body;
108
+ }
109
+ /** Map {@link UpdateSubscriptionInput} to the hyphenated request body. */
110
+ export function updateInputToWire(changes) {
111
+ const body = {};
112
+ if (changes.model !== undefined)
113
+ body['model'] = changes.model;
114
+ if (changes.postUrl !== undefined)
115
+ body['post-url'] = changes.postUrl;
116
+ if (changes.geoSupport !== undefined)
117
+ body['subscription-geo-support'] = changes.geoSupport;
118
+ const exp = expiryToWire(changes.expiresAt);
119
+ if (exp !== undefined)
120
+ body['subscription-expires-datetime'] = exp;
121
+ if (changes.preferredServer !== undefined)
122
+ body['preferred-server'] = changes.preferredServer;
123
+ if (changes.errorCount !== undefined)
124
+ body['error-count'] = changes.errorCount;
125
+ if (changes.postsCount !== undefined)
126
+ body['posts-count'] = changes.postsCount;
127
+ return body;
128
+ }
129
+ /** Thrown by {@link NsSubscriptionsClient.create} on the API's 409 "already exists" response. */
130
+ export class NsSubscriptionConflictError extends NsApiError {
131
+ constructor(message, path, body) {
132
+ super(message, 409, path, body, 'POST');
133
+ this.name = 'NsSubscriptionConflictError';
134
+ }
135
+ }
136
+ /**
137
+ * Read/write client for `/subscriptions`.
138
+ *
139
+ * ```ts
140
+ * const subs = new NsSubscriptionsClient({ server: 'api.example.com', token: key });
141
+ * const mine = (await subs.list()).filter((s) => s.postUrl?.startsWith('https://hooks.example.com/'));
142
+ * ```
143
+ */
144
+ export class NsSubscriptionsClient {
145
+ #baseUrl;
146
+ #token;
147
+ #fetchImpl;
148
+ #pageSize;
149
+ constructor(cfg) {
150
+ this.#baseUrl = `https://${assertBareServer(cfg.server)}/ns-api/v2`;
151
+ this.#token = cfg.token;
152
+ this.#fetchImpl = cfg.fetchImpl ?? fetch;
153
+ this.#pageSize = cfg.pageSize && cfg.pageSize > 0 ? cfg.pageSize : 500;
154
+ }
155
+ /**
156
+ * Every subscription the credential can see, paged to completion.
157
+ *
158
+ * Paging matters: with no local registry this list *is* the source of truth, so a partial read would
159
+ * make a reconciler create duplicates or skip renewals. The loop is defensive in both directions — it
160
+ * stops on a short page, and also if a server that ignores the paging parameters returns the same
161
+ * records again.
162
+ */
163
+ list() {
164
+ return this.#listPaged('/subscriptions');
165
+ }
166
+ /** Subscriptions filtered to one domain. ⚠️ Requires API v45+; a v44 cluster returns 404. */
167
+ listForDomain(domain) {
168
+ return this.#listPaged(`/domains/${enc(domain)}/subscriptions`);
169
+ }
170
+ /** Read one subscription by id. */
171
+ async get(id) {
172
+ const rec = await this.#request('GET', `/subscriptions/${enc(id)}`);
173
+ return subscriptionFromWire(rec);
174
+ }
175
+ /**
176
+ * Create a subscription. Throws {@link NsSubscriptionConflictError} on 409, which the API returns when a
177
+ * subscription with a matching set of parameters already exists — usually meaning the desired state is
178
+ * already in place.
179
+ */
180
+ create(input) {
181
+ return this.#create('/subscriptions', createInputToWire(input));
182
+ }
183
+ /** Create against the domain-scoped path. ⚠️ Requires API v45+; a v44 cluster returns 404. */
184
+ createForDomain(domain, input) {
185
+ return this.#create(`/domains/${enc(domain)}/subscriptions`, createInputToWire(input));
186
+ }
187
+ /**
188
+ * Update a subscription — this is how renewal works (a new `subscription-expires-datetime`) and how a
189
+ * callback URL is rotated (`post-url`). The event filters cannot be changed.
190
+ */
191
+ update(id, changes) {
192
+ return this.#request('PUT', `/subscriptions/${enc(id)}`, updateInputToWire(changes));
193
+ }
194
+ /** Update against the domain-scoped path. ⚠️ Requires API v45+; a v44 cluster returns 404. */
195
+ updateForDomain(domain, id, changes) {
196
+ return this.#request('PUT', `/domains/${enc(domain)}/subscriptions/${enc(id)}`, updateInputToWire(changes));
197
+ }
198
+ /**
199
+ * Delete a subscription.
200
+ *
201
+ * Note the body: this endpoint takes `subscription_id` (and `domain`, required for scopes below Super
202
+ * User) *in addition to* the path id. That is why this client exists rather than reusing a generic write
203
+ * client whose `delete()` sends no body.
204
+ */
205
+ remove(id, opts = {}) {
206
+ const body = { subscription_id: id };
207
+ if (opts.domain !== undefined)
208
+ body['domain'] = opts.domain;
209
+ return this.#request('DELETE', `/subscriptions/${enc(id)}`, body);
210
+ }
211
+ async #create(path, body) {
212
+ try {
213
+ const rec = await this.#request('POST', path, body);
214
+ return subscriptionFromWire(rec);
215
+ }
216
+ catch (e) {
217
+ if (e instanceof NsApiError && e.status === 409) {
218
+ throw new NsSubscriptionConflictError(e.message, path, e.body);
219
+ }
220
+ throw e;
221
+ }
222
+ }
223
+ async #listPaged(path) {
224
+ const out = [];
225
+ const seen = new Set();
226
+ const limit = this.#pageSize;
227
+ for (let start = 0, guard = 0; guard < 200; guard++, start += limit) {
228
+ const page = asArray(await this.#request('GET', path, undefined, { limit, start }));
229
+ if (page.length === 0)
230
+ break;
231
+ let added = 0;
232
+ for (const rec of page) {
233
+ const sub = subscriptionFromWire(rec);
234
+ if (!sub.id || seen.has(sub.id))
235
+ continue;
236
+ seen.add(sub.id);
237
+ out.push(sub);
238
+ added++;
239
+ }
240
+ // Short page ⇒ done. No new ids ⇒ the server ignored our paging parameters; stop rather than loop.
241
+ if (page.length < limit || added === 0)
242
+ break;
243
+ }
244
+ return out;
245
+ }
246
+ async #request(method, path, body, query) {
247
+ const url = new URL(this.#baseUrl + path);
248
+ for (const [k, v] of Object.entries(query ?? {}))
249
+ url.searchParams.set(k, String(v));
250
+ // Call via a local, NOT `this.#fetchImpl(...)`: invoking the global fetch as a method of this
251
+ // instance throws "Illegal invocation" in workerd (the global fetch requires a global `this`).
252
+ const doFetch = this.#fetchImpl;
253
+ const res = await doFetch(url.toString(), {
254
+ method,
255
+ headers: {
256
+ Authorization: `Bearer ${this.#token}`,
257
+ Accept: 'application/json',
258
+ ...(body ? { 'Content-Type': 'application/json' } : {}),
259
+ },
260
+ ...(body ? { body: JSON.stringify(body) } : {}),
261
+ });
262
+ const text = await res.text();
263
+ let parsed = text;
264
+ if (text) {
265
+ try {
266
+ parsed = JSON.parse(text);
267
+ }
268
+ catch {
269
+ /* some endpoints return empty / plain bodies */
270
+ }
271
+ }
272
+ if (!res.ok) {
273
+ const detail = (typeof parsed === 'object' && parsed !== null ? JSON.stringify(parsed) : String(parsed)).slice(0, 500);
274
+ const hint = res.status === 401
275
+ ? ' (token expired/invalid or domain out of scope)'
276
+ : res.status === 403
277
+ ? ' (token lacks permission)'
278
+ : res.status === 409
279
+ ? ' (a subscription with matching parameters already exists)'
280
+ : '';
281
+ throw new NsApiError(`${method} ${path} → ${res.status}${hint}: ${detail}`, res.status, path, parsed, method);
282
+ }
283
+ return parsed;
284
+ }
285
+ }
286
+ function pickCanonical(subs) {
287
+ // Prefer an active one, then the most recently created; mirrors how the rest of the stack breaks ties.
288
+ const score = (s) => (s.status === 'active' ? 2 : s.status === 'pending' ? 1 : 0);
289
+ return [...subs].sort((a, b) => {
290
+ const d = score(b) - score(a);
291
+ if (d !== 0)
292
+ return d;
293
+ const at = parseNsDatetime(a.createdAt)?.getTime() ?? 0;
294
+ const bt = parseNsDatetime(b.createdAt)?.getTime() ?? 0;
295
+ return bt - at;
296
+ })[0];
297
+ }
298
+ /**
299
+ * Decide what to do, given what we want and what the API currently reports. Pure: no I/O, no clock, no
300
+ * configuration beyond {@link PlanSubscriptionsOptions} — so the whole decision surface is unit-testable
301
+ * and shareable by any consumer that manages its own subscriptions.
302
+ *
303
+ * Deliberate behaviours worth knowing:
304
+ * - Subscriptions outside `ownedPrefix` are **never** modified. If one collides with a desired
305
+ * (domain, model) it is *reported*, because two subscriptions on one domain double-deliver.
306
+ * - `errorCount > 0` alone is **not** a fault — see this module's header.
307
+ * - An already-expired subscription yields `renew`, not `delete`+`create`; the caller should fall back to
308
+ * `create` only if the `PUT` reports the subscription is gone.
309
+ */
310
+ export function planSubscriptions(desired, actual, nowMs, opts) {
311
+ const rateThreshold = opts.errorRateThreshold ?? 0.5;
312
+ const minPosts = opts.minPostsForRate ?? 25;
313
+ const targetExpiry = nsDatetime(new Date(nowMs + opts.targetLifetimeSeconds * 1000));
314
+ const isOurs = (s) => typeof s.postUrl === 'string' && s.postUrl.startsWith(opts.ownedPrefix);
315
+ const ours = actual.filter(isOurs);
316
+ const foreign = actual.filter((s) => !isOurs(s));
317
+ const key = (domain, model) => `${domain.toLowerCase()}${model}`;
318
+ const actions = [];
319
+ const claimed = new Set();
320
+ for (const want of desired) {
321
+ const k = key(want.domain, want.model);
322
+ claimed.add(k);
323
+ const matches = ours.filter((s) => key(s.domain ?? '', s.model ?? '') === k);
324
+ for (const f of foreign) {
325
+ if (key(f.domain ?? '', f.model ?? '') === k) {
326
+ actions.push({
327
+ kind: 'report',
328
+ id: f.id,
329
+ domain: want.domain,
330
+ reason: 'another integration already subscribes to this domain+model; events will be delivered twice',
331
+ ...(f.status !== undefined ? { status: f.status } : {}),
332
+ });
333
+ }
334
+ }
335
+ if (matches.length === 0) {
336
+ actions.push({
337
+ kind: 'create',
338
+ domain: want.domain,
339
+ model: want.model,
340
+ postUrl: want.postUrl,
341
+ expiresAt: targetExpiry,
342
+ reason: 'no subscription exists for this domain+model',
343
+ });
344
+ continue;
345
+ }
346
+ const canonical = pickCanonical(matches);
347
+ for (const extra of matches) {
348
+ if (extra.id !== canonical.id) {
349
+ actions.push({
350
+ kind: 'delete',
351
+ id: extra.id,
352
+ domain: extra.domain ?? want.domain,
353
+ reason: 'duplicate of our own subscription for this domain+model',
354
+ });
355
+ }
356
+ }
357
+ // Health is reported independently of whether the record also needs a url/expiry change.
358
+ const errs = canonical.errorCount ?? 0;
359
+ const posts = canonical.postsCount ?? 0;
360
+ if (canonical.status === 'error') {
361
+ actions.push({
362
+ kind: 'report',
363
+ id: canonical.id,
364
+ domain: canonical.domain ?? want.domain,
365
+ reason: 'delivery is failing (status=error)',
366
+ ...(canonical.status !== undefined ? { status: canonical.status } : {}),
367
+ errorCount: errs,
368
+ postsCount: posts,
369
+ });
370
+ }
371
+ else if (posts >= minPosts && errs / posts > rateThreshold) {
372
+ actions.push({
373
+ kind: 'report',
374
+ id: canonical.id,
375
+ domain: canonical.domain ?? want.domain,
376
+ reason: `sustained delivery error rate ${errs}/${posts}`,
377
+ ...(canonical.status !== undefined ? { status: canonical.status } : {}),
378
+ errorCount: errs,
379
+ postsCount: posts,
380
+ });
381
+ }
382
+ if (canonical.postUrl !== want.postUrl) {
383
+ actions.push({
384
+ kind: 'repair-url',
385
+ id: canonical.id,
386
+ domain: canonical.domain ?? want.domain,
387
+ postUrl: want.postUrl,
388
+ reason: 'callback URL differs from the configured one (deploy moved, or secret rotated)',
389
+ });
390
+ continue;
391
+ }
392
+ const expiry = parseNsDatetime(canonical.expiresAt);
393
+ if (!expiry) {
394
+ actions.push({
395
+ kind: 'renew',
396
+ id: canonical.id,
397
+ domain: canonical.domain ?? want.domain,
398
+ expiresAt: targetExpiry,
399
+ reason: 'expiry missing or unparseable',
400
+ });
401
+ continue;
402
+ }
403
+ const remainingSeconds = (expiry.getTime() - nowMs) / 1000;
404
+ if (remainingSeconds <= 0) {
405
+ actions.push({
406
+ kind: 'renew',
407
+ id: canonical.id,
408
+ domain: canonical.domain ?? want.domain,
409
+ expiresAt: targetExpiry,
410
+ reason: 'already expired',
411
+ });
412
+ }
413
+ else if (remainingSeconds < opts.renewHorizonSeconds) {
414
+ actions.push({
415
+ kind: 'renew',
416
+ id: canonical.id,
417
+ domain: canonical.domain ?? want.domain,
418
+ expiresAt: targetExpiry,
419
+ reason: `expires in ${Math.floor(remainingSeconds)}s, inside the renewal horizon`,
420
+ });
421
+ }
422
+ else {
423
+ actions.push({
424
+ kind: 'noop',
425
+ id: canonical.id,
426
+ domain: canonical.domain ?? want.domain,
427
+ reason: 'present, correct, and not near expiry',
428
+ });
429
+ }
430
+ }
431
+ // Ours, but no longer wanted.
432
+ for (const s of ours) {
433
+ const k = key(s.domain ?? '', s.model ?? '');
434
+ if (!claimed.has(k)) {
435
+ actions.push({
436
+ kind: 'delete',
437
+ id: s.id,
438
+ domain: s.domain ?? '',
439
+ reason: 'ours, but this domain+model is no longer configured',
440
+ });
441
+ }
442
+ }
443
+ return actions;
444
+ }
@@ -13,6 +13,7 @@
13
13
  * instead of a 202 with replication lag. Shares the read client's SSRF guard and `NsApiError`.
14
14
  */
15
15
  import type { Rec } from './model.js';
16
+ import { type EnsureNsDeviceOptions, type EnsureNsDeviceResult } from './nsDevice.js';
16
17
  export interface NsWriteClientConfig {
17
18
  /** API host, e.g. "api.example.com". Base URL becomes https://{server}/ns-api/v2. */
18
19
  server: string;
@@ -40,6 +41,26 @@ export declare class NsWriteClient {
40
41
  * optional fields (e.g. an emergency caller-id).
41
42
  */
42
43
  createDevice(domain: string, user: string, device: string, extra?: Rec): Promise<Rec>;
44
+ /**
45
+ * Update a device in place.
46
+ *
47
+ * The reason this exists rather than callers using `put()`: rotating
48
+ * `device-sip-registration-password` must **not** be done by deleting and recreating the device, which
49
+ * would discard everything else on it — emergency caller id, the provisioning MAC/model link, SRTP and
50
+ * transport settings. A PUT changes the one field and preserves the rest.
51
+ */
52
+ updateDevice(domain: string, user: string, device: string, changes: Rec): Promise<Rec>;
43
53
  /** Delete a device. */
44
54
  deleteDevice(domain: string, user: string, device: string): Promise<Rec>;
55
+ /**
56
+ * Convenience wrapper over {@link ensureNsDevice} — ensure a device exists and return its SIP password,
57
+ * optionally rotating it. See that function for the semantics, and for why rotation matters.
58
+ *
59
+ * Deliberately a **one-line delegation, not an implementation**. Every other method on this class is
60
+ * exactly one HTTP request; this one is several with branching, so the logic lives in a standalone
61
+ * function that composes over any writer (a consumer may have its own client) and that consumers can mock as
62
+ * a plain 4-method object instead of stubbing a whole client. This method exists only so the capability
63
+ * is discoverable from the client you already hold.
64
+ */
65
+ ensureDevice(opts: EnsureNsDeviceOptions): Promise<EnsureNsDeviceResult>;
45
66
  }
@@ -1,4 +1,5 @@
1
1
  import { NsApiError, assertBareServer, asArray } from './nsClient.js';
2
+ import { ensureNsDevice } from './nsDevice.js';
2
3
  const enc = encodeURIComponent;
3
4
  export class NsWriteClient {
4
5
  #baseUrl;
@@ -41,10 +42,34 @@ export class NsWriteClient {
41
42
  createDevice(domain, user, device, extra = {}) {
42
43
  return this.post(`/domains/${enc(domain)}/users/${enc(user)}/devices`, { device, ...extra });
43
44
  }
45
+ /**
46
+ * Update a device in place.
47
+ *
48
+ * The reason this exists rather than callers using `put()`: rotating
49
+ * `device-sip-registration-password` must **not** be done by deleting and recreating the device, which
50
+ * would discard everything else on it — emergency caller id, the provisioning MAC/model link, SRTP and
51
+ * transport settings. A PUT changes the one field and preserves the rest.
52
+ */
53
+ updateDevice(domain, user, device, changes) {
54
+ return this.put(`/domains/${enc(domain)}/users/${enc(user)}/devices/${enc(device)}`, changes);
55
+ }
44
56
  /** Delete a device. */
45
57
  deleteDevice(domain, user, device) {
46
58
  return this.delete(`/domains/${enc(domain)}/users/${enc(user)}/devices/${enc(device)}`);
47
59
  }
60
+ /**
61
+ * Convenience wrapper over {@link ensureNsDevice} — ensure a device exists and return its SIP password,
62
+ * optionally rotating it. See that function for the semantics, and for why rotation matters.
63
+ *
64
+ * Deliberately a **one-line delegation, not an implementation**. Every other method on this class is
65
+ * exactly one HTTP request; this one is several with branching, so the logic lives in a standalone
66
+ * function that composes over any writer (a consumer may have its own client) and that consumers can mock as
67
+ * a plain 4-method object instead of stubbing a whole client. This method exists only so the capability
68
+ * is discoverable from the client you already hold.
69
+ */
70
+ ensureDevice(opts) {
71
+ return ensureNsDevice(this, opts);
72
+ }
48
73
  async #request(method, path, body, query) {
49
74
  const url = new URL(this.#baseUrl + path);
50
75
  for (const [k, v] of Object.entries(query ?? {}))
package/dist/resolver.js CHANGED
@@ -764,12 +764,25 @@ function aaApp(app, dest, idx, b) {
764
764
  * * → unassigned key ("Unknown Input")
765
765
  * Default → no-key timeout
766
766
  * Apps: Announce → play-message; Prompt→own prompt id → repeat greeting; else via aaApp().
767
+ *
768
+ * A `Prompt` option pointing at a DIFFERENT prompt id that has its own `Prompt_<id>.` rule family in
769
+ * this same dialplan is a SECOND-LEVEL MENU — the portal's "Add Tier" on a keypress. Recurse into it
770
+ * rather than rendering a dead-end "Play prompt <id>" leaf. The tier's prompt id exists ONLY in the
771
+ * dialplan; the /autoattendants detail nests the tier as `option-N.auto-attendant` with no id at all,
772
+ * so the two are joined by the keypress digit. Portal-created tiers are one level deep, but the
773
+ * dialplan grammar is not, so this recurses without a depth bound; claim()/enter() make a back-link
774
+ * to an ancestor tier draw as a "loops back" reference leaf instead of recursing forever.
775
+ *
767
776
  * `detailTier` (the /autoattendants option-N structure, when present) enriches each key with its
768
777
  * CNAM prefix + play-message script/audio, which the dialplan lacks.
769
778
  */
770
- function renderAaFromDialrules(rules, startingPrompt, fromId, ext, idx, b, detailTier) {
779
+ function renderAaFromDialrules(rules, startingPrompt, fromId, ext, idx, b, detailTier, tiers = new Map()) {
771
780
  const prefix = `${startingPrompt}.`;
772
781
  const promptId = startingPrompt.replace(/^Prompt_/i, ''); // e.g. "912201"
782
+ // prompt id -> the node whose menu it is. A deeper tier keyed back to an earlier prompt ("9 for the
783
+ // main menu") is a jump to THAT node, not a second copy of it; Builder.edge turns it into a
784
+ // loops-back leaf when the target is an ancestor still being expanded.
785
+ tiers.set(promptId, fromId);
773
786
  let dialByExt = false;
774
787
  const opts = [];
775
788
  let noKey = null;
@@ -808,14 +821,65 @@ function renderAaFromDialrules(rules, startingPrompt, fromId, ext, idx, b, detai
808
821
  const script = opt ? s(opt.audio?.['file-script-text']) : '';
809
822
  const label = o.label + (cnam && cnam !== '[*]' ? ` · ${cnam}` : '');
810
823
  let target;
811
- if (/^announce/i.test(o.app))
812
- target = b.node(`aaannounce_${ext}_${o.dest}`, 'prompt', `🔊 ${script ? `“${trim(script)}”` : 'Play message'}`, undefined, undefined, script.length > GREET_MAX ? script : undefined).id;
813
- else if (/^prompt/i.test(o.app))
814
- target = o.dest === promptId ? b.node(`aarepeat_${ext}`, 'prompt', '🔁 Repeat greeting', 're-plays the menu').id : b.node(`aaprompt_${ext}_${o.dest}`, 'prompt', '🔊 Play prompt', o.dest || undefined).id;
824
+ if (/^announce/i.test(o.app)) {
825
+ const n = b.node(`aaannounce_${ext}_${o.dest}`, 'prompt', `🔊 ${script ? `“${trim(script)}”` : 'Play message'}`, undefined, undefined, script.length > GREET_MAX ? script : undefined);
826
+ if (n.isNew)
827
+ announceReturn(o.dest, n.id); // only once two keys may play the same message
828
+ target = n.id;
829
+ }
830
+ else if (/^prompt/i.test(o.app)) {
831
+ if (o.dest === promptId)
832
+ target = b.node(`aarepeat_${ext}_${promptId}`, 'prompt', '🔁 Repeat greeting', 're-plays the menu').id;
833
+ else if (tiers.has(o.dest))
834
+ target = tiers.get(o.dest);
835
+ else if (hasTier(o.dest))
836
+ return renderSubTier(o, opt, label);
837
+ else
838
+ target = b.node(`aaprompt_${ext}_${o.dest}`, 'prompt', '🔊 Play prompt', o.dest || undefined).id;
839
+ }
815
840
  else
816
841
  target = aaApp(o.app, o.dest, idx, b);
817
842
  b.edge(fromId, target, 'menu', label);
818
843
  };
844
+ /**
845
+ * Where the call goes once a played message finishes: the dialplan's `Announce_<id>.Done` rule.
846
+ * It is almost always `Prompt <this menu>` — i.e. the caller hears the message, then the menu again.
847
+ * Without this edge a message is drawn as a dead end, which is the one thing it never is.
848
+ * `Prompt <this menu>` reuses the shared "Repeat greeting" node (same node the no-key default lands
849
+ * on — identical behavior, so it should be identical on the diagram); a jump to another tier points
850
+ * at that tier's node. Anything else routes normally. An unrecognized prompt id gets no edge rather
851
+ * than an invented node.
852
+ */
853
+ const announceReturn = (announceId, fromAnnounce) => {
854
+ const done = rules.find((r) => s(r['dial-rule-matching-to-uri']) === `Announce_${announceId}.Done`);
855
+ if (!done)
856
+ return;
857
+ const app = s(done['dial-rule-application']);
858
+ const dest = s(done['dial-rule-translation-destination-user']);
859
+ if (/^announce/i.test(app))
860
+ return; // message → message chain: not seen in the wild, don't guess
861
+ let back;
862
+ if (/^prompt/i.test(app))
863
+ back = dest === promptId ? b.node(`aarepeat_${ext}_${promptId}`, 'prompt', '🔁 Repeat greeting', 're-plays the menu').id : tiers.get(dest);
864
+ else
865
+ back = aaApp(app, dest, idx, b);
866
+ if (back)
867
+ b.edge(fromAnnounce, back, 'menu', 'then');
868
+ };
869
+ /** Does `dest` have its own rule family here — i.e. is it a nested menu tier, not a bare prompt? */
870
+ const hasTier = (dest) => !!dest && rules.some((r) => s(r['dial-rule-matching-to-uri']).startsWith(`Prompt_${dest}.`));
871
+ /** A second-level menu: its own node, then the same grammar again from that tier's prompt. */
872
+ const renderSubTier = (o, opt, label) => {
873
+ // Keyed by prompt id, not keypress: two keys may open the same tier, and that should be one node.
874
+ const greet = s(opt?.audio?.['file-script-text']);
875
+ const subId = b.node(`aa_${ext}_p${o.dest}`, 'attendant', `🔀 Submenu${o.dtmf ? ` (press ${o.dtmf})` : ''}`, greet ? `“${trim(greet)}”` : 'nested menu', undefined, greet.length > GREET_MAX ? greet : undefined).id;
876
+ b.edge(fromId, subId, 'menu', label);
877
+ if (!b.claim(subId))
878
+ return;
879
+ b.enter(subId);
880
+ renderAaFromDialrules(rules, `Prompt_${o.dest}`, subId, ext, idx, b, opt?.['auto-attendant'], tiers);
881
+ b.leave(null);
882
+ };
819
883
  for (const o of opts.sort((a, c) => a.sort.localeCompare(c.sort)))
820
884
  route(o);
821
885
  if (noKey)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dszp/netsapiens-lib",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "Portable, Node-free NetSapiens toolkit: read-only API client, JWT (ns_t) validation, and a snapshot -> FlowGraph -> Mermaid call-flow resolver/renderer. Runs unchanged in a Cloudflare Worker, Node, or the browser.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -12,11 +12,17 @@
12
12
  "type": "git",
13
13
  "url": "git+https://github.com/dszp/netsapiens-lib.git"
14
14
  },
15
- "bugs": { "url": "https://github.com/dszp/netsapiens-lib/issues" },
15
+ "bugs": {
16
+ "url": "https://github.com/dszp/netsapiens-lib/issues"
17
+ },
16
18
  "homepage": "https://github.com/dszp/netsapiens-lib#readme",
17
19
  "//publishConfig": "No `provenance: true` here on purpose: it would force provenance on EVERY publish, including the manual first one that a brand-new package requires (npm can only configure trusted publishing on a package that already exists). Provenance comes from the workflow's explicit `npm publish --provenance` in CI.",
18
- "publishConfig": { "access": "public" },
19
- "engines": { "node": ">=20" },
20
+ "publishConfig": {
21
+ "access": "public"
22
+ },
23
+ "engines": {
24
+ "node": ">=20"
25
+ },
20
26
  "keywords": [
21
27
  "netsapiens",
22
28
  "voip",
@@ -49,8 +55,8 @@
49
55
  "build:watch": "tsc -p tsconfig.json --watch",
50
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.",
51
57
  "prepublishOnly": "tsc -p tsconfig.json --sourceMap false --declarationMap false",
52
- "//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.",
53
- "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",
58
+ "//test": "The offline suite \u2014 green on a fresh clone with no credentials and no fixtures. test:ns is NOT included: it needs a domain snapshot that (correctly) isn't in the repo.",
59
+ "test": "pnpm run test:jwt && pnpm run test:principal && pnpm run test:resolver && pnpm run test:raster && pnpm run test:nswrite && pnpm run test:eligibility && pnpm run test:nsauth && pnpm test:nssubs && pnpm test:nsdevice",
54
60
  "test:jwt": "tsx src/jwt.selftest.ts",
55
61
  "test:ns": "tsx src/nsClient.selftest.ts",
56
62
  "test:nswrite": "tsx src/nsWriteClient.selftest.ts",
@@ -58,7 +64,9 @@
58
64
  "test:principal": "tsx src/principal.selftest.ts",
59
65
  "test:resolver": "tsx src/resolver.selftest.ts",
60
66
  "test:raster": "tsx src/raster.selftest.ts",
61
- "test:eligibility": "tsx src/eligibility.selftest.ts"
67
+ "test:eligibility": "tsx src/eligibility.selftest.ts",
68
+ "test:nssubs": "tsx src/nsSubscriptions.selftest.ts",
69
+ "test:nsdevice": "tsx src/nsDevice.selftest.ts"
62
70
  },
63
71
  "devDependencies": {
64
72
  "tsx": "^4.22.4",