@dszp/netsapiens-lib 0.1.8 → 0.3.0

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 (80) 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 +151 -0
  11. package/dist/inventory.d.ts.map +1 -0
  12. package/dist/inventory.js +0 -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 +8 -2
  36. package/dist/policy.d.ts.map +1 -0
  37. package/dist/policy.js +3 -1
  38. package/dist/policy.js.map +1 -0
  39. package/dist/principal.d.ts.map +1 -0
  40. package/dist/principal.js.map +1 -0
  41. package/dist/raster.d.ts.map +1 -0
  42. package/dist/raster.js.map +1 -0
  43. package/dist/resolver.d.ts.map +1 -0
  44. package/dist/resolver.js.map +1 -0
  45. package/dist/sensitivity.d.ts.map +1 -0
  46. package/dist/sensitivity.js.map +1 -0
  47. package/dist/themes.d.ts.map +1 -0
  48. package/dist/themes.js.map +1 -0
  49. package/package.json +7 -3
  50. package/src/eligibility.selftest.ts +95 -0
  51. package/src/eligibility.ts +118 -0
  52. package/src/html.ts +407 -0
  53. package/src/index.ts +120 -0
  54. package/src/inventory.selftest.ts +198 -0
  55. package/src/inventory.ts +314 -0
  56. package/src/jwt.selftest.ts +145 -0
  57. package/src/jwt.ts +491 -0
  58. package/src/mermaid.ts +169 -0
  59. package/src/model.ts +130 -0
  60. package/src/nsAuthClient.selftest.ts +60 -0
  61. package/src/nsAuthClient.ts +102 -0
  62. package/src/nsClient.selftest.ts +173 -0
  63. package/src/nsClient.ts +323 -0
  64. package/src/nsDevice.selftest.ts +190 -0
  65. package/src/nsDevice.ts +167 -0
  66. package/src/nsSubscriptions.selftest.ts +486 -0
  67. package/src/nsSubscriptions.ts +638 -0
  68. package/src/nsSynchronous.selftest.ts +63 -0
  69. package/src/nsSynchronous.ts +98 -0
  70. package/src/nsWriteClient.selftest.ts +104 -0
  71. package/src/nsWriteClient.ts +157 -0
  72. package/src/policy.ts +123 -0
  73. package/src/principal.selftest.ts +118 -0
  74. package/src/principal.ts +101 -0
  75. package/src/raster.selftest.ts +42 -0
  76. package/src/raster.ts +79 -0
  77. package/src/resolver.selftest.ts +225 -0
  78. package/src/resolver.ts +1115 -0
  79. package/src/sensitivity.ts +40 -0
  80. package/src/themes.ts +142 -0
