@dszp/netsapiens-lib 0.1.3 → 0.1.5

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 CHANGED
@@ -6,8 +6,10 @@ never `node:*`.
6
6
 
7
7
  Five capabilities, one dependency-free package:
8
8
 
9
- - **Read-only NS API v2 client** — `NsClient` (bearer auth, injectable `fetch`) +
10
- `fetchDomainSnapshot(client, domain)` which assembles a routing-relevant domain snapshot.
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-only by charter
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 belong in a separate, explicitly-reviewed client. If a write surface is
70
- added here later it will be a distinct class, never new methods on `NsClient`.
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,64 @@
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
+ * Credentials are delivered by LOGIN, not email, so the email precondition does not apply. Set this on
43
+ * an SSO/JIT path, where the account is created from the user's own directory credentials on first
44
+ * sign-in and nothing is mailed. It waives ONLY the email precondition — never HARD, never SOFT.
45
+ *
46
+ * The caller decides WHEN to set it; the engine only guarantees the outcome is the same everywhere it
47
+ * is set. A waived result stays distinguishable via `emailWaived`, so a caller can still branch on
48
+ * "eligible, but there is no address to mail anything to".
49
+ */
50
+ emailNotRequired?: boolean;
51
+ }
52
+ export type EligTier = 'ok' | 'hard' | 'soft' | 'precondition';
53
+ export interface EligResult {
54
+ activatable: boolean;
55
+ tier: EligTier;
56
+ reasons: string[];
57
+ /**
58
+ * The user has no email address and `emailNotRequired` waived the precondition. `tier` is `'ok'` —
59
+ * they are eligible — but there is no address, so a caller must not try to mail them credentials.
60
+ * Absent whenever an address is present or the precondition was not reached.
61
+ */
62
+ emailWaived?: true;
63
+ }
64
+ export declare function evaluateEligibility(user: EligUser, ctx: EligContext, config: EligibilityConfig): EligResult;
@@ -0,0 +1,58 @@
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
+ if (!ctx.emailNotRequired) {
46
+ return { activatable: false, tier: 'precondition', reasons: ['an email address is required to activate'] };
47
+ }
48
+ // Waived: credentials arrive by login, not mail. Eligible — but say the address is missing, so a
49
+ // caller that WOULD have mailed something can still tell.
50
+ return {
51
+ activatable: true,
52
+ tier: 'ok',
53
+ reasons: ['no email address (precondition waived: credentials are not emailed)'],
54
+ emailWaived: true,
55
+ };
56
+ }
57
+ return { activatable: true, tier: 'ok', reasons: [] };
58
+ }
package/dist/index.d.ts CHANGED
@@ -17,7 +17,9 @@ export { renderGalleryHtml, renderFlowCards, renderFlowCard, mermaidBootstrap, f
17
17
  export { resolveSvgSize, rasterizerScript } from './raster.js';
18
18
  export { NsClient, NsApiError, assertBareServer, fetchDomainSnapshot, listDomains, asArray, type NsClientConfig, type FetchSnapshotOptions } from './nsClient.js';
19
19
  export { NsWriteClient, type NsWriteClientConfig } from './nsWriteClient.js';
20
+ export { NsAuthClient, NsAuthError, type NsAuthClientConfig, type NsTokenResponse } from './nsAuthClient.js';
20
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';
21
22
  export { type CallSensitivity, needsFreshAuth, SENSITIVITY_NOTE } from './sensitivity.js';
22
23
  export { toPrincipal, parseOperator, isResellerScope, isAdminScope, type Principal, type Operator, type Scope, } from './principal.js';
23
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
@@ -16,7 +16,9 @@ export { renderGalleryHtml, renderFlowCards, renderFlowCard, mermaidBootstrap, f
16
16
  export { resolveSvgSize, rasterizerScript } from './raster.js';
17
17
  export { NsClient, NsApiError, assertBareServer, fetchDomainSnapshot, listDomains, asArray } from './nsClient.js';
18
18
  export { NsWriteClient } from './nsWriteClient.js';
19
+ export { NsAuthClient, NsAuthError } from './nsAuthClient.js';
19
20
  export { verify, validateJwtFormat, extractContext, assertClaims, verifyHs256Signature, normalizeToken, tokenKey, MemoryVerdictCache, } from './jwt.js';
20
21
  export { needsFreshAuth, SENSITIVITY_NOTE } from './sensitivity.js';
21
22
  export { toPrincipal, parseOperator, isResellerScope, isAdminScope, } from './principal.js';
22
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dszp/netsapiens-lib",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
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,13 +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 && pnpm run test:nswrite",
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
56
  "test:nswrite": "tsx src/nsWriteClient.selftest.ts",
57
+ "test:nsauth": "tsx src/nsAuthClient.selftest.ts",
57
58
  "test:principal": "tsx src/principal.selftest.ts",
58
59
  "test:resolver": "tsx src/resolver.selftest.ts",
59
- "test:raster": "tsx src/raster.selftest.ts"
60
+ "test:raster": "tsx src/raster.selftest.ts",
61
+ "test:eligibility": "tsx src/eligibility.selftest.ts"
60
62
  },
61
63
  "devDependencies": {
62
64
  "tsx": "^4.22.4",