@dszp/netsapiens-lib 0.1.9 → 0.3.1

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.
Files changed (78) hide show
  1. package/README.md +95 -1
  2. package/dist/eligibility.d.ts.map +1 -0
  3. package/dist/eligibility.js.map +1 -0
  4. package/dist/html.d.ts.map +1 -0
  5. package/dist/html.js.map +1 -0
  6. package/dist/index.d.ts +1 -0
  7. package/dist/index.d.ts.map +1 -0
  8. package/dist/index.js +1 -0
  9. package/dist/index.js.map +1 -0
  10. package/dist/inventory.d.ts +153 -0
  11. package/dist/inventory.d.ts.map +1 -0
  12. package/dist/inventory.js +234 -0
  13. package/dist/inventory.js.map +1 -0
  14. package/dist/jwt.d.ts.map +1 -0
  15. package/dist/jwt.js.map +1 -0
  16. package/dist/mermaid.d.ts.map +1 -0
  17. package/dist/mermaid.js.map +1 -0
  18. package/dist/model.d.ts +18 -0
  19. package/dist/model.d.ts.map +1 -0
  20. package/dist/model.js.map +1 -0
  21. package/dist/nsAuthClient.d.ts.map +1 -0
  22. package/dist/nsAuthClient.js.map +1 -0
  23. package/dist/nsClient.d.ts +19 -0
  24. package/dist/nsClient.d.ts.map +1 -0
  25. package/dist/nsClient.js +40 -3
  26. package/dist/nsClient.js.map +1 -0
  27. package/dist/nsDevice.d.ts.map +1 -0
  28. package/dist/nsDevice.js.map +1 -0
  29. package/dist/nsSubscriptions.d.ts.map +1 -0
  30. package/dist/nsSubscriptions.js.map +1 -0
  31. package/dist/nsSynchronous.d.ts.map +1 -0
  32. package/dist/nsSynchronous.js.map +1 -0
  33. package/dist/nsWriteClient.d.ts.map +1 -0
  34. package/dist/nsWriteClient.js.map +1 -0
  35. package/dist/policy.d.ts.map +1 -0
  36. package/dist/policy.js.map +1 -0
  37. package/dist/principal.d.ts.map +1 -0
  38. package/dist/principal.js.map +1 -0
  39. package/dist/raster.d.ts.map +1 -0
  40. package/dist/raster.js.map +1 -0
  41. package/dist/resolver.d.ts.map +1 -0
  42. package/dist/resolver.js.map +1 -0
  43. package/dist/sensitivity.d.ts.map +1 -0
  44. package/dist/sensitivity.js.map +1 -0
  45. package/dist/themes.d.ts.map +1 -0
  46. package/dist/themes.js.map +1 -0
  47. package/package.json +7 -3
  48. package/src/eligibility.selftest.ts +95 -0
  49. package/src/eligibility.ts +118 -0
  50. package/src/html.ts +407 -0
  51. package/src/index.ts +120 -0
  52. package/src/inventory.selftest.ts +213 -0
  53. package/src/inventory.ts +324 -0
  54. package/src/jwt.selftest.ts +145 -0
  55. package/src/jwt.ts +491 -0
  56. package/src/mermaid.ts +169 -0
  57. package/src/model.ts +130 -0
  58. package/src/nsAuthClient.selftest.ts +60 -0
  59. package/src/nsAuthClient.ts +102 -0
  60. package/src/nsClient.selftest.ts +173 -0
  61. package/src/nsClient.ts +323 -0
  62. package/src/nsDevice.selftest.ts +190 -0
  63. package/src/nsDevice.ts +167 -0
  64. package/src/nsSubscriptions.selftest.ts +486 -0
  65. package/src/nsSubscriptions.ts +638 -0
  66. package/src/nsSynchronous.selftest.ts +63 -0
  67. package/src/nsSynchronous.ts +98 -0
  68. package/src/nsWriteClient.selftest.ts +104 -0
  69. package/src/nsWriteClient.ts +157 -0
  70. package/src/policy.ts +123 -0
  71. package/src/principal.selftest.ts +118 -0
  72. package/src/principal.ts +101 -0
  73. package/src/raster.selftest.ts +42 -0
  74. package/src/raster.ts +79 -0
  75. package/src/resolver.selftest.ts +225 -0
  76. package/src/resolver.ts +1115 -0
  77. package/src/sensitivity.ts +40 -0
  78. package/src/themes.ts +142 -0