@@ -0,0 +1,167 @@
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
+
16
+ /** The NS device field carrying the auto-generated SIP registration password (API v2). */
17
+ export const SIP_PW_FIELD = 'device-sip-registration-password';
18
+
19
+ /** The subset of a write client this needs. Structural, so a consumer can inject a mock or a subset. */
20
+ export interface NsDeviceWriter {
21
+ getDevices(domain: string, user: string): Promise<Rec[]>;
22
+ getDevice(domain: string, user: string, device: string): Promise<Rec>;
23
+ createDevice(domain: string, user: string, device: string, extra?: Rec): Promise<Rec>;
24
+ updateDevice(domain: string, user: string, device: string, changes: Rec): Promise<Rec>;
25
+ }
26
+
27
+ const PW_UPPER = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
28
+ const PW_LOWER = 'abcdefghijklmnopqrstuvwxyz';
29
+ const PW_DIGIT = '0123456789';
30
+ const PW_ALPHABET = PW_UPPER + PW_LOWER + PW_DIGIT;
31
+
32
+ /** Uniformly-drawn characters from the alphabet, rejection-sampled so no symbol is over-represented. */
33
+ function randomChars(n: number): string[] {
34
+ const out: string[] = [];
35
+ const buf = new Uint8Array(n * 2);
36
+ const limit = Math.floor(256 / PW_ALPHABET.length) * PW_ALPHABET.length; // 248 for a 62-symbol alphabet
37
+ while (out.length < n) {
38
+ crypto.getRandomValues(buf);
39
+ for (const b of buf) {
40
+ if (b >= limit) continue; // reject, to keep the distribution uniform
41
+ out.push(PW_ALPHABET[b % PW_ALPHABET.length]!);
42
+ if (out.length === n) break;
43
+ }
44
+ }
45
+ return out;
46
+ }
47
+
48
+ const hasEach = (s: string): boolean => /[A-Z]/.test(s) && /[a-z]/.test(s) && /[0-9]/.test(s);
49
+
50
+ /**
51
+ * Generate a SIP registration password.
52
+ *
53
+ * Alphanumeric only: the value travels through SIP digest auth, device provisioning templates, and
54
+ * whatever the consuming app stores it in, and punctuation buys no meaningful entropy while risking an
55
+ * escaping bug in any one of those. Characters are rejection-sampled rather than modulo-reduced, so every
56
+ * symbol is equally likely.
57
+ *
58
+ * **Guarantees at least one uppercase, one lowercase, and one digit** (for `length >= 3`). A uniform draw
59
+ * from a 62-symbol alphabet omits digits entirely about 3% of the time at length 20, which looks like a
60
+ * bug to anyone who eyeballs one and can trip a downstream password-complexity rule. The whole candidate
61
+ * is redrawn until it qualifies — never patched in place, which would bias the positions it patched.
62
+ */
63
+ export function generateSipPassword(length = 20): string {
64
+ if (!Number.isInteger(length) || length < 1) throw new Error('generateSipPassword: length must be a positive integer');
65
+ // Below 3 characters the guarantee is arithmetically impossible; return a uniform draw.
66
+ if (length < 3) return randomChars(length).join('');
67
+ for (let attempt = 0; attempt < 100; attempt++) {
68
+ const candidate = randomChars(length).join('');
69
+ if (hasEach(candidate)) return candidate;
70
+ }
71
+ // Unreachable in practice (the odds compound to ~0). Draw one character from each class DIRECTLY —
72
+ // upper-casing an arbitrary draw is not a guarantee, since upper-casing a digit yields the same digit.
73
+ const pick = (alphabet: string): string => {
74
+ const b = new Uint8Array(1);
75
+ const limit = Math.floor(256 / alphabet.length) * alphabet.length;
76
+ for (;;) {
77
+ crypto.getRandomValues(b);
78
+ if (b[0]! < limit) return alphabet[b[0]! % alphabet.length]!;
79
+ }
80
+ };
81
+ return [pick(PW_UPPER), pick(PW_LOWER), pick(PW_DIGIT), ...randomChars(length - 3)].join('');
82
+ }
83
+
84
+ export interface EnsureNsDeviceOptions {
85
+ domain: string;
86
+ /** The NS user / extension that owns the device. */
87
+ user: string;
88
+ /** The device name, e.g. `100r`. */
89
+ device: string;
90
+ /**
91
+ * May this create the device when it is absent? Default `true`. Pass `false` to look without creating —
92
+ * the result's `password` is then `''` for a missing device, which a caller can treat as "refuse".
93
+ */
94
+ mayCreate?: boolean;
95
+ /**
96
+ * Replace the password of a device that **already existed**.
97
+ *
98
+ * This closes a subtle and genuinely hard-to-diagnose failure: reusing the stored password leaves any
99
+ * *other* endpoint still holding it with valid credentials for the same address-of-record. Both clients
100
+ * then register, the most recent wins, and they trade the registration back and forth — intermittent
101
+ * call failures with nothing obviously wrong in either system.
102
+ *
103
+ * Rotate only where something has just declared this device to belong to one client — a deliberate
104
+ * activation or a first-time provision. **Do not rotate on a per-login or per-request path**: concurrent
105
+ * runs would churn the credential and can race a re-registration.
106
+ *
107
+ * Rotation is **best-effort** and never throws: on failure the result carries the pre-existing password
108
+ * plus `rotated: false` and `rotateError`, because failing the whole operation over a hardening step
109
+ * would be worse than the contention it prevents. Notably a NetSapiens release without the device `PUT`
110
+ * lands here.
111
+ */
112
+ rotateExisting?: boolean;
113
+ /** Length for a rotated password. Default 20. */
114
+ passwordLength?: number;
115
+ }
116
+
117
+ export interface EnsureNsDeviceResult {
118
+ /** The SIP password to give the client. `''` means "absent and not created" — treat as a refusal. */
119
+ password: string;
120
+ /** True when this call created the device. */
121
+ created: boolean;
122
+ /** Present only when `rotateExisting` was requested: whether the rotation actually happened. */
123
+ rotated?: boolean;
124
+ /** Why rotation failed, when it did. */
125
+ rotateError?: string;
126
+ }
127
+
128
+ /**
129
+ * Ensure the device exists and return its SIP password.
130
+ *
131
+ * Present: read it back with a per-device GET, because a device *list* may omit the password. Absent:
132
+ * create it (NetSapiens generates the password; a `synchronous` write returns it inline) unless
133
+ * `mayCreate` is false.
134
+ *
135
+ * A newly created device is never rotated — it already has a fresh, exclusive password.
136
+ */
137
+ export async function ensureNsDevice(writer: NsDeviceWriter, opts: EnsureNsDeviceOptions): Promise<EnsureNsDeviceResult> {
138
+ const { domain, user, device } = opts;
139
+ const devices = await writer.getDevices(domain, user);
140
+ const existing = Array.isArray(devices) ? devices.find((d) => String(d['device'] ?? '') === device) : undefined;
141
+
142
+ if (existing) {
143
+ const dev = await writer.getDevice(domain, user, device);
144
+ const current = String(dev[SIP_PW_FIELD] ?? existing[SIP_PW_FIELD] ?? '');
145
+ if (!opts.rotateExisting) return { password: current, created: false };
146
+
147
+ const fresh = generateSipPassword(opts.passwordLength ?? 20);
148
+ try {
149
+ const updated = await writer.updateDevice(domain, user, device, { [SIP_PW_FIELD]: fresh });
150
+ // Prefer what NS echoes back if it echoes anything; otherwise the value we just set.
151
+ // `||` not `??`: an echoed empty string would otherwise be handed back as the password, and the
152
+ // caller's blank-password guard would refuse AFTER the device was already rotated.
153
+ return { password: String(updated?.[SIP_PW_FIELD] || fresh), created: false, rotated: true };
154
+ } catch (e) {
155
+ return {
156
+ password: current,
157
+ created: false,
158
+ rotated: false,
159
+ rotateError: String((e as Error)?.message ?? e).slice(0, 200),
160
+ };
161
+ }
162
+ }
163
+
164
+ if (opts.mayCreate === false) return { password: '', created: false };
165
+ const created = await writer.createDevice(domain, user, device);
166
+ return { password: String(created?.[SIP_PW_FIELD] ?? ''), created: true };
167
+ }
@@ -0,0 +1,486 @@
1
+ /**
2
+ * Selftests for the Event Subscriptions client + planner. Offline: every HTTP call goes through an
3
+ * injected fetch stub that records what was sent.
4
+ *
5
+ * Run: pnpm test:nssubs
6
+ */
7
+ import {
8
+ NsSubscriptionsClient,
9
+ NsSubscriptionConflictError,
10
+ SUBSCRIPTION_MODELS,
11
+ isSubscriptionModel,
12
+ nsDatetime,
13
+ parseNsDatetime,
14
+ subscriptionFromWire,
15
+ createInputToWire,
16
+ updateInputToWire,
17
+ planSubscriptions,
18
+ type Subscription,
19
+ type DesiredSubscription,
20
+ type SubscriptionAction,
21
+ } from './nsSubscriptions.js';
22
+ import { NsApiError } from './nsClient.js';
23
+ import type { Rec } from './model.js';
24
+
25
+ let pass = 0,
26
+ fail = 0;
27
+ const ok = (c: boolean, m: string) => {
28
+ c ? pass++ : fail++;
29
+ console.log(`${c ? '✓' : '✗ FAIL'} ${m}`);
30
+ };
31
+
32
+ // ── fetch stub ────────────────────────────────────────────────────────────────
33
+ interface Call {
34
+ url: string;
35
+ method: string;
36
+ headers: Record<string, string>;
37
+ body?: unknown;
38
+ }
39
+ let calls: Call[] = [];
40
+ type Responder = (call: Call) => { status?: number; json?: unknown; text?: string };
41
+ let responder: Responder = () => ({ status: 200, json: {} });
42
+
43
+ const stubFetch: typeof fetch = async (input, init) => {
44
+ const call: Call = {
45
+ url: String(input),
46
+ method: init?.method ?? 'GET',
47
+ headers: (init?.headers ?? {}) as Record<string, string>,
48
+ body: init?.body ? JSON.parse(String(init.body)) : undefined,
49
+ };
50
+ calls.push(call);
51
+ const r = responder(call);
52
+ const status = r.status ?? 200;
53
+ const text = r.text ?? (r.json === undefined ? '' : JSON.stringify(r.json));
54
+ return {
55
+ ok: status >= 200 && status < 300,
56
+ status,
57
+ text: async () => text,
58
+ } as Response;
59
+ };
60
+ const reset = (r?: Responder) => {
61
+ calls = [];
62
+ if (r) responder = r;
63
+ };
64
+ const mk = () => new NsSubscriptionsClient({ server: 'api.example.com', token: 'nsu_test', fetchImpl: stubFetch, pageSize: 2 });
65
+
66
+ // ── model enum ────────────────────────────────────────────────────────────────
67
+ ok(SUBSCRIPTION_MODELS.length === 11, 'eleven models are defined');
68
+ ok(SUBSCRIPTION_MODELS.includes('subscriber') && SUBSCRIPTION_MODELS.includes('auditlog_lite'), 'subscriber and auditlog_lite are present');
69
+ ok(!(SUBSCRIPTION_MODELS as readonly string[]).includes('device'), 'there is deliberately no device model');
70
+ ok(isSubscriptionModel('presence') && !isSubscriptionModel('contacts') && !isSubscriptionModel(''), 'isSubscriptionModel rejects unknown and empty');
71
+ ok(!isSubscriptionModel(undefined) && !isSubscriptionModel(3), 'isSubscriptionModel rejects non-strings');
72
+
73
+ // ── datetime ──────────────────────────────────────────────────────────────────
74
+ ok(nsDatetime(new Date(Date.UTC(2026, 6, 25, 3, 4, 5))) === '2026-07-25 03:04:05', 'nsDatetime emits the documented write format in UTC');
75
+ ok(nsDatetime(new Date(Date.UTC(2026, 0, 2, 0, 0, 0))) === '2026-01-02 00:00:00', 'nsDatetime zero-pads month/day/time');
76
+ ok(parseNsDatetime('2026-07-25 03:04:05')?.getTime() === Date.UTC(2026, 6, 25, 3, 4, 5), 'parseNsDatetime reads the documented bare format as UTC');
77
+ ok(parseNsDatetime('2026-07-24T16:46:17+00:00')?.getTime() === Date.UTC(2026, 6, 24, 16, 46, 17), 'parseNsDatetime reads the ISO-8601 form the API actually returns');
78
+ ok(parseNsDatetime('2026-07-24T16:46:17-04:00')?.getTime() === Date.UTC(2026, 6, 24, 20, 46, 17), 'parseNsDatetime honours a non-UTC offset');
79
+ {
80
+ const d = new Date(Date.UTC(2046, 3, 4, 19, 39, 37));
81
+ ok(parseNsDatetime(nsDatetime(d))?.getTime() === d.getTime(), 'nsDatetime round-trips through parseNsDatetime');
82
+ }
83
+ ok(parseNsDatetime('not a date') === undefined, 'parseNsDatetime returns undefined on garbage, not an Invalid Date');
84
+ ok(parseNsDatetime('') === undefined && parseNsDatetime(undefined) === undefined && parseNsDatetime(null) === undefined, 'parseNsDatetime handles empty/undefined/null');
85
+ ok(parseNsDatetime('2026-07-25 03:04') !== undefined, 'parseNsDatetime tolerates a missing seconds component');
86
+
87
+ // ── wire mapping ──────────────────────────────────────────────────────────────
88
+ {
89
+ // Shaped exactly like a real API record.
90
+ const wire: Rec = {
91
+ id: 'da268bc8d6f46714c5933324aae107c2',
92
+ 'subscription-geo-support': 'yes',
93
+ 'post-url': 'https://hooks.example.com/ns-events/tok/acme.example.com',
94
+ model: 'subscriber',
95
+ 'user-scope': 'Reseller',
96
+ reseller: '12345.service',
97
+ domain: 'acme.example.com',
98
+ user: '*',
99
+ 'preferred-server': 'core1.example.com',
100
+ 'current-active-server': 'core1.example.com',
101
+ status: 'active',
102
+ 'error-count': 15,
103
+ 'posts-count': 207,
104
+ 'subscription-creation-datetime': '2026-07-24T16:46:17+00:00',
105
+ 'subscription-expires-datetime': '2046-04-04T19:39:37+00:00',
106
+ };
107
+ const s = subscriptionFromWire(wire);
108
+ ok(s.id === 'da268bc8d6f46714c5933324aae107c2', 'fromWire maps id');
109
+ ok(s.postUrl === 'https://hooks.example.com/ns-events/tok/acme.example.com', 'fromWire maps post-url → postUrl');
110
+ ok(s.geoSupport === 'yes' && s.userScope === 'Reseller', 'fromWire maps geo-support and user-scope');
111
+ ok(s.currentActiveServer === 'core1.example.com' && s.preferredServer === 'core1.example.com', 'fromWire maps both server fields');
112
+ ok(s.errorCount === 15 && s.postsCount === 207, 'fromWire maps the counters as numbers');
113
+ ok(s.expiresAt === '2046-04-04T19:39:37+00:00', 'fromWire keeps the raw expiry string');
114
+ ok(s.raw === wire, 'fromWire preserves the untouched record on .raw');
115
+ }
116
+ {
117
+ const s = subscriptionFromWire({ id: 'x', 'error-count': '7', 'posts-count': '7043' });
118
+ ok(s.errorCount === 7 && s.postsCount === 7043, 'fromWire coerces string counters to numbers');
119
+ ok(s.model === undefined && s.domain === undefined, 'fromWire leaves unmodelled/missing fields absent rather than empty-string');
120
+ }
121
+ {
122
+ const s = subscriptionFromWire({});
123
+ ok(s.id === '', 'fromWire yields an empty id for a record without one (planner filters these out)');
124
+ }
125
+
126
+ {
127
+ const body = createInputToWire({ model: 'subscriber', postUrl: 'https://hooks.example.com/e', domain: 'acme' });
128
+ ok(body['post-url'] === 'https://hooks.example.com/e' && body['model'] === 'subscriber', 'createInputToWire hyphenates post-url');
129
+ ok(body['domain'] === 'acme', 'createInputToWire passes the domain filter');
130
+ ok(!('subscription-expires-datetime' in body) && !('user' in body), 'createInputToWire omits fields that were not supplied');
131
+ }
132
+ {
133
+ const body = createInputToWire({
134
+ model: 'subscriber',
135
+ postUrl: 'https://hooks.example.com/e',
136
+ expiresAt: new Date(Date.UTC(2030, 0, 1, 0, 0, 0)),
137
+ geoSupport: 'yes',
138
+ user: '*',
139
+ });
140
+ ok(body['subscription-expires-datetime'] === '2030-01-01 00:00:00', 'createInputToWire formats a Date expiry as the write format');
141
+ ok(body['subscription-geo-support'] === 'yes', 'createInputToWire hyphenates geo-support');
142
+ }
143
+ {
144
+ const body = createInputToWire({ model: 'subscriber', postUrl: 'u', expiresAt: '2030-01-01 00:00:00' });
145
+ ok(body['subscription-expires-datetime'] === '2030-01-01 00:00:00', 'createInputToWire passes a string expiry through unchanged');
146
+ }
147
+ {
148
+ const body = updateInputToWire({ postUrl: 'https://hooks.example.com/new', errorCount: 0 });
149
+ ok(body['post-url'] === 'https://hooks.example.com/new' && body['error-count'] === 0, 'updateInputToWire maps post-url and a counter reset');
150
+ ok(!('domain' in body) && !('user' in body) && !('reseller' in body), 'updateInputToWire cannot express the immutable filters');
151
+ ok(Object.keys(updateInputToWire({})).length === 0, 'updateInputToWire of nothing is an empty body');
152
+ }
153
+
154
+ // ── client: transport ─────────────────────────────────────────────────────────
155
+ {
156
+ reset(() => ({ status: 200, json: [{ id: 'a' }] }));
157
+ await mk().list();
158
+ const c = calls[0]!;
159
+ ok(c.url.startsWith('https://api.example.com/ns-api/v2/subscriptions'), 'list hits /ns-api/v2/subscriptions over https');
160
+ ok(c.method === 'GET' && c.headers['Authorization'] === 'Bearer nsu_test', 'list is a GET with the bearer token');
161
+ ok(c.body === undefined, 'a GET carries no body');
162
+ ok(!('Content-Type' in c.headers), 'a bodyless request sets no Content-Type');
163
+ }
164
+ {
165
+ reset(() => ({ status: 200, json: [] }));
166
+ await mk().listForDomain('acme.example.com');
167
+ ok(calls[0]!.url.includes('/domains/acme.example.com/subscriptions'), 'listForDomain uses the domain-scoped path');
168
+ }
169
+ {
170
+ let threw = '';
171
+ try {
172
+ new NsSubscriptionsClient({ server: 'api.example.com/evil', token: 't', fetchImpl: stubFetch });
173
+ } catch (e) {
174
+ threw = (e as Error).message;
175
+ }
176
+ ok(threw.includes('Invalid NS server'), 'the SSRF guard from the read client is applied to server');
177
+ }
178
+
179
+ // ── client: paging ────────────────────────────────────────────────────────────
180
+ {
181
+ // pageSize is 2. A short first page means "done" — this is the real-world case today (5 records).
182
+ reset(() => ({ status: 200, json: [{ id: 'a' }] }));
183
+ const out = await mk().list();
184
+ ok(out.length === 1 && calls.length === 1, 'a short page ends pagination after one request');
185
+ }
186
+ {
187
+ // Full page, then a short page → two requests, both pages kept.
188
+ let n = 0;
189
+ reset(() => {
190
+ n++;
191
+ return { status: 200, json: n === 1 ? [{ id: 'a' }, { id: 'b' }] : [{ id: 'c' }] };
192
+ });
193
+ const out = await mk().list();
194
+ ok(out.length === 3 && out.map((s) => s.id).join(',') === 'a,b,c', 'a full page is followed by the next page');
195
+ ok(calls.length === 2 && calls[1]!.url.includes('start=2'), 'the second request advances the start offset');
196
+ ok(calls[0]!.url.includes('limit=2'), 'the page size is sent as limit');
197
+ }
198
+ {
199
+ // A server that IGNORES limit/start returns the same full page forever. Must not loop.
200
+ reset(() => ({ status: 200, json: [{ id: 'a' }, { id: 'b' }] }));
201
+ const out = await mk().list();
202
+ ok(out.length === 2, 'a paging-ignorant server yields each record once');
203
+ ok(calls.length === 2, 'a repeated identical page stops the loop instead of spinning');
204
+ }
205
+ {
206
+ reset(() => ({ status: 200, json: [] }));
207
+ const out = await mk().list();
208
+ ok(out.length === 0 && calls.length === 1, 'an empty first page yields nothing and stops');
209
+ }
210
+ {
211
+ // Records without an id are skipped rather than becoming phantom subscriptions.
212
+ reset(() => ({ status: 200, json: [{ id: 'a' }, {}] }));
213
+ const out = await mk().list();
214
+ ok(out.length === 1 && out[0]!.id === 'a', 'records without an id are dropped');
215
+ }
216
+
217
+ // ── client: create / update / delete ──────────────────────────────────────────
218
+ {
219
+ reset(() => ({ status: 200, json: { id: 'new1', status: 'pending' } }));
220
+ const s = await mk().create({ model: 'subscriber', postUrl: 'https://hooks.example.com/e', domain: 'acme' });
221
+ ok(s.id === 'new1' && s.status === 'pending', 'create returns the parsed subscription');
222
+ ok(calls[0]!.method === 'POST' && calls[0]!.headers['Content-Type'] === 'application/json', 'create POSTs JSON');
223
+ ok((calls[0]!.body as Rec)['post-url'] === 'https://hooks.example.com/e', 'create sends the hyphenated body');
224
+ ok(!('synchronous' in (calls[0]!.body as Rec)), "create does NOT inject synchronous:'yes' (unlike NsWriteClient)");
225
+ }
226
+ {
227
+ reset(() => ({ status: 200, json: { id: 'n' } }));
228
+ await mk().createForDomain('acme.example.com', { model: 'subscriber', postUrl: 'u' });
229
+ ok(calls[0]!.url.includes('/domains/acme.example.com/subscriptions'), 'createForDomain posts to the domain-scoped path');
230
+ }
231
+ {
232
+ reset(() => ({ status: 409, json: { message: 'Subscription ID already Exists or matching set of parameters already exists' } }));
233
+ let err: unknown;
234
+ try {
235
+ await mk().create({ model: 'subscriber', postUrl: 'u' });
236
+ } catch (e) {
237
+ err = e;
238
+ }
239
+ ok(err instanceof NsSubscriptionConflictError, '409 becomes NsSubscriptionConflictError');
240
+ ok(err instanceof NsApiError, 'the conflict error is still an NsApiError so existing handlers keep working');
241
+ ok((err as NsApiError).status === 409, 'the conflict error carries status 409');
242
+ }
243
+ {
244
+ reset(() => ({ status: 401, json: { code: 401, message: 'nope' } }));
245
+ let err: unknown;
246
+ try {
247
+ await mk().list();
248
+ } catch (e) {
249
+ err = e;
250
+ }
251
+ ok(err instanceof NsApiError && (err as NsApiError).status === 401, 'a 401 surfaces as NsApiError');
252
+ ok(!(err instanceof NsSubscriptionConflictError), 'a non-409 is not turned into a conflict');
253
+ ok((err as Error).message.includes('token expired/invalid'), 'the 401 hint is included');
254
+ }
255
+ {
256
+ reset(() => ({ status: 202, json: { code: 202, message: 'ok' } }));
257
+ await mk().update('abc', { expiresAt: new Date(Date.UTC(2030, 0, 1)) });
258
+ ok(calls[0]!.method === 'PUT' && calls[0]!.url.endsWith('/subscriptions/abc'), 'update PUTs to the id path');
259
+ ok((calls[0]!.body as Rec)['subscription-expires-datetime'] === '2030-01-01 00:00:00', 'update sends the formatted expiry — this is how renewal works');
260
+ }
261
+ {
262
+ reset(() => ({ status: 202, json: {} }));
263
+ await mk().remove('abc', { domain: 'acme.example.com' });
264
+ const c = calls[0]!;
265
+ ok(c.method === 'DELETE' && c.url.endsWith('/subscriptions/abc'), 'remove DELETEs the id path');
266
+ ok((c.body as Rec)['subscription_id'] === 'abc', 'remove sends subscription_id IN THE BODY (the reason this client exists)');
267
+ ok((c.body as Rec)['domain'] === 'acme.example.com', 'remove sends domain, required below Super User scope');
268
+ ok(c.headers['Content-Type'] === 'application/json', 'the DELETE body is sent as JSON');
269
+ }
270
+ {
271
+ reset(() => ({ status: 202, json: {} }));
272
+ await mk().remove('abc');
273
+ ok(!('domain' in (calls[0]!.body as Rec)), 'remove omits domain when not supplied');
274
+ }
275
+ {
276
+ reset(() => ({ status: 200, text: '' }));
277
+ const s = await mk().get('abc');
278
+ ok(s.id === '' && calls[0]!.method === 'GET', 'an empty body does not throw');
279
+ }
280
+ {
281
+ reset(() => ({ status: 200, json: { id: 'weird/id?x' } }));
282
+ await mk().get('weird/id?x');
283
+ ok(calls[0]!.url.includes('weird%2Fid%3Fx'), 'the id is percent-encoded into the path');
284
+ }
285
+
286
+ // ── planner ───────────────────────────────────────────────────────────────────
287
+ const PREFIX = 'https://hooks.example.com/ns-events/';
288
+ const NOW = Date.UTC(2026, 6, 25, 0, 0, 0);
289
+ const HOUR = 3600;
290
+ const DAY = 86400;
291
+ const OPTS = { ownedPrefix: PREFIX, renewHorizonSeconds: 3 * DAY, targetLifetimeSeconds: 30 * DAY };
292
+ const want = (domain: string): DesiredSubscription => ({ domain, model: 'subscriber', postUrl: `${PREFIX}tok/${domain}` });
293
+ const sub = (o: Partial<Subscription> & { id: string }): Subscription => ({ raw: {}, ...o });
294
+ const kinds = (a: SubscriptionAction[]) => a.map((x) => x.kind).sort().join(',');
295
+ const find = <K extends SubscriptionAction['kind']>(a: SubscriptionAction[], k: K) =>
296
+ a.find((x) => x.kind === k) as Extract<SubscriptionAction, { kind: K }> | undefined;
297
+
298
+ {
299
+ const a = planSubscriptions([want('acme')], [], NOW, OPTS);
300
+ ok(kinds(a) === 'create', 'nothing present ⇒ create');
301
+ ok(find(a, 'create')!.postUrl === `${PREFIX}tok/acme`, 'create carries the configured callback URL');
302
+ ok(find(a, 'create')!.expiresAt === '2026-08-24 00:00:00', 'create requests the target lifetime as an explicit expiry');
303
+ }
304
+ {
305
+ const healthy = sub({
306
+ id: '1',
307
+ domain: 'acme',
308
+ model: 'subscriber',
309
+ postUrl: `${PREFIX}tok/acme`,
310
+ status: 'active',
311
+ expiresAt: '2046-01-01T00:00:00+00:00',
312
+ });
313
+ ok(kinds(planSubscriptions([want('acme')], [healthy], NOW, OPTS)) === 'noop', 'present, correct and far from expiry ⇒ noop');
314
+ }
315
+ {
316
+ // Phase 0 finding 2: a healthy live subscription really does sit at 7 errors / 7043 posts.
317
+ const noisy = sub({
318
+ id: '1',
319
+ domain: 'acme',
320
+ model: 'subscriber',
321
+ postUrl: `${PREFIX}tok/acme`,
322
+ status: 'active',
323
+ errorCount: 7,
324
+ postsCount: 7043,
325
+ expiresAt: '2046-01-01T00:00:00+00:00',
326
+ });
327
+ ok(kinds(planSubscriptions([want('acme')], [noisy], NOW, OPTS)) === 'noop', 'errorCount > 0 on an active subscription is NOT a fault');
328
+ }
329
+ {
330
+ const broken = sub({
331
+ id: '1',
332
+ domain: 'acme',
333
+ model: 'subscriber',
334
+ postUrl: `${PREFIX}tok/acme`,
335
+ status: 'error',
336
+ errorCount: 40,
337
+ postsCount: 40,
338
+ expiresAt: '2046-01-01T00:00:00+00:00',
339
+ });
340
+ const a = planSubscriptions([want('acme')], [broken], NOW, OPTS);
341
+ ok(kinds(a) === 'noop,report', 'status=error is reported, and the record is otherwise left alone');
342
+ ok(find(a, 'report')!.errorCount === 40, 'the report carries the counters');
343
+ ok(!a.some((x) => x.kind === 'delete'), 'an unhealthy subscription is never deleted');
344
+ }
345
+ {
346
+ const rate = sub({
347
+ id: '1',
348
+ domain: 'acme',
349
+ model: 'subscriber',
350
+ postUrl: `${PREFIX}tok/acme`,
351
+ status: 'active',
352
+ errorCount: 30,
353
+ postsCount: 40,
354
+ expiresAt: '2046-01-01T00:00:00+00:00',
355
+ });
356
+ ok(kinds(planSubscriptions([want('acme')], [rate], NOW, OPTS)) === 'noop,report', 'a sustained high error RATE is reported');
357
+ }
358
+ {
359
+ const small = sub({
360
+ id: '1',
361
+ domain: 'acme',
362
+ model: 'subscriber',
363
+ postUrl: `${PREFIX}tok/acme`,
364
+ status: 'active',
365
+ errorCount: 3,
366
+ postsCount: 3,
367
+ expiresAt: '2046-01-01T00:00:00+00:00',
368
+ });
369
+ ok(kinds(planSubscriptions([want('acme')], [small], NOW, OPTS)) === 'noop', 'a tiny sample is not judged on error rate');
370
+ }
371
+ {
372
+ const soon = sub({
373
+ id: '1',
374
+ domain: 'acme',
375
+ model: 'subscriber',
376
+ postUrl: `${PREFIX}tok/acme`,
377
+ status: 'active',
378
+ expiresAt: nsDatetime(new Date(NOW + 2 * HOUR * 1000)),
379
+ });
380
+ const a = planSubscriptions([want('acme')], [soon], NOW, OPTS);
381
+ ok(kinds(a) === 'renew', 'expiry inside the horizon ⇒ renew');
382
+ ok(find(a, 'renew')!.expiresAt === '2026-08-24 00:00:00', 'renew pushes the expiry out to the target lifetime');
383
+ ok(find(a, 'renew')!.id === '1', 'renew targets the existing id (a PUT, not a recreate)');
384
+ }
385
+ {
386
+ const expired = sub({
387
+ id: '1',
388
+ domain: 'acme',
389
+ model: 'subscriber',
390
+ postUrl: `${PREFIX}tok/acme`,
391
+ expiresAt: nsDatetime(new Date(NOW - DAY * 1000)),
392
+ });
393
+ const a = planSubscriptions([want('acme')], [expired], NOW, OPTS);
394
+ ok(kinds(a) === 'renew' && find(a, 'renew')!.reason === 'already expired', 'an already-expired subscription is renewed, not recreated');
395
+ }
396
+ {
397
+ const noExp = sub({ id: '1', domain: 'acme', model: 'subscriber', postUrl: `${PREFIX}tok/acme`, expiresAt: 'garbage' });
398
+ ok(kinds(planSubscriptions([want('acme')], [noExp], NOW, OPTS)) === 'renew', 'an unparseable expiry is treated as needing renewal');
399
+ }
400
+ {
401
+ const drifted = sub({
402
+ id: '1',
403
+ domain: 'acme',
404
+ model: 'subscriber',
405
+ postUrl: `${PREFIX}OLDTOKEN/acme`,
406
+ expiresAt: '2046-01-01T00:00:00+00:00',
407
+ });
408
+ const a = planSubscriptions([want('acme')], [drifted], NOW, OPTS);
409
+ ok(kinds(a) === 'repair-url', 'a drifted callback URL ⇒ repair-url (this is what makes secret rotation possible)');
410
+ ok(find(a, 'repair-url')!.postUrl === `${PREFIX}tok/acme`, 'repair-url carries the new URL');
411
+ }
412
+ {
413
+ // The live-fleet case: another integration owns a subscriber subscription on the same domain.
414
+ const foreign = sub({
415
+ id: 'f1',
416
+ domain: 'acme',
417
+ model: 'subscriber',
418
+ postUrl: 'https://automation.example.net/webhook/uuid',
419
+ status: 'active',
420
+ errorCount: 15,
421
+ postsCount: 207,
422
+ });
423
+ const a = planSubscriptions([want('acme')], [foreign], NOW, OPTS);
424
+ ok(a.every((x) => x.kind !== 'delete'), "a foreign subscription is NEVER deleted");
425
+ ok(a.every((x) => x.kind !== 'renew' && x.kind !== 'repair-url'), 'a foreign subscription is never modified');
426
+ ok(find(a, 'report')?.id === 'f1', 'a foreign subscription on a domain we want is reported (double delivery)');
427
+ ok(find(a, 'create') !== undefined, 'we still create our own alongside it');
428
+ }
429
+ {
430
+ const foreignElsewhere = sub({ id: 'f1', domain: 'other', model: 'message', postUrl: 'https://vendor.example.net/x' });
431
+ const a = planSubscriptions([want('acme')], [foreignElsewhere], NOW, OPTS);
432
+ ok(kinds(a) === 'create', 'an unrelated foreign subscription produces no noise at all');
433
+ }
434
+ {
435
+ const stale = sub({ id: '1', domain: 'gone', model: 'subscriber', postUrl: `${PREFIX}tok/gone`, expiresAt: '2046-01-01T00:00:00+00:00' });
436
+ const a = planSubscriptions([want('acme')], [stale], NOW, OPTS);
437
+ ok(find(a, 'delete')?.id === '1', 'ours but no longer configured ⇒ delete');
438
+ ok(find(a, 'create') !== undefined, 'and the newly-configured domain is created');
439
+ }
440
+ {
441
+ const dupA = sub({ id: 'old', domain: 'acme', model: 'subscriber', postUrl: `${PREFIX}tok/acme`, status: 'pending', createdAt: '2026-01-01T00:00:00+00:00', expiresAt: '2046-01-01T00:00:00+00:00' });
442
+ const dupB = sub({ id: 'live', domain: 'acme', model: 'subscriber', postUrl: `${PREFIX}tok/acme`, status: 'active', createdAt: '2025-01-01T00:00:00+00:00', expiresAt: '2046-01-01T00:00:00+00:00' });
443
+ const a = planSubscriptions([want('acme')], [dupA, dupB], NOW, OPTS);
444
+ ok(find(a, 'delete')?.id === 'old', 'among our own duplicates the ACTIVE one is kept even if older');
445
+ ok(find(a, 'noop')?.id === 'live', 'the active duplicate is the canonical record');
446
+ }
447
+ {
448
+ const d1 = sub({ id: 'a', domain: 'acme', model: 'subscriber', postUrl: `${PREFIX}tok/acme`, status: 'pending', createdAt: '2026-01-01T00:00:00+00:00', expiresAt: '2046-01-01T00:00:00+00:00' });
449
+ const d2 = sub({ id: 'b', domain: 'acme', model: 'subscriber', postUrl: `${PREFIX}tok/acme`, status: 'pending', createdAt: '2026-06-01T00:00:00+00:00', expiresAt: '2046-01-01T00:00:00+00:00' });
450
+ const a = planSubscriptions([want('acme')], [d1, d2], NOW, OPTS);
451
+ ok(find(a, 'delete')?.id === 'a', 'with equal status the NEWER duplicate is kept');
452
+ }
453
+ {
454
+ // Domain comparison must be case-insensitive; NS domains are.
455
+ const mixed = sub({ id: '1', domain: 'ACME', model: 'subscriber', postUrl: `${PREFIX}tok/acme`, expiresAt: '2046-01-01T00:00:00+00:00' });
456
+ ok(kinds(planSubscriptions([want('acme')], [mixed], NOW, OPTS)) === 'noop', 'domain matching is case-insensitive');
457
+ }
458
+ {
459
+ // A bare domain (no dot) is real in the live fleet.
460
+ const bare: DesiredSubscription = { domain: 'baredomain', model: 'subscriber', postUrl: `${PREFIX}tok/baredomain` };
461
+ const s = sub({ id: '1', domain: 'baredomain', model: 'subscriber', postUrl: `${PREFIX}tok/baredomain`, expiresAt: '2046-01-01T00:00:00+00:00' });
462
+ ok(kinds(planSubscriptions([bare], [s], NOW, OPTS)) === 'noop', 'a bare domain with no territory suffix works');
463
+ }
464
+ {
465
+ // Same domain, different model → independent subscriptions, not duplicates.
466
+ const other = sub({ id: 'm', domain: 'acme', model: 'message', postUrl: `${PREFIX}tok/acme`, expiresAt: '2046-01-01T00:00:00+00:00' });
467
+ const a = planSubscriptions([want('acme')], [other], NOW, OPTS);
468
+ ok(find(a, 'create') !== undefined, 'a different model on the same domain does not satisfy the desired one');
469
+ ok(find(a, 'delete')?.id === 'm', 'and our unconfigured model is cleaned up');
470
+ }
471
+ {
472
+ ok(planSubscriptions([], [], NOW, OPTS).length === 0, 'nothing wanted, nothing present ⇒ no actions');
473
+ }
474
+ {
475
+ const a = planSubscriptions([want('acme'), want('beta')], [], NOW, OPTS);
476
+ ok(a.filter((x) => x.kind === 'create').length === 2, 'multiple desired domains each get a create');
477
+ }
478
+ {
479
+ // A subscription of ours with no domain recorded must not be mistaken for a wanted one.
480
+ const weird = sub({ id: '1', postUrl: `${PREFIX}tok/x`, expiresAt: '2046-01-01T00:00:00+00:00' });
481
+ const a = planSubscriptions([want('acme')], [weird], NOW, OPTS);
482
+ ok(find(a, 'create') !== undefined && find(a, 'delete')?.id === '1', 'a domain-less record of ours is not matched, and is cleaned up');
483
+ }
484
+
485
+ console.log(`\n${pass} passed, ${fail} failed`);
486
+ if (fail > 0) process.exitCode = 1;