@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.
@@ -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()}\u0000${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
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Which NetSapiens v2 write operations accept the `synchronous` request-body flag.
3
+ *
4
+ * `synchronous: 'yes'` asks the API to complete the write before replying, so the response is
5
+ * **200 with the resulting resource inline** — including server-generated fields a caller cannot
6
+ * otherwise learn without a second read (a new device's SIP registration password is the worked
7
+ * example). Without it, or on an operation that does not support it, the API replies
8
+ * **202 Accepted** with a bare `{code, message}` acknowledgement and applies the write behind the
9
+ * scenes.
10
+ *
11
+ * **It is a per-operation capability, not a global one.** Only the operations listed below declare
12
+ * a `synchronous` property in the v2 OpenAPI specification (core 44.4.10) — 17 of them, almost all
13
+ * creates. Sending the flag to any other endpoint is inert: NetSapiens ignores unrecognized body
14
+ * fields and still answers 202. That is harmless but misleading, because it makes code look as
15
+ * though it has a synchronous guarantee it never had.
16
+ *
17
+ * The most consequential absence is **`PUT /domains/{domain}/users/{user}`** — a user *update*
18
+ * cannot be made synchronous, though a user *create* can. Verified live 2026-08-03: the flag in the
19
+ * body, as `?synchronous=yes`, as `?synchronous=true`, both at once, and omitted entirely all return
20
+ * an identical 202 on that endpoint. Any confirmation of a user update has to come from reading the
21
+ * record back, not from the response.
22
+ *
23
+ * Kept as data rather than folded into each method so both this library's write client and other
24
+ * NetSapiens clients can share one answer instead of drifting apart.
25
+ */
26
+ /** The HTTP methods any `synchronous`-capable operation uses. */
27
+ export type SynchronousMethod = 'POST' | 'PUT';
28
+ /** One operation that accepts `synchronous`, as a method plus an OpenAPI-style templated path. */
29
+ export interface SynchronousOperation {
30
+ method: SynchronousMethod;
31
+ /** Templated path, e.g. `/domains/{domain}/users`. A `{...}` segment matches exactly one path segment. */
32
+ path: string;
33
+ }
34
+ /**
35
+ * Every operation declaring `synchronous` in the v2 spec (core 44.4.10), deduplicated — the
36
+ * specification lists several of these more than once under `#1`…`#4` suffixes for differing
37
+ * request shapes, which are the same HTTP operation.
38
+ *
39
+ * Note the near-misses, which are the whole reason this list is explicit: user and domain **create**
40
+ * are here, user **update** is not; greeting and MOH **update** are here, device update is not.
41
+ */
42
+ export declare const SYNCHRONOUS_OPERATIONS: readonly SynchronousOperation[];
43
+ /**
44
+ * Does `method path` accept `synchronous`?
45
+ *
46
+ * `path` is a concrete request path relative to the `/ns-api/v2` base, with its dynamic segments
47
+ * already filled in and URI-encoded — exactly what a client passes to `post()`/`put()`. Encoding is
48
+ * what makes the match safe: a value containing a slash arrives as `%2F` and stays one segment, so
49
+ * it cannot masquerade as a deeper path.
50
+ *
51
+ * Unknown paths answer `false`. That is the safe direction: the flag is then omitted, and the caller
52
+ * gets the 202 it would have received anyway — rather than a promise of a 200 that never arrives.
53
+ */
54
+ export declare function supportsSynchronous(method: string, path: string): boolean;
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Which NetSapiens v2 write operations accept the `synchronous` request-body flag.
3
+ *
4
+ * `synchronous: 'yes'` asks the API to complete the write before replying, so the response is
5
+ * **200 with the resulting resource inline** — including server-generated fields a caller cannot
6
+ * otherwise learn without a second read (a new device's SIP registration password is the worked
7
+ * example). Without it, or on an operation that does not support it, the API replies
8
+ * **202 Accepted** with a bare `{code, message}` acknowledgement and applies the write behind the
9
+ * scenes.
10
+ *
11
+ * **It is a per-operation capability, not a global one.** Only the operations listed below declare
12
+ * a `synchronous` property in the v2 OpenAPI specification (core 44.4.10) — 17 of them, almost all
13
+ * creates. Sending the flag to any other endpoint is inert: NetSapiens ignores unrecognized body
14
+ * fields and still answers 202. That is harmless but misleading, because it makes code look as
15
+ * though it has a synchronous guarantee it never had.
16
+ *
17
+ * The most consequential absence is **`PUT /domains/{domain}/users/{user}`** — a user *update*
18
+ * cannot be made synchronous, though a user *create* can. Verified live 2026-08-03: the flag in the
19
+ * body, as `?synchronous=yes`, as `?synchronous=true`, both at once, and omitted entirely all return
20
+ * an identical 202 on that endpoint. Any confirmation of a user update has to come from reading the
21
+ * record back, not from the response.
22
+ *
23
+ * Kept as data rather than folded into each method so both this library's write client and other
24
+ * NetSapiens clients can share one answer instead of drifting apart.
25
+ */
26
+ /**
27
+ * Every operation declaring `synchronous` in the v2 spec (core 44.4.10), deduplicated — the
28
+ * specification lists several of these more than once under `#1`…`#4` suffixes for differing
29
+ * request shapes, which are the same HTTP operation.
30
+ *
31
+ * Note the near-misses, which are the whole reason this list is explicit: user and domain **create**
32
+ * are here, user **update** is not; greeting and MOH **update** are here, device update is not.
33
+ */
34
+ export const SYNCHRONOUS_OPERATIONS = [
35
+ { method: 'POST', path: '/domains' },
36
+ { method: 'POST', path: '/domains/{domain}/callqueues' },
37
+ { method: 'POST', path: '/domains/{domain}/callqueues/{callqueue}/agents' },
38
+ { method: 'POST', path: '/domains/{domain}/dialplans/{dialplan}/dialrules' },
39
+ { method: 'POST', path: '/domains/{domain}/moh' },
40
+ { method: 'PUT', path: '/domains/{domain}/moh/{index}' },
41
+ { method: 'PUT', path: '/domains/{domain}/sites/{site}' },
42
+ { method: 'POST', path: '/domains/{domain}/timeframes' },
43
+ { method: 'POST', path: '/domains/{domain}/users' },
44
+ { method: 'POST', path: '/domains/{domain}/users/{user}/answerrules' },
45
+ { method: 'POST', path: '/domains/{domain}/users/{user}/calls' },
46
+ { method: 'POST', path: '/domains/{domain}/users/{user}/devices' },
47
+ { method: 'POST', path: '/domains/{domain}/users/{user}/greetings' },
48
+ { method: 'PUT', path: '/domains/{domain}/users/{user}/greetings/{index}' },
49
+ { method: 'POST', path: '/domains/{domain}/users/{user}/moh' },
50
+ { method: 'PUT', path: '/domains/{domain}/users/{user}/moh/{index}' },
51
+ { method: 'POST', path: '/domains/{domain}/users/{user}/timeframes' },
52
+ ];
53
+ /** Split a path into non-empty segments, ignoring any query string and leading/trailing slashes. */
54
+ const segmentsOf = (path) => (path.split('?')[0] ?? '').split('/').filter(Boolean);
55
+ /** Precomputed segment forms, so a lookup is a comparison rather than a re-parse per call. */
56
+ const TABLE = SYNCHRONOUS_OPERATIONS.map((op) => ({ method: op.method, segments: segmentsOf(op.path) }));
57
+ /**
58
+ * Does `method path` accept `synchronous`?
59
+ *
60
+ * `path` is a concrete request path relative to the `/ns-api/v2` base, with its dynamic segments
61
+ * already filled in and URI-encoded — exactly what a client passes to `post()`/`put()`. Encoding is
62
+ * what makes the match safe: a value containing a slash arrives as `%2F` and stays one segment, so
63
+ * it cannot masquerade as a deeper path.
64
+ *
65
+ * Unknown paths answer `false`. That is the safe direction: the flag is then omitted, and the caller
66
+ * gets the 202 it would have received anyway — rather than a promise of a 200 that never arrives.
67
+ */
68
+ export function supportsSynchronous(method, path) {
69
+ const verb = method.toUpperCase();
70
+ if (verb !== 'POST' && verb !== 'PUT')
71
+ return false;
72
+ const actual = segmentsOf(path);
73
+ return TABLE.some((op) => op.method === verb &&
74
+ op.segments.length === actual.length &&
75
+ op.segments.every((seg, i) => seg.startsWith('{') && seg.endsWith('}')
76
+ ? true // a template segment matches any single segment
77
+ : seg.toLowerCase() === actual[i].toLowerCase()));
78
+ }
@@ -8,11 +8,15 @@
8
8
  * generic post/put/delete core, and is meant to GROW into the full NS write surface (users, DIDs, …) —
9
9
  * porting the endpoint/body shapes from the onboarding tool's resource defs as they're needed.
10
10
  *
11
- * Like the onboarding client, POST/PUT inject `synchronous: 'yes'` so a create returns 200 + the created
12
- * resource inline (with server-generated fields e.g. a device's `device-sip-registration-password`)
13
- * instead of a 202 with replication lag. Shares the read client's SSRF guard and `NsApiError`.
11
+ * POST/PUT inject `synchronous: 'yes'` **only on the operations that accept it** (see
12
+ * {@link supportsSynchronous}), where it makes the API return 200 + the resulting resource inline —
13
+ * with server-generated fields such as a device's `device-sip-registration-password` instead of a
14
+ * bare 202 acknowledgement. Everywhere else the flag is omitted, because sending it there is inert:
15
+ * NetSapiens ignores it and still answers 202, which previously made this client look as though all
16
+ * of its writes were confirmed when most were not. Shares the read client's SSRF guard and `NsApiError`.
14
17
  */
15
18
  import type { Rec } from './model.js';
19
+ import { type EnsureNsDeviceOptions, type EnsureNsDeviceResult } from './nsDevice.js';
16
20
  export interface NsWriteClientConfig {
17
21
  /** API host, e.g. "api.example.com". Base URL becomes https://{server}/ns-api/v2. */
18
22
  server: string;
@@ -25,9 +29,15 @@ export declare class NsWriteClient {
25
29
  #private;
26
30
  constructor(cfg: NsWriteClientConfig);
27
31
  get<T = unknown>(path: string, query?: Record<string, string | number>): Promise<T>;
28
- /** POST with `synchronous:'yes'` injected → 200 + created resource inline. */
32
+ /**
33
+ * POST. On an operation that accepts it, `synchronous:'yes'` is injected → 200 + the created
34
+ * resource inline; otherwise the flag is omitted and the API answers 202 Accepted.
35
+ */
29
36
  post<T = unknown>(path: string, body: Rec): Promise<T>;
30
- /** PUT with `synchronous:'yes'` injected. */
37
+ /**
38
+ * PUT. Same rule as {@link post} — and note most updates do NOT accept the flag, so their
39
+ * response is a 202 acknowledgement with no resource body. Confirm those by reading back.
40
+ */
31
41
  put<T = unknown>(path: string, body: Rec): Promise<T>;
32
42
  delete<T = unknown>(path: string): Promise<T>;
33
43
  /** List a user's devices (normalized to an array). */
@@ -40,6 +50,30 @@ export declare class NsWriteClient {
40
50
  * optional fields (e.g. an emergency caller-id).
41
51
  */
42
52
  createDevice(domain: string, user: string, device: string, extra?: Rec): Promise<Rec>;
53
+ /**
54
+ * Update a device in place.
55
+ *
56
+ * `PUT .../devices/{device}` does **not** accept `synchronous`, so this returns a 202
57
+ * acknowledgement, not the updated device. Callers must not depend on the response echoing their
58
+ * change back — {@link ensureNsDevice} falls back to the value it just sent for exactly this reason.
59
+ *
60
+ * The reason this exists rather than callers using `put()`: rotating
61
+ * `device-sip-registration-password` must **not** be done by deleting and recreating the device, which
62
+ * would discard everything else on it — emergency caller id, the provisioning MAC/model link, SRTP and
63
+ * transport settings. A PUT changes the one field and preserves the rest.
64
+ */
65
+ updateDevice(domain: string, user: string, device: string, changes: Rec): Promise<Rec>;
43
66
  /** Delete a device. */
44
67
  deleteDevice(domain: string, user: string, device: string): Promise<Rec>;
68
+ /**
69
+ * Convenience wrapper over {@link ensureNsDevice} — ensure a device exists and return its SIP password,
70
+ * optionally rotating it. See that function for the semantics, and for why rotation matters.
71
+ *
72
+ * Deliberately a **one-line delegation, not an implementation**. Every other method on this class is
73
+ * exactly one HTTP request; this one is several with branching, so the logic lives in a standalone
74
+ * function that composes over any writer (a consumer may have its own client) and that consumers can mock as
75
+ * a plain 4-method object instead of stubbing a whole client. This method exists only so the capability
76
+ * is discoverable from the client you already hold.
77
+ */
78
+ ensureDevice(opts: EnsureNsDeviceOptions): Promise<EnsureNsDeviceResult>;
45
79
  }