@@ -0,0 +1,638 @@
1
+ /**
2
+ * NetSapiens API v2 **Event Subscriptions** — a separate client for the `/subscriptions` surface, plus a
3
+ * pure reconciliation planner.
4
+ *
5
+ * This is its own class on purpose. `NsClient` is read-only by charter (a consumer holds one precisely to
6
+ * know it cannot write), and `NsWriteClient`'s `delete()` sends no body — while
7
+ * `DELETE /subscriptions/{id}` *requires* one (`subscription_id`, plus `domain` for scopes below Super
8
+ * User). (A second reason applied until 0.1.7: `NsWriteClient` injected `synchronous: 'yes'` into *every*
9
+ * POST/PUT, which no `/subscriptions` operation accepts. It now injects only where the API declares
10
+ * support, so that objection is gone — the `delete()` body is what still makes the split necessary.)
11
+ * Node-free (fetch/URL/crypto only), so it runs unchanged in a Cloudflare Worker.
12
+ *
13
+ * An event subscription tells NetSapiens to POST change events to a URL you own. Notable API properties
14
+ * that shape this module:
15
+ *
16
+ * - **`id` is server-generated**, so a subscription cannot be tagged by the client. Ours are therefore
17
+ * identified by `post-url`, which makes the URL both the address *and* the label.
18
+ * - **Filters are immutable.** `PUT` accepts `post-url` and `subscription-expires-datetime` but not
19
+ * `domain`/`user`/`reseller`, so a filter change means a new subscription.
20
+ * - **Always send an explicit `expiresAt`.** Observed behaviour: an explicit expiry is stored verbatim
21
+ * *even when the request is authenticated with a one-hour OAuth access token* — the expiry is not
22
+ * clamped to the credential's lifetime. Omitting it yields a ~20-year expiry for an API key but only the
23
+ * token's expiry for a timed token, so relying on the default makes lifetime depend on how you
24
+ * authenticated. Renewal, when needed, is a `PUT`, never delete-and-recreate.
25
+ * - ⚠️ **`subscription-geo-support` behaves as `no` when omitted**, despite the API describing the default
26
+ * as `yes`. Send it explicitly if you want geo-redundant delivery (you almost certainly do — otherwise
27
+ * delivery is pinned and stops when that node is down).
28
+ * - ⚠️ **The domain-scoped routes (`/domains/{domain}/subscriptions`, API v45+) are not present on every
29
+ * cluster** — a v44 cluster answers `404 No Route Found` while the flat `/subscriptions` paths work.
30
+ * Prefer the flat methods and treat the domain-scoped ones as an opt-in optimization.
31
+ * - **Datetimes are asymmetric.** Reads observably return ISO-8601 with an offset; the documented *write*
32
+ * format is `YYYY-MM-DD HH:MM:SS`. {@link parseNsDatetime} accepts both; {@link nsDatetime} emits the
33
+ * documented form.
34
+ * - **`error-count` > 0 is normal on a healthy subscription** — a live example sat at 7 errors across 7195
35
+ * posts while `status` stayed `active`. Treat `status === 'error'` or a sustained error *rate* as the
36
+ * signal, and never reset the counters as routine maintenance: they are the only history the API keeps.
37
+ */
38
+ import type { Rec } from './model.js';
39
+ import { NsApiError, assertBareServer, asArray } from './nsClient.js';
40
+
41
+ /** Event types a subscription can carry. One subscription carries exactly one model. */
42
+ export type SubscriptionModel =
43
+ | 'agent'
44
+ | 'auditlog'
45
+ | 'auditlog_lite'
46
+ | 'call'
47
+ | 'call_origid'
48
+ | 'cdr'
49
+ | 'message'
50
+ | 'messagesession'
51
+ | 'subscriber'
52
+ | 'presence'
53
+ | 'voicemail';
54
+
55
+ /** Every valid `model` value, for validating configuration before it reaches the API. */
56
+ export const SUBSCRIPTION_MODELS: readonly SubscriptionModel[] = [
57
+ 'agent',
58
+ 'auditlog',
59
+ 'auditlog_lite',
60
+ 'call',
61
+ 'call_origid',
62
+ 'cdr',
63
+ 'message',
64
+ 'messagesession',
65
+ 'subscriber',
66
+ 'presence',
67
+ 'voicemail',
68
+ ] as const;
69
+
70
+ /** Narrowing guard for a configured model string. */
71
+ export function isSubscriptionModel(v: unknown): v is SubscriptionModel {
72
+ return typeof v === 'string' && (SUBSCRIPTION_MODELS as readonly string[]).includes(v);
73
+ }
74
+
75
+ /** Server-reported delivery health. `pending` until the first successful post. */
76
+ export type SubscriptionStatus = 'pending' | 'active' | 'error';
77
+
78
+ /**
79
+ * A subscription, with the API's hyphenated wire keys mapped to camelCase. Datetimes are kept as the
80
+ * **raw strings** the API returned (parse with {@link parseNsDatetime} when you need a `Date`), and `raw`
81
+ * carries the untouched record so a caller never loses a field this type hasn't modelled.
82
+ */
83
+ export interface Subscription {
84
+ id: string;
85
+ model?: string;
86
+ postUrl?: string;
87
+ geoSupport?: string;
88
+ userScope?: string;
89
+ reseller?: string;
90
+ domain?: string;
91
+ user?: string;
92
+ /** Raw `subscription-creation-datetime`. */
93
+ createdAt?: string;
94
+ /** Raw `subscription-expires-datetime`. */
95
+ expiresAt?: string;
96
+ preferredServer?: string;
97
+ /** Read-only: the node currently delivering. Changes on failover. */
98
+ currentActiveServer?: string;
99
+ status?: string;
100
+ errorCount?: number;
101
+ postsCount?: number;
102
+ /** The untouched API record. */
103
+ raw: Rec;
104
+ }
105
+
106
+ /** Fields accepted when creating. `domain`/`user`/`reseller` are the (immutable) event filters. */
107
+ export interface CreateSubscriptionInput {
108
+ model: SubscriptionModel;
109
+ /** Absolute https URL NetSapiens will POST to. */
110
+ postUrl: string;
111
+ /** Restrict to one domain. `'*'` means all domains and requires Super User scope. */
112
+ domain?: string;
113
+ /** Restrict to one user/extension. Defaults to all. */
114
+ user?: string;
115
+ /** Restrict to one reseller. `'*'` requires Super User scope. */
116
+ reseller?: string;
117
+ /**
118
+ * Geo-redundant delivery across nodes. ⚠️ Behaves as `'no'` when omitted, despite the API documenting
119
+ * `'yes'` as the default — send `'yes'` explicitly unless you deliberately want delivery pinned.
120
+ */
121
+ geoSupport?: 'yes' | 'no';
122
+ /**
123
+ * Explicit expiry, and you should always set one. It is honoured verbatim even when the request is
124
+ * authenticated with a short-lived OAuth token. Omitting it makes the lifetime depend on the credential
125
+ * (API key ⇒ ~20 years; timed token ⇒ that token's expiry).
126
+ */
127
+ expiresAt?: Date | string;
128
+ /** Preferred delivering node. A preference, not a pin — other nodes deliver during instability. */
129
+ preferredServer?: string;
130
+ }
131
+
132
+ /** Fields `PUT` accepts. The event filters are deliberately absent — they cannot be changed. */
133
+ export interface UpdateSubscriptionInput {
134
+ model?: SubscriptionModel;
135
+ postUrl?: string;
136
+ geoSupport?: 'yes' | 'no';
137
+ expiresAt?: Date | string;
138
+ preferredServer?: string;
139
+ /** Only `0` is accepted — a reset. Prefer leaving counters alone; they are the only history kept. */
140
+ errorCount?: 0;
141
+ /** Only `0` is accepted — a reset. */
142
+ postsCount?: 0;
143
+ }
144
+
145
+ const enc = encodeURIComponent;
146
+ const two = (n: number) => String(n).padStart(2, '0');
147
+
148
+ /**
149
+ * Format a `Date` as the documented write format `YYYY-MM-DD HH:MM:SS`, **in UTC**.
150
+ *
151
+ * The API documents no timezone for this field. Emitting UTC is the only self-consistent choice, and it
152
+ * round-trips with {@link parseNsDatetime}, which also reads a bare timestamp as UTC.
153
+ */
154
+ export function nsDatetime(d: Date): string {
155
+ return (
156
+ `${d.getUTCFullYear()}-${two(d.getUTCMonth() + 1)}-${two(d.getUTCDate())}` +
157
+ ` ${two(d.getUTCHours())}:${two(d.getUTCMinutes())}:${two(d.getUTCSeconds())}`
158
+ );
159
+ }
160
+
161
+ /**
162
+ * Parse either datetime shape the API uses: the documented `YYYY-MM-DD HH:MM:SS` (read as **UTC**) or the
163
+ * ISO-8601-with-offset form that reads actually return. Returns `undefined` rather than an Invalid Date so
164
+ * callers fail closed on a value they can't interpret.
165
+ */
166
+ export function parseNsDatetime(s: string | undefined | null): Date | undefined {
167
+ if (typeof s !== 'string') return undefined;
168
+ const t = s.trim();
169
+ if (!t) return undefined;
170
+ // Bare "YYYY-MM-DD HH:MM:SS" (optionally with fractional seconds) carries no zone → treat as UTC.
171
+ const bare = /^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})(?::(\d{2}))?(?:\.\d+)?$/.exec(t);
172
+ if (bare) {
173
+ const ms = Date.UTC(
174
+ Number(bare[1]),
175
+ Number(bare[2]) - 1,
176
+ Number(bare[3]),
177
+ Number(bare[4]),
178
+ Number(bare[5]),
179
+ Number(bare[6] ?? 0),
180
+ );
181
+ return Number.isNaN(ms) ? undefined : new Date(ms);
182
+ }
183
+ const ms = Date.parse(t);
184
+ return Number.isNaN(ms) ? undefined : new Date(ms);
185
+ }
186
+
187
+ function num(v: unknown): number | undefined {
188
+ if (typeof v === 'number' && Number.isFinite(v)) return v;
189
+ if (typeof v === 'string' && v.trim() !== '') {
190
+ const n = Number(v);
191
+ if (Number.isFinite(n)) return n;
192
+ }
193
+ return undefined;
194
+ }
195
+
196
+ function str(v: unknown): string | undefined {
197
+ return typeof v === 'string' && v !== '' ? v : undefined;
198
+ }
199
+
200
+ /** Map one API record to {@link Subscription}. Tolerant: an unmodelled or missing field is simply absent. */
201
+ export function subscriptionFromWire(rec: Rec): Subscription {
202
+ return {
203
+ id: String(rec['id'] ?? ''),
204
+ ...(str(rec['model']) ? { model: str(rec['model'])! } : {}),
205
+ ...(str(rec['post-url']) ? { postUrl: str(rec['post-url'])! } : {}),
206
+ ...(str(rec['subscription-geo-support']) ? { geoSupport: str(rec['subscription-geo-support'])! } : {}),
207
+ ...(str(rec['user-scope']) ? { userScope: str(rec['user-scope'])! } : {}),
208
+ ...(str(rec['reseller']) ? { reseller: str(rec['reseller'])! } : {}),
209
+ ...(str(rec['domain']) ? { domain: str(rec['domain'])! } : {}),
210
+ ...(str(rec['user']) ? { user: str(rec['user'])! } : {}),
211
+ ...(str(rec['subscription-creation-datetime']) ? { createdAt: str(rec['subscription-creation-datetime'])! } : {}),
212
+ ...(str(rec['subscription-expires-datetime']) ? { expiresAt: str(rec['subscription-expires-datetime'])! } : {}),
213
+ ...(str(rec['preferred-server']) ? { preferredServer: str(rec['preferred-server'])! } : {}),
214
+ ...(str(rec['current-active-server']) ? { currentActiveServer: str(rec['current-active-server'])! } : {}),
215
+ ...(str(rec['status']) ? { status: str(rec['status'])! } : {}),
216
+ ...(num(rec['error-count']) !== undefined ? { errorCount: num(rec['error-count'])! } : {}),
217
+ ...(num(rec['posts-count']) !== undefined ? { postsCount: num(rec['posts-count'])! } : {}),
218
+ raw: rec,
219
+ };
220
+ }
221
+
222
+ function expiryToWire(v: Date | string | undefined): string | undefined {
223
+ if (v === undefined) return undefined;
224
+ return typeof v === 'string' ? v : nsDatetime(v);
225
+ }
226
+
227
+ /** Map {@link CreateSubscriptionInput} to the hyphenated request body. */
228
+ export function createInputToWire(input: CreateSubscriptionInput): Rec {
229
+ const body: Rec = { model: input.model, 'post-url': input.postUrl };
230
+ if (input.domain !== undefined) body['domain'] = input.domain;
231
+ if (input.user !== undefined) body['user'] = input.user;
232
+ if (input.reseller !== undefined) body['reseller'] = input.reseller;
233
+ if (input.geoSupport !== undefined) body['subscription-geo-support'] = input.geoSupport;
234
+ const exp = expiryToWire(input.expiresAt);
235
+ if (exp !== undefined) body['subscription-expires-datetime'] = exp;
236
+ if (input.preferredServer !== undefined) body['preferred-server'] = input.preferredServer;
237
+ return body;
238
+ }
239
+
240
+ /** Map {@link UpdateSubscriptionInput} to the hyphenated request body. */
241
+ export function updateInputToWire(changes: UpdateSubscriptionInput): Rec {
242
+ const body: Rec = {};
243
+ if (changes.model !== undefined) body['model'] = changes.model;
244
+ if (changes.postUrl !== undefined) body['post-url'] = changes.postUrl;
245
+ if (changes.geoSupport !== undefined) body['subscription-geo-support'] = changes.geoSupport;
246
+ const exp = expiryToWire(changes.expiresAt);
247
+ if (exp !== undefined) body['subscription-expires-datetime'] = exp;
248
+ if (changes.preferredServer !== undefined) body['preferred-server'] = changes.preferredServer;
249
+ if (changes.errorCount !== undefined) body['error-count'] = changes.errorCount;
250
+ if (changes.postsCount !== undefined) body['posts-count'] = changes.postsCount;
251
+ return body;
252
+ }
253
+
254
+ export interface NsSubscriptionsClientConfig {
255
+ /** API host, e.g. `"api.example.com"`. Base URL becomes `https://{server}/ns-api/v2`. */
256
+ server: string;
257
+ /** Bearer token — an API key, or an OAuth access token, with scope to manage subscriptions. */
258
+ token: string;
259
+ /** Injectable for tests / non-global fetch. */
260
+ fetchImpl?: typeof fetch;
261
+ /** Page size for list calls. Default 500. */
262
+ pageSize?: number;
263
+ }
264
+
265
+ /** Thrown by {@link NsSubscriptionsClient.create} on the API's 409 "already exists" response. */
266
+ export class NsSubscriptionConflictError extends NsApiError {
267
+ constructor(message: string, path: string, body: unknown) {
268
+ super(message, 409, path, body, 'POST');
269
+ this.name = 'NsSubscriptionConflictError';
270
+ }
271
+ }
272
+
273
+ /**
274
+ * Read/write client for `/subscriptions`.
275
+ *
276
+ * ```ts
277
+ * const subs = new NsSubscriptionsClient({ server: 'api.example.com', token: key });
278
+ * const mine = (await subs.list()).filter((s) => s.postUrl?.startsWith('https://hooks.example.com/'));
279
+ * ```
280
+ */
281
+ export class NsSubscriptionsClient {
282
+ readonly #baseUrl: string;
283
+ readonly #token: string;
284
+ readonly #fetchImpl: typeof fetch;
285
+ readonly #pageSize: number;
286
+
287
+ constructor(cfg: NsSubscriptionsClientConfig) {
288
+ this.#baseUrl = `https://${assertBareServer(cfg.server)}/ns-api/v2`;
289
+ this.#token = cfg.token;
290
+ this.#fetchImpl = cfg.fetchImpl ?? fetch;
291
+ this.#pageSize = cfg.pageSize && cfg.pageSize > 0 ? cfg.pageSize : 500;
292
+ }
293
+
294
+ /**
295
+ * Every subscription the credential can see, paged to completion.
296
+ *
297
+ * Paging matters: with no local registry this list *is* the source of truth, so a partial read would
298
+ * make a reconciler create duplicates or skip renewals. The loop is defensive in both directions — it
299
+ * stops on a short page, and also if a server that ignores the paging parameters returns the same
300
+ * records again.
301
+ */
302
+ list(): Promise<Subscription[]> {
303
+ return this.#listPaged('/subscriptions');
304
+ }
305
+
306
+ /** Subscriptions filtered to one domain. ⚠️ Requires API v45+; a v44 cluster returns 404. */
307
+ listForDomain(domain: string): Promise<Subscription[]> {
308
+ return this.#listPaged(`/domains/${enc(domain)}/subscriptions`);
309
+ }
310
+
311
+ /** Read one subscription by id. */
312
+ async get(id: string): Promise<Subscription> {
313
+ const rec = await this.#request<Rec>('GET', `/subscriptions/${enc(id)}`);
314
+ return subscriptionFromWire(rec);
315
+ }
316
+
317
+ /**
318
+ * Create a subscription. Throws {@link NsSubscriptionConflictError} on 409, which the API returns when a
319
+ * subscription with a matching set of parameters already exists — usually meaning the desired state is
320
+ * already in place.
321
+ */
322
+ create(input: CreateSubscriptionInput): Promise<Subscription> {
323
+ return this.#create('/subscriptions', createInputToWire(input));
324
+ }
325
+
326
+ /** Create against the domain-scoped path. ⚠️ Requires API v45+; a v44 cluster returns 404. */
327
+ createForDomain(domain: string, input: CreateSubscriptionInput): Promise<Subscription> {
328
+ return this.#create(`/domains/${enc(domain)}/subscriptions`, createInputToWire(input));
329
+ }
330
+
331
+ /**
332
+ * Update a subscription — this is how renewal works (a new `subscription-expires-datetime`) and how a
333
+ * callback URL is rotated (`post-url`). The event filters cannot be changed.
334
+ */
335
+ update(id: string, changes: UpdateSubscriptionInput): Promise<unknown> {
336
+ return this.#request('PUT', `/subscriptions/${enc(id)}`, updateInputToWire(changes));
337
+ }
338
+
339
+ /** Update against the domain-scoped path. ⚠️ Requires API v45+; a v44 cluster returns 404. */
340
+ updateForDomain(domain: string, id: string, changes: UpdateSubscriptionInput): Promise<unknown> {
341
+ return this.#request('PUT', `/domains/${enc(domain)}/subscriptions/${enc(id)}`, updateInputToWire(changes));
342
+ }
343
+
344
+ /**
345
+ * Delete a subscription.
346
+ *
347
+ * Note the body: this endpoint takes `subscription_id` (and `domain`, required for scopes below Super
348
+ * User) *in addition to* the path id. That is why this client exists rather than reusing a generic write
349
+ * client whose `delete()` sends no body.
350
+ */
351
+ remove(id: string, opts: { domain?: string } = {}): Promise<unknown> {
352
+ const body: Rec = { subscription_id: id };
353
+ if (opts.domain !== undefined) body['domain'] = opts.domain;
354
+ return this.#request('DELETE', `/subscriptions/${enc(id)}`, body);
355
+ }
356
+
357
+ async #create(path: string, body: Rec): Promise<Subscription> {
358
+ try {
359
+ const rec = await this.#request<Rec>('POST', path, body);
360
+ return subscriptionFromWire(rec);
361
+ } catch (e) {
362
+ if (e instanceof NsApiError && e.status === 409) {
363
+ throw new NsSubscriptionConflictError(e.message, path, e.body);
364
+ }
365
+ throw e;
366
+ }
367
+ }
368
+
369
+ async #listPaged(path: string): Promise<Subscription[]> {
370
+ const out: Subscription[] = [];
371
+ const seen = new Set<string>();
372
+ const limit = this.#pageSize;
373
+ for (let start = 0, guard = 0; guard < 200; guard++, start += limit) {
374
+ const page = asArray(await this.#request<unknown>('GET', path, undefined, { limit, start }));
375
+ if (page.length === 0) break;
376
+ let added = 0;
377
+ for (const rec of page) {
378
+ const sub = subscriptionFromWire(rec);
379
+ if (!sub.id || seen.has(sub.id)) continue;
380
+ seen.add(sub.id);
381
+ out.push(sub);
382
+ added++;
383
+ }
384
+ // Short page ⇒ done. No new ids ⇒ the server ignored our paging parameters; stop rather than loop.
385
+ if (page.length < limit || added === 0) break;
386
+ }
387
+ return out;
388
+ }
389
+
390
+ async #request<T>(method: string, path: string, body?: Rec, query?: Record<string, string | number>): Promise<T> {
391
+ const url = new URL(this.#baseUrl + path);
392
+ for (const [k, v] of Object.entries(query ?? {})) url.searchParams.set(k, String(v));
393
+
394
+ // Call via a local, NOT `this.#fetchImpl(...)`: invoking the global fetch as a method of this
395
+ // instance throws "Illegal invocation" in workerd (the global fetch requires a global `this`).
396
+ const doFetch = this.#fetchImpl;
397
+ const res = await doFetch(url.toString(), {
398
+ method,
399
+ headers: {
400
+ Authorization: `Bearer ${this.#token}`,
401
+ Accept: 'application/json',
402
+ ...(body ? { 'Content-Type': 'application/json' } : {}),
403
+ },
404
+ ...(body ? { body: JSON.stringify(body) } : {}),
405
+ });
406
+ const text = await res.text();
407
+ let parsed: unknown = text;
408
+ if (text) {
409
+ try {
410
+ parsed = JSON.parse(text);
411
+ } catch {
412
+ /* some endpoints return empty / plain bodies */
413
+ }
414
+ }
415
+ if (!res.ok) {
416
+ const detail = (typeof parsed === 'object' && parsed !== null ? JSON.stringify(parsed) : String(parsed)).slice(0, 500);
417
+ const hint =
418
+ res.status === 401
419
+ ? ' (token expired/invalid or domain out of scope)'
420
+ : res.status === 403
421
+ ? ' (token lacks permission)'
422
+ : res.status === 409
423
+ ? ' (a subscription with matching parameters already exists)'
424
+ : '';
425
+ throw new NsApiError(`${method} ${path} → ${res.status}${hint}: ${detail}`, res.status, path, parsed, method);
426
+ }
427
+ return parsed as T;
428
+ }
429
+ }
430
+
431
+ // ── the pure planner ─────────────────────────────────────────────────────────────────────────────────
432
+
433
+ /** One subscription we want to exist. */
434
+ export interface DesiredSubscription {
435
+ domain: string;
436
+ model: SubscriptionModel;
437
+ /** The exact callback URL this (domain, model) should post to. */
438
+ postUrl: string;
439
+ }
440
+
441
+ /** An action the caller should execute. Every variant carries a human-readable `reason` for logging. */
442
+ export type SubscriptionAction =
443
+ | { kind: 'create'; domain: string; model: SubscriptionModel; postUrl: string; expiresAt: string; reason: string }
444
+ | { kind: 'renew'; id: string; domain: string; expiresAt: string; reason: string }
445
+ | { kind: 'repair-url'; id: string; domain: string; postUrl: string; reason: string }
446
+ | { kind: 'delete'; id: string; domain: string; reason: string }
447
+ | { kind: 'report'; id: string; domain: string; reason: string; status?: string; errorCount?: number; postsCount?: number }
448
+ | { kind: 'noop'; id: string; domain: string; reason: string };
449
+
450
+ export interface PlanSubscriptionsOptions {
451
+ /**
452
+ * Only subscriptions whose `postUrl` starts with this prefix are considered ours. Everything else is
453
+ * left strictly alone — other integrations legitimately subscribe to the same domains.
454
+ */
455
+ ownedPrefix: string;
456
+ /** Renew when the remaining lifetime is below this. */
457
+ renewHorizonSeconds: number;
458
+ /** Lifetime to request on create and renew. */
459
+ targetLifetimeSeconds: number;
460
+ /** Report when `errorCount / postsCount` exceeds this. Default 0.5. */
461
+ errorRateThreshold?: number;
462
+ /** Don't judge an error rate below this many posts — small samples are noise. Default 25. */
463
+ minPostsForRate?: number;
464
+ }
465
+
466
+ function pickCanonical(subs: Subscription[]): Subscription {
467
+ // Prefer an active one, then the most recently created; mirrors how the rest of the stack breaks ties.
468
+ const score = (s: Subscription) => (s.status === 'active' ? 2 : s.status === 'pending' ? 1 : 0);
469
+ return [...subs].sort((a, b) => {
470
+ const d = score(b) - score(a);
471
+ if (d !== 0) return d;
472
+ const at = parseNsDatetime(a.createdAt)?.getTime() ?? 0;
473
+ const bt = parseNsDatetime(b.createdAt)?.getTime() ?? 0;
474
+ return bt - at;
475
+ })[0] as Subscription;
476
+ }
477
+
478
+ /**
479
+ * Decide what to do, given what we want and what the API currently reports. Pure: no I/O, no clock, no
480
+ * configuration beyond {@link PlanSubscriptionsOptions} — so the whole decision surface is unit-testable
481
+ * and shareable by any consumer that manages its own subscriptions.
482
+ *
483
+ * Deliberate behaviours worth knowing:
484
+ * - Subscriptions outside `ownedPrefix` are **never** modified. If one collides with a desired
485
+ * (domain, model) it is *reported*, because two subscriptions on one domain double-deliver.
486
+ * - `errorCount > 0` alone is **not** a fault — see this module's header.
487
+ * - An already-expired subscription yields `renew`, not `delete`+`create`; the caller should fall back to
488
+ * `create` only if the `PUT` reports the subscription is gone.
489
+ */
490
+ export function planSubscriptions(
491
+ desired: DesiredSubscription[],
492
+ actual: Subscription[],
493
+ nowMs: number,
494
+ opts: PlanSubscriptionsOptions,
495
+ ): SubscriptionAction[] {
496
+ const rateThreshold = opts.errorRateThreshold ?? 0.5;
497
+ const minPosts = opts.minPostsForRate ?? 25;
498
+ const targetExpiry = nsDatetime(new Date(nowMs + opts.targetLifetimeSeconds * 1000));
499
+
500
+ const isOurs = (s: Subscription) => typeof s.postUrl === 'string' && s.postUrl.startsWith(opts.ownedPrefix);
501
+ const ours = actual.filter(isOurs);
502
+ const foreign = actual.filter((s) => !isOurs(s));
503
+ const key = (domain: string, model: string) => `${domain.toLowerCase()}\u0000${model}`;
504
+
505
+ const actions: SubscriptionAction[] = [];
506
+ const claimed = new Set<string>();
507
+
508
+ for (const want of desired) {
509
+ const k = key(want.domain, want.model);
510
+ claimed.add(k);
511
+
512
+ const matches = ours.filter((s) => key(s.domain ?? '', s.model ?? '') === k);
513
+
514
+ for (const f of foreign) {
515
+ if (key(f.domain ?? '', f.model ?? '') === k) {
516
+ actions.push({
517
+ kind: 'report',
518
+ id: f.id,
519
+ domain: want.domain,
520
+ reason: 'another integration already subscribes to this domain+model; events will be delivered twice',
521
+ ...(f.status !== undefined ? { status: f.status } : {}),
522
+ });
523
+ }
524
+ }
525
+
526
+ if (matches.length === 0) {
527
+ actions.push({
528
+ kind: 'create',
529
+ domain: want.domain,
530
+ model: want.model,
531
+ postUrl: want.postUrl,
532
+ expiresAt: targetExpiry,
533
+ reason: 'no subscription exists for this domain+model',
534
+ });
535
+ continue;
536
+ }
537
+
538
+ const canonical = pickCanonical(matches);
539
+ for (const extra of matches) {
540
+ if (extra.id !== canonical.id) {
541
+ actions.push({
542
+ kind: 'delete',
543
+ id: extra.id,
544
+ domain: extra.domain ?? want.domain,
545
+ reason: 'duplicate of our own subscription for this domain+model',
546
+ });
547
+ }
548
+ }
549
+
550
+ // Health is reported independently of whether the record also needs a url/expiry change.
551
+ const errs = canonical.errorCount ?? 0;
552
+ const posts = canonical.postsCount ?? 0;
553
+ if (canonical.status === 'error') {
554
+ actions.push({
555
+ kind: 'report',
556
+ id: canonical.id,
557
+ domain: canonical.domain ?? want.domain,
558
+ reason: 'delivery is failing (status=error)',
559
+ ...(canonical.status !== undefined ? { status: canonical.status } : {}),
560
+ errorCount: errs,
561
+ postsCount: posts,
562
+ });
563
+ } else if (posts >= minPosts && errs / posts > rateThreshold) {
564
+ actions.push({
565
+ kind: 'report',
566
+ id: canonical.id,
567
+ domain: canonical.domain ?? want.domain,
568
+ reason: `sustained delivery error rate ${errs}/${posts}`,
569
+ ...(canonical.status !== undefined ? { status: canonical.status } : {}),
570
+ errorCount: errs,
571
+ postsCount: posts,
572
+ });
573
+ }
574
+
575
+ if (canonical.postUrl !== want.postUrl) {
576
+ actions.push({
577
+ kind: 'repair-url',
578
+ id: canonical.id,
579
+ domain: canonical.domain ?? want.domain,
580
+ postUrl: want.postUrl,
581
+ reason: 'callback URL differs from the configured one (deploy moved, or secret rotated)',
582
+ });
583
+ continue;
584
+ }
585
+
586
+ const expiry = parseNsDatetime(canonical.expiresAt);
587
+ if (!expiry) {
588
+ actions.push({
589
+ kind: 'renew',
590
+ id: canonical.id,
591
+ domain: canonical.domain ?? want.domain,
592
+ expiresAt: targetExpiry,
593
+ reason: 'expiry missing or unparseable',
594
+ });
595
+ continue;
596
+ }
597
+ const remainingSeconds = (expiry.getTime() - nowMs) / 1000;
598
+ if (remainingSeconds <= 0) {
599
+ actions.push({
600
+ kind: 'renew',
601
+ id: canonical.id,
602
+ domain: canonical.domain ?? want.domain,
603
+ expiresAt: targetExpiry,
604
+ reason: 'already expired',
605
+ });
606
+ } else if (remainingSeconds < opts.renewHorizonSeconds) {
607
+ actions.push({
608
+ kind: 'renew',
609
+ id: canonical.id,
610
+ domain: canonical.domain ?? want.domain,
611
+ expiresAt: targetExpiry,
612
+ reason: `expires in ${Math.floor(remainingSeconds)}s, inside the renewal horizon`,
613
+ });
614
+ } else {
615
+ actions.push({
616
+ kind: 'noop',
617
+ id: canonical.id,
618
+ domain: canonical.domain ?? want.domain,
619
+ reason: 'present, correct, and not near expiry',
620
+ });
621
+ }
622
+ }
623
+
624
+ // Ours, but no longer wanted.
625
+ for (const s of ours) {
626
+ const k = key(s.domain ?? '', s.model ?? '');
627
+ if (!claimed.has(k)) {
628
+ actions.push({
629
+ kind: 'delete',
630
+ id: s.id,
631
+ domain: s.domain ?? '',
632
+ reason: 'ours, but this domain+model is no longer configured',
633
+ });
634
+ }
635
+ }
636
+
637
+ return actions;
638
+ }