@dszp/netsapiens-lib 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -5
- package/dist/eligibility.d.ts +48 -0
- package/dist/eligibility.js +48 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/nsAuthClient.d.ts +51 -0
- package/dist/nsAuthClient.js +84 -0
- package/dist/nsClient.d.ts +5 -1
- package/dist/nsClient.js +5 -1
- package/dist/nsWriteClient.d.ts +45 -0
- package/dist/nsWriteClient.js +81 -0
- package/package.json +6 -3
package/README.md
CHANGED
|
@@ -6,8 +6,10 @@ never `node:*`.
|
|
|
6
6
|
|
|
7
7
|
Five capabilities, one dependency-free package:
|
|
8
8
|
|
|
9
|
-
- **
|
|
10
|
-
|
|
9
|
+
- **NS API v2 client (read + write)** — `NsClient` (read-only: `get()` + `fetchDomainSnapshot(client,
|
|
10
|
+
domain)` which assembles a routing-relevant domain snapshot) plus `NsWriteClient`, a **separate** write
|
|
11
|
+
client (device provisioning). Both are bearer-auth with an injectable `fetch`; holding the read client
|
|
12
|
+
still cannot write.
|
|
11
13
|
- **JWT (`ns_t`) validation** — `verify()` (cheap local format gate → cached live `/jwt` check) and
|
|
12
14
|
`validateJwtFormat()`. Pluggable `VerdictCache` (inject the Workers Cache API / KV / DO;
|
|
13
15
|
`MemoryVerdictCache` for dev). Anti-overload by design — a bad/expired token never hits the server.
|
|
@@ -61,13 +63,14 @@ Two composites are provided because they're multi-read and worth getting right o
|
|
|
61
63
|
|
|
62
64
|
The snapshot is the routing subset — what `resolveFlow()` needs. It is not a full domain export.
|
|
63
65
|
|
|
64
|
-
### Read
|
|
66
|
+
### Read/write split by charter
|
|
65
67
|
|
|
66
68
|
`NsClient` exposes **`get()` and nothing else**, and `verify()` only ever issues `GET /jwt`. That is a
|
|
67
69
|
deliberate boundary, not a missing feature: this library is built for tools that visualize and audit a
|
|
68
70
|
NetSapiens domain, where "it cannot possibly write" is a property worth having structurally rather
|
|
69
|
-
than by convention. Writes
|
|
70
|
-
|
|
71
|
+
than by convention. Writes live in a **separate** class — `NsWriteClient`, a small, explicitly-reviewed
|
|
72
|
+
surface (device provisioning) — never as new methods on `NsClient`. So a consumer that holds the read
|
|
73
|
+
client still cannot write; that guarantee holds by construction, not by convention.
|
|
71
74
|
|
|
72
75
|
### Configuration binds to *your* deployment
|
|
73
76
|
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* evaluateEligibility — is a NetSapiens user a real end-user candidate for an app integration (Ringotel,
|
|
3
|
+
* or any other)? Pure and deployment-neutral. The name is generic on purpose: the config/rules define the
|
|
4
|
+
* purpose. Consumers supply an EligibilityConfig; env parsing lives in each consumer, never here.
|
|
5
|
+
*
|
|
6
|
+
* HARD — system/service users (srv_code) + structurally-invalid extensions. Never eligible, not even a
|
|
7
|
+
* reseller override.
|
|
8
|
+
* SOFT — name matchers, extension lists, no-device heuristic. Default-excluded, reseller-overridable
|
|
9
|
+
* per configured category (or via an explicit per-request `force`).
|
|
10
|
+
* email — a precondition: activation typically emails credentials, so it can't proceed without an address.
|
|
11
|
+
* Precedence: HARD → SOFT (names, exts) → precondition → ok.
|
|
12
|
+
*/
|
|
13
|
+
export type SoftCategory = 'names' | 'exts' | 'no_devices';
|
|
14
|
+
export interface EligibilityConfig {
|
|
15
|
+
/** Lowercased name-contains matchers (checked against first/last/display). Caller lowercases. */
|
|
16
|
+
excludeNames: string[];
|
|
17
|
+
/** Global extension exclusions (exact, or trailing-`*` prefix). */
|
|
18
|
+
excludeExts: string[];
|
|
19
|
+
/** Per-domain override of the extension list (add/remove relative to global). */
|
|
20
|
+
excludeExtsByDomain: Record<string, {
|
|
21
|
+
add?: string[];
|
|
22
|
+
remove?: string[];
|
|
23
|
+
}>;
|
|
24
|
+
/** No-device heuristic: TIGHTENS a name match (never decides alone). */
|
|
25
|
+
excludeNoDevices: boolean;
|
|
26
|
+
/** Soft categories a reseller may override. */
|
|
27
|
+
resellerOverride: Set<SoftCategory>;
|
|
28
|
+
}
|
|
29
|
+
export interface EligUser {
|
|
30
|
+
ext: string;
|
|
31
|
+
srvCode?: string;
|
|
32
|
+
email?: string;
|
|
33
|
+
names?: string[];
|
|
34
|
+
deviceCount?: number;
|
|
35
|
+
}
|
|
36
|
+
export interface EligContext {
|
|
37
|
+
domain: string;
|
|
38
|
+
isReseller: boolean;
|
|
39
|
+
/** Reseller RUNTIME force: bypass ALL soft categories — never HARD, never the email precondition. */
|
|
40
|
+
force?: boolean;
|
|
41
|
+
}
|
|
42
|
+
export type EligTier = 'ok' | 'hard' | 'soft' | 'precondition';
|
|
43
|
+
export interface EligResult {
|
|
44
|
+
activatable: boolean;
|
|
45
|
+
tier: EligTier;
|
|
46
|
+
reasons: string[];
|
|
47
|
+
}
|
|
48
|
+
export declare function evaluateEligibility(user: EligUser, ctx: EligContext, config: EligibilityConfig): EligResult;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* evaluateEligibility — is a NetSapiens user a real end-user candidate for an app integration (Ringotel,
|
|
3
|
+
* or any other)? Pure and deployment-neutral. The name is generic on purpose: the config/rules define the
|
|
4
|
+
* purpose. Consumers supply an EligibilityConfig; env parsing lives in each consumer, never here.
|
|
5
|
+
*
|
|
6
|
+
* HARD — system/service users (srv_code) + structurally-invalid extensions. Never eligible, not even a
|
|
7
|
+
* reseller override.
|
|
8
|
+
* SOFT — name matchers, extension lists, no-device heuristic. Default-excluded, reseller-overridable
|
|
9
|
+
* per configured category (or via an explicit per-request `force`).
|
|
10
|
+
* email — a precondition: activation typically emails credentials, so it can't proceed without an address.
|
|
11
|
+
* Precedence: HARD → SOFT (names, exts) → precondition → ok.
|
|
12
|
+
*/
|
|
13
|
+
const blank = (s) => !s || s.trim() === '';
|
|
14
|
+
function excludedExtsFor(config, domain) {
|
|
15
|
+
const dom = config.excludeExtsByDomain[domain] ?? {};
|
|
16
|
+
const set = new Set(config.excludeExts);
|
|
17
|
+
for (const a of dom.add ?? [])
|
|
18
|
+
set.add(a);
|
|
19
|
+
for (const r of dom.remove ?? [])
|
|
20
|
+
set.delete(r);
|
|
21
|
+
return [...set];
|
|
22
|
+
}
|
|
23
|
+
function extMatch(ext, patterns) {
|
|
24
|
+
return patterns.find((p) => (p.endsWith('*') ? ext.startsWith(p.slice(0, -1)) : ext === p));
|
|
25
|
+
}
|
|
26
|
+
export function evaluateEligibility(user, ctx, config) {
|
|
27
|
+
if (!blank(user.srvCode)) {
|
|
28
|
+
return { activatable: false, tier: 'hard', reasons: [`system/service user (srv_code="${user.srvCode.trim()}")`] };
|
|
29
|
+
}
|
|
30
|
+
if (!/^\d{3,4}$/.test(user.ext)) {
|
|
31
|
+
return { activatable: false, tier: 'hard', reasons: [`extension "${user.ext}" is not a 3-4 digit user extension`] };
|
|
32
|
+
}
|
|
33
|
+
const canOverride = (cat) => ctx.isReseller && (config.resellerOverride.has(cat) || !!ctx.force);
|
|
34
|
+
const names = (user.names ?? []).map((n) => (n || '').toLowerCase());
|
|
35
|
+
const nameMatch = config.excludeNames.find((m) => names.some((n) => n.includes(m)));
|
|
36
|
+
const nameHit = nameMatch && (!config.excludeNoDevices || (user.deviceCount ?? 0) === 0);
|
|
37
|
+
if (nameHit && !canOverride('names')) {
|
|
38
|
+
return { activatable: false, tier: 'soft', reasons: [`name matches excluded pattern "${nameMatch}"`] };
|
|
39
|
+
}
|
|
40
|
+
const extHit = extMatch(user.ext, excludedExtsFor(config, ctx.domain));
|
|
41
|
+
if (extHit && !canOverride('exts')) {
|
|
42
|
+
return { activatable: false, tier: 'soft', reasons: [`extension "${user.ext}" matches excluded pattern "${extHit}"`] };
|
|
43
|
+
}
|
|
44
|
+
if (blank(user.email)) {
|
|
45
|
+
return { activatable: false, tier: 'precondition', reasons: ['an email address is required to activate'] };
|
|
46
|
+
}
|
|
47
|
+
return { activatable: true, tier: 'ok', reasons: [] };
|
|
48
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -16,7 +16,10 @@ export { THEMES, DEFAULT_LIGHT_THEME, DEFAULT_DARK_THEME, NODE_LIGHT, NODE_DARK,
|
|
|
16
16
|
export { renderGalleryHtml, renderFlowCards, renderFlowCard, mermaidBootstrap, flowAnchorId, type GalleryOptions, type CardOptions, } from './html.js';
|
|
17
17
|
export { resolveSvgSize, rasterizerScript } from './raster.js';
|
|
18
18
|
export { NsClient, NsApiError, assertBareServer, fetchDomainSnapshot, listDomains, asArray, type NsClientConfig, type FetchSnapshotOptions } from './nsClient.js';
|
|
19
|
+
export { NsWriteClient, type NsWriteClientConfig } from './nsWriteClient.js';
|
|
20
|
+
export { NsAuthClient, NsAuthError, type NsAuthClientConfig, type NsTokenResponse } from './nsAuthClient.js';
|
|
19
21
|
export { verify, validateJwtFormat, extractContext, assertClaims, verifyHs256Signature, normalizeToken, tokenKey, MemoryVerdictCache, type JwtVerdict, type JwtContext, type ClaimExpectations, type VerdictCache, type VerifyOptions, type FormatResult, } from './jwt.js';
|
|
20
22
|
export { type CallSensitivity, needsFreshAuth, SENSITIVITY_NOTE } from './sensitivity.js';
|
|
21
23
|
export { toPrincipal, parseOperator, isResellerScope, isAdminScope, type Principal, type Operator, type Scope, } from './principal.js';
|
|
22
24
|
export { ruleMatches, isAllowed, can, type PolicyRule, type Policy, type FeaturePolicies, } from './policy.js';
|
|
25
|
+
export { evaluateEligibility, type SoftCategory, type EligibilityConfig, type EligUser, type EligContext, type EligTier, type EligResult, } from './eligibility.js';
|
package/dist/index.js
CHANGED
|
@@ -15,7 +15,10 @@ export { THEMES, DEFAULT_LIGHT_THEME, DEFAULT_DARK_THEME, NODE_LIGHT, NODE_DARK,
|
|
|
15
15
|
export { renderGalleryHtml, renderFlowCards, renderFlowCard, mermaidBootstrap, flowAnchorId, } from './html.js';
|
|
16
16
|
export { resolveSvgSize, rasterizerScript } from './raster.js';
|
|
17
17
|
export { NsClient, NsApiError, assertBareServer, fetchDomainSnapshot, listDomains, asArray } from './nsClient.js';
|
|
18
|
+
export { NsWriteClient } from './nsWriteClient.js';
|
|
19
|
+
export { NsAuthClient, NsAuthError } from './nsAuthClient.js';
|
|
18
20
|
export { verify, validateJwtFormat, extractContext, assertClaims, verifyHs256Signature, normalizeToken, tokenKey, MemoryVerdictCache, } from './jwt.js';
|
|
19
21
|
export { needsFreshAuth, SENSITIVITY_NOTE } from './sensitivity.js';
|
|
20
22
|
export { toPrincipal, parseOperator, isResellerScope, isAdminScope, } from './principal.js';
|
|
21
23
|
export { ruleMatches, isAllowed, can, } from './policy.js';
|
|
24
|
+
export { evaluateEligibility, } from './eligibility.js';
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NsAuthClient — the NetSapiens OAuth2 password-grant surface. Two jobs off one call:
|
|
3
|
+
* - verifyCredentials(user, pass): confirm an END USER's credentials (the SSO webhook's auth check).
|
|
4
|
+
* - passwordGrant(adminUser, adminPass): mint a reseller/admin access token to use as a write bearer,
|
|
5
|
+
* an alternative to a static API key.
|
|
6
|
+
* Both use the deployment's "master key" (an OAuth application's client_id/client_secret). Node-free
|
|
7
|
+
* (fetch/URLSearchParams). The token endpoint is form-encoded and returns JSON.
|
|
8
|
+
*
|
|
9
|
+
* Fail-closed contract: passwordGrant throws NsAuthError on ANY non-2xx. verifyCredentials treats a 4xx as
|
|
10
|
+
* "bad credentials" ({ ok:false }) but RETHROWS a 5xx / network error, so a caller cannot mistake an
|
|
11
|
+
* upstream outage for a failed login.
|
|
12
|
+
*/
|
|
13
|
+
export declare class NsAuthError extends Error {
|
|
14
|
+
readonly status: number;
|
|
15
|
+
constructor(message: string, status: number);
|
|
16
|
+
}
|
|
17
|
+
export interface NsTokenResponse {
|
|
18
|
+
access_token?: string;
|
|
19
|
+
/** The authenticated user's extension (NetSapiens returns this on the token body). */
|
|
20
|
+
user?: string;
|
|
21
|
+
domain?: string;
|
|
22
|
+
scope?: string;
|
|
23
|
+
[k: string]: unknown;
|
|
24
|
+
}
|
|
25
|
+
export interface NsAuthClientConfig {
|
|
26
|
+
/** API host, e.g. "api.example.com" (bare — no scheme/path). Token endpoint = https://{server}/ns-api/oauth2/token/ */
|
|
27
|
+
server: string;
|
|
28
|
+
/** OAuth application client id (the "master key" id). */
|
|
29
|
+
clientId: string;
|
|
30
|
+
/** OAuth application client secret. */
|
|
31
|
+
clientSecret: string;
|
|
32
|
+
/** Injectable for tests / non-global fetch. */
|
|
33
|
+
fetchImpl?: typeof fetch;
|
|
34
|
+
}
|
|
35
|
+
export declare class NsAuthClient {
|
|
36
|
+
#private;
|
|
37
|
+
constructor(cfg: NsAuthClientConfig);
|
|
38
|
+
passwordGrant(username: string, password: string): Promise<NsTokenResponse>;
|
|
39
|
+
/**
|
|
40
|
+
* Confirm an end user's credentials via OAuth2 password-grant.
|
|
41
|
+
*
|
|
42
|
+
* Contract: `ok` is true IF AND ONLY IF the token response carried a non-empty `access_token`.
|
|
43
|
+
* NetSapiens can return HTTP 200 with an empty/in-band-error body (no `access_token`) — that is
|
|
44
|
+
* NOT a successful login, so a bare 2xx is not sufficient. A 4xx maps to `{ ok: false }`; a 5xx /
|
|
45
|
+
* network error rethrows so a caller cannot mistake an upstream outage for a failed login.
|
|
46
|
+
*/
|
|
47
|
+
verifyCredentials(username: string, password: string): Promise<{
|
|
48
|
+
ok: boolean;
|
|
49
|
+
token?: NsTokenResponse;
|
|
50
|
+
}>;
|
|
51
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NsAuthClient — the NetSapiens OAuth2 password-grant surface. Two jobs off one call:
|
|
3
|
+
* - verifyCredentials(user, pass): confirm an END USER's credentials (the SSO webhook's auth check).
|
|
4
|
+
* - passwordGrant(adminUser, adminPass): mint a reseller/admin access token to use as a write bearer,
|
|
5
|
+
* an alternative to a static API key.
|
|
6
|
+
* Both use the deployment's "master key" (an OAuth application's client_id/client_secret). Node-free
|
|
7
|
+
* (fetch/URLSearchParams). The token endpoint is form-encoded and returns JSON.
|
|
8
|
+
*
|
|
9
|
+
* Fail-closed contract: passwordGrant throws NsAuthError on ANY non-2xx. verifyCredentials treats a 4xx as
|
|
10
|
+
* "bad credentials" ({ ok:false }) but RETHROWS a 5xx / network error, so a caller cannot mistake an
|
|
11
|
+
* upstream outage for a failed login.
|
|
12
|
+
*/
|
|
13
|
+
import { assertBareServer } from './nsClient.js';
|
|
14
|
+
export class NsAuthError extends Error {
|
|
15
|
+
status;
|
|
16
|
+
constructor(message, status) {
|
|
17
|
+
super(message);
|
|
18
|
+
this.status = status;
|
|
19
|
+
this.name = 'NsAuthError';
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export class NsAuthClient {
|
|
23
|
+
#url;
|
|
24
|
+
#clientId;
|
|
25
|
+
#clientSecret;
|
|
26
|
+
#fetchImpl;
|
|
27
|
+
constructor(cfg) {
|
|
28
|
+
this.#url = `https://${assertBareServer(cfg.server)}/ns-api/oauth2/token/`;
|
|
29
|
+
this.#clientId = cfg.clientId;
|
|
30
|
+
this.#clientSecret = cfg.clientSecret;
|
|
31
|
+
this.#fetchImpl = cfg.fetchImpl ?? fetch;
|
|
32
|
+
}
|
|
33
|
+
async passwordGrant(username, password) {
|
|
34
|
+
const body = new URLSearchParams({
|
|
35
|
+
grant_type: 'password',
|
|
36
|
+
client_id: this.#clientId,
|
|
37
|
+
client_secret: this.#clientSecret,
|
|
38
|
+
username,
|
|
39
|
+
password,
|
|
40
|
+
format: 'json',
|
|
41
|
+
});
|
|
42
|
+
// Call via a local, NOT this.#fetchImpl(...): the global fetch requires a global `this` in workerd.
|
|
43
|
+
const doFetch = this.#fetchImpl;
|
|
44
|
+
const res = await doFetch(this.#url, {
|
|
45
|
+
method: 'POST',
|
|
46
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
|
|
47
|
+
body: body.toString(),
|
|
48
|
+
});
|
|
49
|
+
const text = await res.text();
|
|
50
|
+
let parsed = text;
|
|
51
|
+
if (text) {
|
|
52
|
+
try {
|
|
53
|
+
parsed = JSON.parse(text);
|
|
54
|
+
}
|
|
55
|
+
catch { /* non-JSON error body */ }
|
|
56
|
+
}
|
|
57
|
+
if (!res.ok) {
|
|
58
|
+
const detail = (typeof parsed === 'object' && parsed !== null ? JSON.stringify(parsed) : String(parsed)).slice(0, 300);
|
|
59
|
+
throw new NsAuthError(`NS oauth2/token → ${res.status}: ${detail}`, res.status);
|
|
60
|
+
}
|
|
61
|
+
return (parsed && typeof parsed === 'object' ? parsed : {});
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Confirm an end user's credentials via OAuth2 password-grant.
|
|
65
|
+
*
|
|
66
|
+
* Contract: `ok` is true IF AND ONLY IF the token response carried a non-empty `access_token`.
|
|
67
|
+
* NetSapiens can return HTTP 200 with an empty/in-band-error body (no `access_token`) — that is
|
|
68
|
+
* NOT a successful login, so a bare 2xx is not sufficient. A 4xx maps to `{ ok: false }`; a 5xx /
|
|
69
|
+
* network error rethrows so a caller cannot mistake an upstream outage for a failed login.
|
|
70
|
+
*/
|
|
71
|
+
async verifyCredentials(username, password) {
|
|
72
|
+
try {
|
|
73
|
+
const token = await this.passwordGrant(username, password);
|
|
74
|
+
if (!token.access_token)
|
|
75
|
+
return { ok: false };
|
|
76
|
+
return { ok: true, token };
|
|
77
|
+
}
|
|
78
|
+
catch (e) {
|
|
79
|
+
if (e instanceof NsAuthError && e.status >= 400 && e.status < 500)
|
|
80
|
+
return { ok: false };
|
|
81
|
+
throw e; // 5xx / network → fail closed upstream
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
package/dist/nsClient.d.ts
CHANGED
|
@@ -14,7 +14,11 @@ export declare class NsApiError extends Error {
|
|
|
14
14
|
readonly status: number;
|
|
15
15
|
readonly path: string;
|
|
16
16
|
readonly body: unknown;
|
|
17
|
-
|
|
17
|
+
/** HTTP method — set by the write client; optional so read-client call sites stay unchanged. */
|
|
18
|
+
readonly method?: string | undefined;
|
|
19
|
+
constructor(message: string, status: number, path: string, body: unknown,
|
|
20
|
+
/** HTTP method — set by the write client; optional so read-client call sites stay unchanged. */
|
|
21
|
+
method?: string | undefined);
|
|
18
22
|
}
|
|
19
23
|
export interface NsClientConfig {
|
|
20
24
|
/** API host, e.g. "api.example.com". Base URL becomes https://{server}/ns-api/v2. */
|
package/dist/nsClient.js
CHANGED
|
@@ -13,11 +13,15 @@ export class NsApiError extends Error {
|
|
|
13
13
|
status;
|
|
14
14
|
path;
|
|
15
15
|
body;
|
|
16
|
-
|
|
16
|
+
method;
|
|
17
|
+
constructor(message, status, path, body,
|
|
18
|
+
/** HTTP method — set by the write client; optional so read-client call sites stay unchanged. */
|
|
19
|
+
method) {
|
|
17
20
|
super(message);
|
|
18
21
|
this.status = status;
|
|
19
22
|
this.path = path;
|
|
20
23
|
this.body = body;
|
|
24
|
+
this.method = method;
|
|
21
25
|
this.name = 'NsApiError';
|
|
22
26
|
}
|
|
23
27
|
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Portable NetSapiens API v2 WRITE client — the separate, explicitly-imported write surface the read-only
|
|
3
|
+
* `NsClient` deliberately does not have (a consumer holds `NsClient` precisely to know it cannot write).
|
|
4
|
+
* This realizes the lib's planned split: read and write are two classes; only this one mutates. Node-free
|
|
5
|
+
* (fetch/URL/crypto only), so it runs unchanged in a Cloudflare Worker.
|
|
6
|
+
*
|
|
7
|
+
* Starts with the device methods the portal's Ringotel activation needs (create/get/delete), over a
|
|
8
|
+
* generic post/put/delete core, and is meant to GROW into the full NS write surface (users, DIDs, …) —
|
|
9
|
+
* porting the endpoint/body shapes from the onboarding tool's resource defs as they're needed.
|
|
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`.
|
|
14
|
+
*/
|
|
15
|
+
import type { Rec } from './model.js';
|
|
16
|
+
export interface NsWriteClientConfig {
|
|
17
|
+
/** API host, e.g. "api.example.com". Base URL becomes https://{server}/ns-api/v2. */
|
|
18
|
+
server: string;
|
|
19
|
+
/** Bearer token (an API key with write scope). */
|
|
20
|
+
token: string;
|
|
21
|
+
/** Injectable for tests / non-global fetch. */
|
|
22
|
+
fetchImpl?: typeof fetch;
|
|
23
|
+
}
|
|
24
|
+
export declare class NsWriteClient {
|
|
25
|
+
#private;
|
|
26
|
+
constructor(cfg: NsWriteClientConfig);
|
|
27
|
+
get<T = unknown>(path: string, query?: Record<string, string | number>): Promise<T>;
|
|
28
|
+
/** POST with `synchronous:'yes'` injected → 200 + created resource inline. */
|
|
29
|
+
post<T = unknown>(path: string, body: Rec): Promise<T>;
|
|
30
|
+
/** PUT with `synchronous:'yes'` injected. */
|
|
31
|
+
put<T = unknown>(path: string, body: Rec): Promise<T>;
|
|
32
|
+
delete<T = unknown>(path: string): Promise<T>;
|
|
33
|
+
/** List a user's devices (normalized to an array). */
|
|
34
|
+
getDevices(domain: string, user: string): Promise<Rec[]>;
|
|
35
|
+
/** Read one device (e.g. to fetch its `device-sip-registration-password`). */
|
|
36
|
+
getDevice(domain: string, user: string, device: string): Promise<Rec>;
|
|
37
|
+
/**
|
|
38
|
+
* Create a device (softphone when named `<ext><suffix>`, e.g. `100r`). NS auto-generates the SIP
|
|
39
|
+
* password when unset; with `synchronous:'yes'` it comes back inline in the response. `extra` allows
|
|
40
|
+
* optional fields (e.g. an emergency caller-id).
|
|
41
|
+
*/
|
|
42
|
+
createDevice(domain: string, user: string, device: string, extra?: Rec): Promise<Rec>;
|
|
43
|
+
/** Delete a device. */
|
|
44
|
+
deleteDevice(domain: string, user: string, device: string): Promise<Rec>;
|
|
45
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { NsApiError, assertBareServer, asArray } from './nsClient.js';
|
|
2
|
+
const enc = encodeURIComponent;
|
|
3
|
+
export class NsWriteClient {
|
|
4
|
+
#baseUrl;
|
|
5
|
+
#token;
|
|
6
|
+
#fetchImpl;
|
|
7
|
+
constructor(cfg) {
|
|
8
|
+
this.#baseUrl = `https://${assertBareServer(cfg.server)}/ns-api/v2`;
|
|
9
|
+
this.#token = cfg.token;
|
|
10
|
+
this.#fetchImpl = cfg.fetchImpl ?? fetch;
|
|
11
|
+
}
|
|
12
|
+
// ── generic verbs (the growth surface) ──────────────────────────────────────
|
|
13
|
+
get(path, query) {
|
|
14
|
+
return this.#request('GET', path, undefined, query);
|
|
15
|
+
}
|
|
16
|
+
/** POST with `synchronous:'yes'` injected → 200 + created resource inline. */
|
|
17
|
+
post(path, body) {
|
|
18
|
+
return this.#request('POST', path, { synchronous: 'yes', ...body });
|
|
19
|
+
}
|
|
20
|
+
/** PUT with `synchronous:'yes'` injected. */
|
|
21
|
+
put(path, body) {
|
|
22
|
+
return this.#request('PUT', path, { synchronous: 'yes', ...body });
|
|
23
|
+
}
|
|
24
|
+
delete(path) {
|
|
25
|
+
return this.#request('DELETE', path);
|
|
26
|
+
}
|
|
27
|
+
// ── typed device helpers ────────────────────────────────────────────────────
|
|
28
|
+
/** List a user's devices (normalized to an array). */
|
|
29
|
+
getDevices(domain, user) {
|
|
30
|
+
return this.get(`/domains/${enc(domain)}/users/${enc(user)}/devices`).then(asArray);
|
|
31
|
+
}
|
|
32
|
+
/** Read one device (e.g. to fetch its `device-sip-registration-password`). */
|
|
33
|
+
getDevice(domain, user, device) {
|
|
34
|
+
return this.get(`/domains/${enc(domain)}/users/${enc(user)}/devices/${enc(device)}`);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Create a device (softphone when named `<ext><suffix>`, e.g. `100r`). NS auto-generates the SIP
|
|
38
|
+
* password when unset; with `synchronous:'yes'` it comes back inline in the response. `extra` allows
|
|
39
|
+
* optional fields (e.g. an emergency caller-id).
|
|
40
|
+
*/
|
|
41
|
+
createDevice(domain, user, device, extra = {}) {
|
|
42
|
+
return this.post(`/domains/${enc(domain)}/users/${enc(user)}/devices`, { device, ...extra });
|
|
43
|
+
}
|
|
44
|
+
/** Delete a device. */
|
|
45
|
+
deleteDevice(domain, user, device) {
|
|
46
|
+
return this.delete(`/domains/${enc(domain)}/users/${enc(user)}/devices/${enc(device)}`);
|
|
47
|
+
}
|
|
48
|
+
async #request(method, path, body, query) {
|
|
49
|
+
const url = new URL(this.#baseUrl + path);
|
|
50
|
+
for (const [k, v] of Object.entries(query ?? {}))
|
|
51
|
+
url.searchParams.set(k, String(v));
|
|
52
|
+
// Call via a local, NOT `this.#fetchImpl(...)`: invoking the global fetch as a method of this
|
|
53
|
+
// instance throws "Illegal invocation" in workerd (the global fetch requires a global `this`).
|
|
54
|
+
const doFetch = this.#fetchImpl;
|
|
55
|
+
const res = await doFetch(url.toString(), {
|
|
56
|
+
method,
|
|
57
|
+
headers: {
|
|
58
|
+
Authorization: `Bearer ${this.#token}`,
|
|
59
|
+
Accept: 'application/json',
|
|
60
|
+
...(body ? { 'Content-Type': 'application/json' } : {}),
|
|
61
|
+
},
|
|
62
|
+
...(body ? { body: JSON.stringify(body) } : {}),
|
|
63
|
+
});
|
|
64
|
+
const text = await res.text();
|
|
65
|
+
let parsed = text;
|
|
66
|
+
if (text) {
|
|
67
|
+
try {
|
|
68
|
+
parsed = JSON.parse(text);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
/* some endpoints return empty / plain bodies */
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (!res.ok) {
|
|
75
|
+
const detail = (typeof parsed === 'object' && parsed !== null ? JSON.stringify(parsed) : String(parsed)).slice(0, 500);
|
|
76
|
+
const hint = res.status === 401 ? ' (token expired/invalid or domain out of scope)' : res.status === 403 ? ' (token lacks permission)' : '';
|
|
77
|
+
throw new NsApiError(`${method} ${path} → ${res.status}${hint}: ${detail}`, res.status, path, parsed, method);
|
|
78
|
+
}
|
|
79
|
+
return parsed;
|
|
80
|
+
}
|
|
81
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dszp/netsapiens-lib",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "Portable, Node-free NetSapiens toolkit: read-only API client, JWT (ns_t) validation, and a snapshot -> FlowGraph -> Mermaid call-flow resolver/renderer. Runs unchanged in a Cloudflare Worker, Node, or the browser.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -50,12 +50,15 @@
|
|
|
50
50
|
"//prepublishOnly": "Publish-only build with sourcemaps OFF. The `files` globs exclude dist/**/*.map on purpose (they point at src/, which does not ship), but tsc still emits a //# sourceMappingURL pointer into every .js/.d.ts -- so consumers' devtools 404 chasing maps that were never published. Dropping the pointer at publish time is what the exclusion always meant. A normal `pnpm build` keeps maps for link: consumers.",
|
|
51
51
|
"prepublishOnly": "tsc -p tsconfig.json --sourceMap false --declarationMap false",
|
|
52
52
|
"//test": "The offline suite — green on a fresh clone with no credentials and no fixtures. test:ns is NOT included: it needs a domain snapshot that (correctly) isn't in the repo.",
|
|
53
|
-
"test": "pnpm run test:jwt && pnpm run test:principal && pnpm run test:resolver && pnpm run test:raster",
|
|
53
|
+
"test": "pnpm run test:jwt && pnpm run test:principal && pnpm run test:resolver && pnpm run test:raster && pnpm run test:nswrite && pnpm run test:eligibility && pnpm run test:nsauth",
|
|
54
54
|
"test:jwt": "tsx src/jwt.selftest.ts",
|
|
55
55
|
"test:ns": "tsx src/nsClient.selftest.ts",
|
|
56
|
+
"test:nswrite": "tsx src/nsWriteClient.selftest.ts",
|
|
57
|
+
"test:nsauth": "tsx src/nsAuthClient.selftest.ts",
|
|
56
58
|
"test:principal": "tsx src/principal.selftest.ts",
|
|
57
59
|
"test:resolver": "tsx src/resolver.selftest.ts",
|
|
58
|
-
"test:raster": "tsx src/raster.selftest.ts"
|
|
60
|
+
"test:raster": "tsx src/raster.selftest.ts",
|
|
61
|
+
"test:eligibility": "tsx src/eligibility.selftest.ts"
|
|
59
62
|
},
|
|
60
63
|
"devDependencies": {
|
|
61
64
|
"tsx": "^4.22.4",
|