@dszp/netsapiens-lib 0.1.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.
- package/LICENSE +21 -0
- package/README.md +103 -0
- package/dist/html.d.ts +51 -0
- package/dist/html.js +332 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +22 -0
- package/dist/jwt.d.ts +174 -0
- package/dist/jwt.js +290 -0
- package/dist/mermaid.d.ts +14 -0
- package/dist/mermaid.js +0 -0
- package/dist/model.d.ts +80 -0
- package/dist/model.js +10 -0
- package/dist/nsClient.d.ts +79 -0
- package/dist/nsClient.js +205 -0
- package/dist/policy.d.ts +49 -0
- package/dist/policy.js +39 -0
- package/dist/principal.d.ts +52 -0
- package/dist/principal.js +45 -0
- package/dist/raster.d.ts +21 -0
- package/dist/raster.js +81 -0
- package/dist/resolver.d.ts +58 -0
- package/dist/resolver.js +1092 -0
- package/dist/sensitivity.d.ts +32 -0
- package/dist/sensitivity.js +30 -0
- package/dist/themes.d.ts +71 -0
- package/dist/themes.js +104 -0
- package/package.json +60 -0
package/dist/jwt.d.ts
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NetSapiens `ns_t` JWT validation — runtime-portable (Cloudflare Worker + Node 18+).
|
|
3
|
+
*
|
|
4
|
+
* Ported from n8n-nodes-netsapiens (nodes/NetSapiens/NetSapiens.node.ts), which exposes two
|
|
5
|
+
* operations we mirror here:
|
|
6
|
+
* - Validate JWT Format → local base64url-decode + `exp` check, NO server contact, NO
|
|
7
|
+
* signature verification (low-security; matches the node exactly).
|
|
8
|
+
* - Validate JWT (live) → GET {server}/ns-api/v2/jwt with `Authorization: Bearer <token>`;
|
|
9
|
+
* HTTP 401/403 ⇒ rejected. Authoritative, but a server roundtrip.
|
|
10
|
+
*
|
|
11
|
+
* Design constraint (David): the live path must NOT overload the NS API. So `verify()` gates on
|
|
12
|
+
* the cheap local check first, then serves a *cached* live verdict keyed by a hash of the token,
|
|
13
|
+
* with a TTL capped by the token's own `exp`. A bad or expired token never reaches the server.
|
|
14
|
+
*
|
|
15
|
+
* Uses only Web-standard globals (atob, TextDecoder, crypto.subtle, fetch) — no Node Buffer — so
|
|
16
|
+
* the same file runs in a Worker and in the ns-onboard CLI.
|
|
17
|
+
*/
|
|
18
|
+
export interface JwtContext {
|
|
19
|
+
/** The token's domain (from claims). Scopes downstream NS reads. When masking, this is the
|
|
20
|
+
* MASKED user's domain — the token is NS-scoped to it, not the operator's. */
|
|
21
|
+
domain?: string;
|
|
22
|
+
/** The token's user / extension (the masked user, when masking). */
|
|
23
|
+
user?: string;
|
|
24
|
+
/** user_scope / scope claim if present (Reseller / Office Manager / Basic User / …). When masking
|
|
25
|
+
* this is the MASKED user's scope, NOT the operator's — so don't infer "a reseller drives this"
|
|
26
|
+
* from scope alone; consult `maskChain`. */
|
|
27
|
+
scope?: string;
|
|
28
|
+
/** sub claim (typically user@domain — the effective/masked identity). */
|
|
29
|
+
sub?: string;
|
|
30
|
+
/** `mask_chain` claim: absent/null ⇒ NOT masking; `"user@domain"` ⇒ the REAL operator behind the
|
|
31
|
+
* mask (e.g. the reseller impersonating a domain user). The masking flag + operator identity. */
|
|
32
|
+
maskChain?: string;
|
|
33
|
+
/** user_email claim. */
|
|
34
|
+
email?: string;
|
|
35
|
+
/** displayName claim. */
|
|
36
|
+
displayName?: string;
|
|
37
|
+
/** territory claim. */
|
|
38
|
+
territory?: string;
|
|
39
|
+
}
|
|
40
|
+
export interface JwtVerdict extends JwtContext {
|
|
41
|
+
/** Structurally a JWT (3 segments, decodable payload). */
|
|
42
|
+
validFormat: boolean;
|
|
43
|
+
/** `exp` claim exists and is in the future. */
|
|
44
|
+
unexpired: boolean;
|
|
45
|
+
/** Result of the live server check, when performed. */
|
|
46
|
+
live: 'valid' | 'invalid' | 'skipped' | 'error';
|
|
47
|
+
/** Local HS256 signature check: 'valid'/'invalid' when a signingSecret is configured, else
|
|
48
|
+
* 'unverified' (no local key — the live `/jwt` check is the signature authority). */
|
|
49
|
+
signature?: 'valid' | 'invalid' | 'unverified';
|
|
50
|
+
/** Overall gate: safe to proceed. */
|
|
51
|
+
ok: boolean;
|
|
52
|
+
expiresAt?: string;
|
|
53
|
+
expiresInSeconds?: number;
|
|
54
|
+
reason?: string;
|
|
55
|
+
statusCode?: number;
|
|
56
|
+
checkedAt: string;
|
|
57
|
+
fromCache?: boolean;
|
|
58
|
+
/** Full decoded payload (local decode). */
|
|
59
|
+
payload?: Record<string, unknown>;
|
|
60
|
+
}
|
|
61
|
+
/** Strip a leading "Bearer " and whitespace. */
|
|
62
|
+
export declare function normalizeToken(raw: string): string;
|
|
63
|
+
/**
|
|
64
|
+
* Assert the ns_t audience (and, by default, issuer). `aud` defaults to "ns" — that value is fixed by
|
|
65
|
+
* the NetSapiens platform, true for every deployment — and is ALWAYS checked.
|
|
66
|
+
*
|
|
67
|
+
* `iss` is your Manager Portal host, which is deployment-specific, so it has **no default**: you must
|
|
68
|
+
* either pass `iss` or explicitly opt out with `validateIss: false`. Omitting both fails closed rather
|
|
69
|
+
* than silently skipping the check. (Earlier versions defaulted to one specific portal host, which
|
|
70
|
+
* quietly bound every consumer to someone else's deployment — a bug, not a convenience.)
|
|
71
|
+
*
|
|
72
|
+
* `iss` accepts a LIST, mirroring `aud`: several portal hostnames can front the same backend (a
|
|
73
|
+
* white-labelled host and the vendor's unbranded one), and a token minted by either is equally valid.
|
|
74
|
+
* Matching is an **exact, case-sensitive** string compare against an explicit list — no wildcards, no
|
|
75
|
+
* suffix matching. `["manage.example.com", "manage.vendor.example"]` is allowed; `"*.vendor.example"`
|
|
76
|
+
* is not, and would be treated as a literal hostname that never matches.
|
|
77
|
+
*
|
|
78
|
+
* Pure claim comparison — no key needed, no network.
|
|
79
|
+
*/
|
|
80
|
+
export interface ClaimExpectations {
|
|
81
|
+
/** Required audience — default "ns". Token `aud` must equal (or, if array, include) one of these. */
|
|
82
|
+
aud?: string | string[];
|
|
83
|
+
/** Required issuer(s) — YOUR portal host(s), e.g. "manage.example.com" or
|
|
84
|
+
* ["manage.example.com", "manage.vendor.example"]. Token `iss` must EXACTLY equal one of them.
|
|
85
|
+
* No default: required unless `validateIss: false`. */
|
|
86
|
+
iss?: string | string[];
|
|
87
|
+
/** Set false to SKIP issuer validation (e.g. accepting tokens across portal domains). Default true,
|
|
88
|
+
* which makes `iss` mandatory. */
|
|
89
|
+
validateIss?: boolean;
|
|
90
|
+
}
|
|
91
|
+
export declare function assertClaims(payload: Record<string, unknown>, exp?: ClaimExpectations): {
|
|
92
|
+
ok: boolean;
|
|
93
|
+
reason?: string;
|
|
94
|
+
};
|
|
95
|
+
/**
|
|
96
|
+
* Verify an ns_t's HS256 signature locally with the shared secret. ns_t is HS256 (symmetric), signed
|
|
97
|
+
* by the NetSapiens core — so this needs that HMAC secret (there is no public JWKS). Returns false for
|
|
98
|
+
* a wrong/absent secret, tampered token, or any header alg other than HS256 (blocks alg:none / alg
|
|
99
|
+
* confusion). Async (crypto.subtle HMAC). When you don't hold the secret, leave it unset and rely on
|
|
100
|
+
* the live `/jwt` roundtrip as the (server-side) signature authority.
|
|
101
|
+
*/
|
|
102
|
+
export declare function verifyHs256Signature(token: string, secret: string): Promise<boolean>;
|
|
103
|
+
/** Pull the routing-relevant context out of NS claims (tolerant of naming variants). */
|
|
104
|
+
export declare function extractContext(payload: Record<string, unknown>): JwtContext;
|
|
105
|
+
export interface FormatResult {
|
|
106
|
+
validFormat: boolean;
|
|
107
|
+
unexpired: boolean;
|
|
108
|
+
expiresAt?: string;
|
|
109
|
+
expiresInSeconds?: number;
|
|
110
|
+
reason?: string;
|
|
111
|
+
payload?: Record<string, unknown>;
|
|
112
|
+
context: JwtContext;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Local format + expiry check. No network, no signature verification.
|
|
116
|
+
* `nowMs` is injectable for testing.
|
|
117
|
+
*/
|
|
118
|
+
export declare function validateJwtFormat(token: string, nowMs?: number): FormatResult;
|
|
119
|
+
/** Pluggable verdict cache (back with Workers Cache API / KV / DO / memory). */
|
|
120
|
+
export interface VerdictCache {
|
|
121
|
+
get(key: string): Promise<JwtVerdict | undefined>;
|
|
122
|
+
/** ttlSeconds is a hint; the store may evict earlier. */
|
|
123
|
+
set(key: string, verdict: JwtVerdict, ttlSeconds: number): Promise<void>;
|
|
124
|
+
}
|
|
125
|
+
/** Simple in-isolate cache for dev / a single Worker isolate (not shared across isolates). */
|
|
126
|
+
export declare class MemoryVerdictCache implements VerdictCache {
|
|
127
|
+
private store;
|
|
128
|
+
get(key: string): Promise<JwtVerdict | undefined>;
|
|
129
|
+
set(key: string, verdict: JwtVerdict, ttlSeconds: number): Promise<void>;
|
|
130
|
+
}
|
|
131
|
+
/** SHA-256 hex of the token — cache key that never stores the raw token. */
|
|
132
|
+
export declare function tokenKey(token: string): Promise<string>;
|
|
133
|
+
export interface VerifyOptions {
|
|
134
|
+
/** NS API host, e.g. "api.example.com". */
|
|
135
|
+
server: string;
|
|
136
|
+
/** 'format' = local only (no roundtrip). 'live' = local gate + cached server check. */
|
|
137
|
+
mode?: 'format' | 'live';
|
|
138
|
+
cache?: VerdictCache;
|
|
139
|
+
/** Max seconds to trust a cached live verdict (also capped by the token's exp). Default 60. */
|
|
140
|
+
maxLiveTtlSeconds?: number;
|
|
141
|
+
/** How long to cache a negative live verdict. Default 30. */
|
|
142
|
+
negativeTtlSeconds?: number;
|
|
143
|
+
/** Abort the live `/jwt` fetch after this many ms (→ live:'error', fail closed). Default 4000. */
|
|
144
|
+
timeoutMs?: number;
|
|
145
|
+
/**
|
|
146
|
+
* Bypass the cache READ and always do the live server check — for writes / sensitive reads. The
|
|
147
|
+
* cache can serve a stale "valid" verdict for a token that has since been logged out / revoked
|
|
148
|
+
* (we get no logout event to evict it); force-fresh closes that window. The fresh verdict is still
|
|
149
|
+
* written back, so it OVERWRITES a stale entry (a now-invalid token's cached "valid" becomes
|
|
150
|
+
* "invalid"). Use it on the operations where ≤`maxLiveTtlSeconds` of staleness is unacceptable.
|
|
151
|
+
*/
|
|
152
|
+
forceFresh?: boolean;
|
|
153
|
+
/** Audience to require — default "ns". Always enforced locally (cheap, before any roundtrip). */
|
|
154
|
+
expectedAud?: string | string[];
|
|
155
|
+
/** Issuer(s) to require — YOUR portal host(s), e.g. "manage.example.com", or a list when one backend
|
|
156
|
+
* is fronted by several portal hostnames. Exact match, no wildcards. No default: required unless
|
|
157
|
+
* `validateIss: false`. Omitting both fails closed. */
|
|
158
|
+
expectedIss?: string | string[];
|
|
159
|
+
/** Set false to skip issuer validation (e.g. work across portal domains). Default true, which makes
|
|
160
|
+
* `expectedIss` mandatory. */
|
|
161
|
+
validateIss?: boolean;
|
|
162
|
+
/** ns_t HS256 shared secret. When set, the signature is verified LOCALLY FIRST (forged/tampered
|
|
163
|
+
* tokens are rejected with no roundtrip). When unset, `signature` is 'unverified' and the live
|
|
164
|
+
* `/jwt` check is the signature authority. */
|
|
165
|
+
signingSecret?: string;
|
|
166
|
+
fetchImpl?: typeof fetch;
|
|
167
|
+
nowMs?: number;
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Full gate. Order: cheap local format+exp → (live mode) cached live verdict → live GET /jwt.
|
|
171
|
+
* A malformed/expired token returns immediately and never touches the server.
|
|
172
|
+
*/
|
|
173
|
+
export declare function verify(token: string, opts: VerifyOptions): Promise<JwtVerdict>;
|
|
174
|
+
//# sourceMappingURL=jwt.d.ts.map
|
package/dist/jwt.js
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NetSapiens `ns_t` JWT validation — runtime-portable (Cloudflare Worker + Node 18+).
|
|
3
|
+
*
|
|
4
|
+
* Ported from n8n-nodes-netsapiens (nodes/NetSapiens/NetSapiens.node.ts), which exposes two
|
|
5
|
+
* operations we mirror here:
|
|
6
|
+
* - Validate JWT Format → local base64url-decode + `exp` check, NO server contact, NO
|
|
7
|
+
* signature verification (low-security; matches the node exactly).
|
|
8
|
+
* - Validate JWT (live) → GET {server}/ns-api/v2/jwt with `Authorization: Bearer <token>`;
|
|
9
|
+
* HTTP 401/403 ⇒ rejected. Authoritative, but a server roundtrip.
|
|
10
|
+
*
|
|
11
|
+
* Design constraint (David): the live path must NOT overload the NS API. So `verify()` gates on
|
|
12
|
+
* the cheap local check first, then serves a *cached* live verdict keyed by a hash of the token,
|
|
13
|
+
* with a TTL capped by the token's own `exp`. A bad or expired token never reaches the server.
|
|
14
|
+
*
|
|
15
|
+
* Uses only Web-standard globals (atob, TextDecoder, crypto.subtle, fetch) — no Node Buffer — so
|
|
16
|
+
* the same file runs in a Worker and in the ns-onboard CLI.
|
|
17
|
+
*/
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Local decode (base64url) — Buffer-free
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
/** Decode a base64url string to UTF-8 without Node Buffer. */
|
|
22
|
+
function base64urlToUtf8(b64url) {
|
|
23
|
+
const b64 = b64url.replace(/-/g, '+').replace(/_/g, '/');
|
|
24
|
+
const padded = b64 + '='.repeat((4 - (b64.length % 4)) % 4);
|
|
25
|
+
const binary = atob(padded);
|
|
26
|
+
const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));
|
|
27
|
+
return new TextDecoder().decode(bytes);
|
|
28
|
+
}
|
|
29
|
+
/** Strip a leading "Bearer " and whitespace. */
|
|
30
|
+
export function normalizeToken(raw) {
|
|
31
|
+
return String(raw ?? '')
|
|
32
|
+
.replace(/^Bearer\s+/i, '')
|
|
33
|
+
.trim();
|
|
34
|
+
}
|
|
35
|
+
/** base64url → bytes (no Node Buffer). Explicit `new Uint8Array(len)` so it's ArrayBuffer-backed
|
|
36
|
+
* (a plain BufferSource for crypto.subtle), not `Uint8Array<ArrayBufferLike>`. */
|
|
37
|
+
function base64urlToBytes(b64url) {
|
|
38
|
+
const b64 = b64url.replace(/-/g, '+').replace(/_/g, '/');
|
|
39
|
+
const padded = b64 + '='.repeat((4 - (b64.length % 4)) % 4);
|
|
40
|
+
const binary = atob(padded);
|
|
41
|
+
const out = new Uint8Array(binary.length); // inferred Uint8Array<ArrayBuffer> — a plain BufferSource
|
|
42
|
+
for (let i = 0; i < binary.length; i++)
|
|
43
|
+
out[i] = binary.charCodeAt(i);
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
export function assertClaims(payload, exp = {}) {
|
|
47
|
+
const wantAud = exp.aud ?? 'ns';
|
|
48
|
+
const wanted = Array.isArray(wantAud) ? wantAud : [wantAud];
|
|
49
|
+
const rawAud = payload.aud;
|
|
50
|
+
const got = Array.isArray(rawAud) ? rawAud.map(String) : rawAud != null ? [String(rawAud)] : [];
|
|
51
|
+
if (!got.some((a) => wanted.includes(a))) {
|
|
52
|
+
return { ok: false, reason: `aud mismatch (want ${wanted.join('|')}, got ${got.join('|') || '∅'})` };
|
|
53
|
+
}
|
|
54
|
+
if (exp.validateIss !== false) {
|
|
55
|
+
// Accept one issuer or several (same backend behind more than one portal hostname). Exact match
|
|
56
|
+
// only — a wildcard here would let any host under a suffix mint tokens we accept.
|
|
57
|
+
const rawIss = exp.iss;
|
|
58
|
+
const wantedIss = (Array.isArray(rawIss) ? rawIss : rawIss != null ? [rawIss] : []).map((i) => String(i).trim()).filter(Boolean);
|
|
59
|
+
// No default: an issuer default would be someone's specific portal. Fail closed and say how to fix.
|
|
60
|
+
if (!wantedIss.length) {
|
|
61
|
+
return { ok: false, reason: 'iss expectation missing — pass `iss` (your portal host, e.g. "manage.example.com", or a list of them) or set `validateIss: false` to opt out' };
|
|
62
|
+
}
|
|
63
|
+
const gotIss = String(payload.iss ?? '');
|
|
64
|
+
if (!wantedIss.includes(gotIss)) {
|
|
65
|
+
return { ok: false, reason: `iss mismatch (want ${wantedIss.join('|')}, got ${gotIss || '∅'})` };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return { ok: true };
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Verify an ns_t's HS256 signature locally with the shared secret. ns_t is HS256 (symmetric), signed
|
|
72
|
+
* by the NetSapiens core — so this needs that HMAC secret (there is no public JWKS). Returns false for
|
|
73
|
+
* a wrong/absent secret, tampered token, or any header alg other than HS256 (blocks alg:none / alg
|
|
74
|
+
* confusion). Async (crypto.subtle HMAC). When you don't hold the secret, leave it unset and rely on
|
|
75
|
+
* the live `/jwt` roundtrip as the (server-side) signature authority.
|
|
76
|
+
*/
|
|
77
|
+
export async function verifyHs256Signature(token, secret) {
|
|
78
|
+
const parts = normalizeToken(token).split('.');
|
|
79
|
+
if (parts.length !== 3 || !secret)
|
|
80
|
+
return false;
|
|
81
|
+
try {
|
|
82
|
+
const header = JSON.parse(base64urlToUtf8(parts[0]));
|
|
83
|
+
if (header.alg !== 'HS256')
|
|
84
|
+
return false;
|
|
85
|
+
const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['verify']);
|
|
86
|
+
return await crypto.subtle.verify('HMAC', key, base64urlToBytes(parts[2]), new TextEncoder().encode(`${parts[0]}.${parts[1]}`));
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/** exp/nbf claims may be number or numeric string (matches the node's toEpochSeconds). */
|
|
93
|
+
function toEpochSeconds(value) {
|
|
94
|
+
if (typeof value === 'number' && Number.isFinite(value))
|
|
95
|
+
return Math.trunc(value);
|
|
96
|
+
if (typeof value === 'string') {
|
|
97
|
+
const n = Number.parseInt(value.trim(), 10);
|
|
98
|
+
if (Number.isFinite(n))
|
|
99
|
+
return n;
|
|
100
|
+
}
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
/** Pull the routing-relevant context out of NS claims (tolerant of naming variants). */
|
|
104
|
+
export function extractContext(payload) {
|
|
105
|
+
const pick = (...keys) => {
|
|
106
|
+
for (const k of keys) {
|
|
107
|
+
const v = payload[k];
|
|
108
|
+
if (typeof v === 'string' && v.trim())
|
|
109
|
+
return v.trim();
|
|
110
|
+
}
|
|
111
|
+
return undefined;
|
|
112
|
+
};
|
|
113
|
+
const sub = pick('sub', 'username', 'user_name');
|
|
114
|
+
let domain = pick('domain', 'nsDomain', 'territory_domain');
|
|
115
|
+
let user = pick('user', 'uid', 'extension');
|
|
116
|
+
// sub is often user@domain — derive the halves if the explicit claims are absent.
|
|
117
|
+
if (sub && sub.includes('@')) {
|
|
118
|
+
const [u, d] = sub.split('@');
|
|
119
|
+
user = user ?? u;
|
|
120
|
+
domain = domain ?? d;
|
|
121
|
+
}
|
|
122
|
+
return {
|
|
123
|
+
domain,
|
|
124
|
+
user,
|
|
125
|
+
scope: pick('user_scope', 'scope', 'role'),
|
|
126
|
+
sub,
|
|
127
|
+
maskChain: pick('mask_chain', 'maskChain'),
|
|
128
|
+
email: pick('user_email', 'email'),
|
|
129
|
+
displayName: pick('displayName', 'display_name', 'name'),
|
|
130
|
+
territory: pick('territory'),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Local format + expiry check. No network, no signature verification.
|
|
135
|
+
* `nowMs` is injectable for testing.
|
|
136
|
+
*/
|
|
137
|
+
export function validateJwtFormat(token, nowMs = Date.now()) {
|
|
138
|
+
const t = normalizeToken(token);
|
|
139
|
+
if (!t)
|
|
140
|
+
return { validFormat: false, unexpired: false, reason: 'Empty token', context: {} };
|
|
141
|
+
const parts = t.split('.');
|
|
142
|
+
if (parts.length !== 3) {
|
|
143
|
+
return { validFormat: false, unexpired: false, reason: `Expected 3 JWT segments, got ${parts.length}`, context: {} };
|
|
144
|
+
}
|
|
145
|
+
let payload;
|
|
146
|
+
try {
|
|
147
|
+
payload = JSON.parse(base64urlToUtf8(parts[1]));
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
return { validFormat: false, unexpired: false, reason: 'Failed to decode JWT payload (invalid base64url or JSON)', context: {} };
|
|
151
|
+
}
|
|
152
|
+
const expSeconds = toEpochSeconds(payload.exp);
|
|
153
|
+
const nowSeconds = Math.trunc(nowMs / 1000);
|
|
154
|
+
const context = extractContext(payload);
|
|
155
|
+
if (expSeconds === undefined) {
|
|
156
|
+
return { validFormat: true, unexpired: false, reason: 'Missing or invalid exp claim', payload, context };
|
|
157
|
+
}
|
|
158
|
+
const expiresInSeconds = expSeconds - nowSeconds;
|
|
159
|
+
return {
|
|
160
|
+
validFormat: true,
|
|
161
|
+
unexpired: expiresInSeconds > 0,
|
|
162
|
+
expiresAt: new Date(expSeconds * 1000).toISOString(),
|
|
163
|
+
expiresInSeconds,
|
|
164
|
+
...(expiresInSeconds <= 0 ? { reason: 'Token exp is in the past' } : {}),
|
|
165
|
+
payload,
|
|
166
|
+
context,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
/** Simple in-isolate cache for dev / a single Worker isolate (not shared across isolates). */
|
|
170
|
+
export class MemoryVerdictCache {
|
|
171
|
+
store = new Map();
|
|
172
|
+
async get(key) {
|
|
173
|
+
const hit = this.store.get(key);
|
|
174
|
+
if (!hit)
|
|
175
|
+
return undefined;
|
|
176
|
+
if (hit.expiresAtMs <= Date.now()) {
|
|
177
|
+
this.store.delete(key);
|
|
178
|
+
return undefined;
|
|
179
|
+
}
|
|
180
|
+
return hit.verdict;
|
|
181
|
+
}
|
|
182
|
+
async set(key, verdict, ttlSeconds) {
|
|
183
|
+
this.store.set(key, { verdict, expiresAtMs: Date.now() + ttlSeconds * 1000 });
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
/** SHA-256 hex of the token — cache key that never stores the raw token. */
|
|
187
|
+
export async function tokenKey(token) {
|
|
188
|
+
const data = new TextEncoder().encode(normalizeToken(token));
|
|
189
|
+
const digest = await crypto.subtle.digest('SHA-256', data);
|
|
190
|
+
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Full gate. Order: cheap local format+exp → (live mode) cached live verdict → live GET /jwt.
|
|
194
|
+
* A malformed/expired token returns immediately and never touches the server.
|
|
195
|
+
*/
|
|
196
|
+
export async function verify(token, opts) {
|
|
197
|
+
const nowMs = opts.nowMs ?? Date.now();
|
|
198
|
+
const checkedAt = new Date(nowMs).toISOString();
|
|
199
|
+
const fmt = validateJwtFormat(token, nowMs);
|
|
200
|
+
const base = {
|
|
201
|
+
validFormat: fmt.validFormat,
|
|
202
|
+
unexpired: fmt.unexpired,
|
|
203
|
+
live: 'skipped',
|
|
204
|
+
ok: false,
|
|
205
|
+
...fmt.context,
|
|
206
|
+
...(fmt.expiresAt ? { expiresAt: fmt.expiresAt } : {}),
|
|
207
|
+
...(fmt.expiresInSeconds !== undefined ? { expiresInSeconds: fmt.expiresInSeconds } : {}),
|
|
208
|
+
...(fmt.reason ? { reason: fmt.reason } : {}),
|
|
209
|
+
payload: fmt.payload,
|
|
210
|
+
checkedAt,
|
|
211
|
+
};
|
|
212
|
+
// Local gate: bad format or expired ⇒ reject without a roundtrip.
|
|
213
|
+
if (!fmt.validFormat || !fmt.unexpired)
|
|
214
|
+
return base;
|
|
215
|
+
// Claim assertions (always, no key needed): aud must be "ns"; iss must match unless opted out.
|
|
216
|
+
const claims = assertClaims(fmt.payload ?? {}, { aud: opts.expectedAud, iss: opts.expectedIss, validateIss: opts.validateIss });
|
|
217
|
+
if (!claims.ok)
|
|
218
|
+
return { ...base, ok: false, reason: claims.reason };
|
|
219
|
+
// Signature: when a shared secret is configured, verify HS256 LOCALLY FIRST (reject forgeries with
|
|
220
|
+
// no roundtrip). Without a secret we can't verify locally (no public JWKS) → 'unverified', and the
|
|
221
|
+
// live check below is the authority.
|
|
222
|
+
let signature = 'unverified';
|
|
223
|
+
if (opts.signingSecret) {
|
|
224
|
+
signature = (await verifyHs256Signature(token, opts.signingSecret)) ? 'valid' : 'invalid';
|
|
225
|
+
if (signature === 'invalid')
|
|
226
|
+
return { ...base, signature, ok: false, reason: 'Signature verification failed' };
|
|
227
|
+
}
|
|
228
|
+
const withSig = { ...base, signature };
|
|
229
|
+
if ((opts.mode ?? 'live') === 'format') {
|
|
230
|
+
// Local-only mode. `ok` means AUTHENTICATED, so it requires a locally-verified signature
|
|
231
|
+
// (`signingSecret`). ns_t has no public JWKS, so without a secret the signature is 'unverified' ⇒
|
|
232
|
+
// ok:false (structurally + aud/iss valid, but NOT attested). Use mode:'live' — the server-side
|
|
233
|
+
// signature authority — to actually authenticate an ns_t.
|
|
234
|
+
return signature === 'valid'
|
|
235
|
+
? { ...withSig, live: 'skipped', ok: true }
|
|
236
|
+
: { ...withSig, live: 'skipped', ok: false, reason: 'Signature not verified (format mode without signingSecret)' };
|
|
237
|
+
}
|
|
238
|
+
// Live mode — consult cache first (unless force-fresh: writes/sensitive reads always re-check).
|
|
239
|
+
const key = await tokenKey(token);
|
|
240
|
+
if (opts.cache && !opts.forceFresh) {
|
|
241
|
+
const cached = await opts.cache.get(key);
|
|
242
|
+
if (cached)
|
|
243
|
+
return { ...cached, fromCache: true, checkedAt };
|
|
244
|
+
}
|
|
245
|
+
// Cache miss → the one server roundtrip. This is the SIGNATURE/revocation authority, so make it
|
|
246
|
+
// brittle-safe: a timeout (a hung NS core can't tie up the request), NO redirect following
|
|
247
|
+
// (`manual`), and ONLY a literal 200 counts as valid — any 3xx/5xx/other fails closed, uncached.
|
|
248
|
+
const doFetch = opts.fetchImpl ?? fetch;
|
|
249
|
+
const controller = new AbortController();
|
|
250
|
+
const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? 4000);
|
|
251
|
+
let verdict;
|
|
252
|
+
try {
|
|
253
|
+
const res = await doFetch(`https://${opts.server}/ns-api/v2/jwt`, {
|
|
254
|
+
method: 'GET',
|
|
255
|
+
headers: { Authorization: `Bearer ${normalizeToken(token)}` },
|
|
256
|
+
redirect: 'manual',
|
|
257
|
+
signal: controller.signal,
|
|
258
|
+
});
|
|
259
|
+
if (res.status === 401 || res.status === 403) {
|
|
260
|
+
verdict = { ...withSig, live: 'invalid', ok: false, statusCode: res.status, reason: `JWT rejected by API (${res.status})` };
|
|
261
|
+
}
|
|
262
|
+
else if (res.status === 200) {
|
|
263
|
+
verdict = { ...withSig, live: 'valid', ok: true, statusCode: res.status };
|
|
264
|
+
}
|
|
265
|
+
else {
|
|
266
|
+
// Anything else (3xx redirect, 5xx, opaque, 0) — don't trust it. Fail closed, don't cache.
|
|
267
|
+
verdict = { ...withSig, live: 'error', ok: false, statusCode: res.status, reason: `Unexpected /jwt status ${res.status}` };
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
catch (err) {
|
|
271
|
+
verdict = { ...withSig, live: 'error', ok: false, reason: `JWT check failed: ${err.message}` };
|
|
272
|
+
}
|
|
273
|
+
finally {
|
|
274
|
+
clearTimeout(timer);
|
|
275
|
+
}
|
|
276
|
+
// Cache valid/invalid verdicts (never 'error'). TTL capped by the token's own exp.
|
|
277
|
+
if (opts.cache && (verdict.live === 'valid' || verdict.live === 'invalid')) {
|
|
278
|
+
const cap = verdict.live === 'valid' ? (opts.maxLiveTtlSeconds ?? 60) : (opts.negativeTtlSeconds ?? 30);
|
|
279
|
+
const untilExp = fmt.expiresInSeconds ?? 0;
|
|
280
|
+
const ttl = verdict.live === 'valid' ? Math.max(0, Math.min(cap, untilExp)) : cap;
|
|
281
|
+
// Trim the full decoded claims blob before persisting: nothing downstream reads verdict.payload
|
|
282
|
+
// (toPrincipal uses the typed context fields), and it's the largest PII surface to leave sitting
|
|
283
|
+
// in the per-colo cache. Keep it on the returned verdict (this request only), drop it from storage.
|
|
284
|
+
const { payload: _payload, ...cacheable } = verdict;
|
|
285
|
+
if (ttl > 0)
|
|
286
|
+
await opts.cache.set(key, cacheable, ttl);
|
|
287
|
+
}
|
|
288
|
+
return verdict;
|
|
289
|
+
}
|
|
290
|
+
//# sourceMappingURL=jwt.js.map
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FlowGraph -> Mermaid flowchart. Node shape + CSS class encode the node kind so a
|
|
3
|
+
* reader can tell a queue from a mailbox from an external forward at a glance.
|
|
4
|
+
*/
|
|
5
|
+
import type { FlowGraph } from './model.js';
|
|
6
|
+
/** Diagram theme. `dark` is the original palette (Cloudflare Worker / live-chart use);
|
|
7
|
+
* `light` matches ns-onboard's light review report. */
|
|
8
|
+
export type FlowTheme = 'light' | 'dark';
|
|
9
|
+
export interface MermaidOptions {
|
|
10
|
+
/** Emit a themed diagram. OMIT for byte-identical legacy output (the Worker relies on this). */
|
|
11
|
+
theme?: FlowTheme;
|
|
12
|
+
}
|
|
13
|
+
export declare function toMermaid(g: FlowGraph, opts?: MermaidOptions): string;
|
|
14
|
+
//# sourceMappingURL=mermaid.d.ts.map
|
package/dist/mermaid.js
ADDED
|
Binary file
|
package/dist/model.d.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalized call-flow graph — renderer-agnostic. The resolver emits this from a
|
|
3
|
+
* NetSapiens domain snapshot; the Mermaid emitter (and, later, any other renderer)
|
|
4
|
+
* consumes it. This is the "real IP" contract from the handoff: a normalized graph JSON.
|
|
5
|
+
*
|
|
6
|
+
* Runtime-portable by design: no Node-only imports here or in resolver.ts, so the same
|
|
7
|
+
* code can run in the ns-onboard CLI and in a Cloudflare Worker.
|
|
8
|
+
*/
|
|
9
|
+
export type NodeKind = 'did' | 'timeframe' | 'user' | 'devices' | 'queue' | 'agents' | 'attendant' | 'prompt' | 'voicemail' | 'external' | 'trunk' | 'hangup' | 'unknown';
|
|
10
|
+
export type EdgeKind = 'route' | 'time' | 'always' | 'noanswer' | 'busy' | 'unreg' | 'dnd' | 'dispatch' | 'overflow' | 'menu' | 'ref';
|
|
11
|
+
export interface FlowNode {
|
|
12
|
+
id: string;
|
|
13
|
+
kind: NodeKind;
|
|
14
|
+
/** Primary label line. */
|
|
15
|
+
label: string;
|
|
16
|
+
/** Optional secondary line (e.g. dispatch type, schedule). */
|
|
17
|
+
sub?: string;
|
|
18
|
+
/** Optional additional lines rendered one-per-line under the label (e.g. a bulleted agent list). */
|
|
19
|
+
lines?: string[];
|
|
20
|
+
/** Full text for a hover tooltip (e.g. a long greeting shown truncated in the label). Viewer-only. */
|
|
21
|
+
title?: string;
|
|
22
|
+
}
|
|
23
|
+
export interface FlowEdge {
|
|
24
|
+
from: string;
|
|
25
|
+
to: string;
|
|
26
|
+
kind: EdgeKind;
|
|
27
|
+
/** Edge caption (e.g. "open hrs", "no answer 30s"). */
|
|
28
|
+
label?: string;
|
|
29
|
+
}
|
|
30
|
+
export interface FlowGraph {
|
|
31
|
+
/** Entity the flow was resolved for. */
|
|
32
|
+
entity: {
|
|
33
|
+
kind: string;
|
|
34
|
+
ref: string;
|
|
35
|
+
label: string;
|
|
36
|
+
};
|
|
37
|
+
domain: string;
|
|
38
|
+
rootId: string;
|
|
39
|
+
nodes: FlowNode[];
|
|
40
|
+
edges: FlowEdge[];
|
|
41
|
+
/** Human-facing caveats surfaced during resolution (gaps, unmapped params, cycles). */
|
|
42
|
+
notes: string[];
|
|
43
|
+
}
|
|
44
|
+
export type Rec = Record<string, any>;
|
|
45
|
+
export interface Snapshot {
|
|
46
|
+
meta: Rec;
|
|
47
|
+
domain?: Rec;
|
|
48
|
+
timeframes?: Rec[];
|
|
49
|
+
users?: Rec[];
|
|
50
|
+
devicesByUser?: Record<string, Rec[]>;
|
|
51
|
+
callqueues?: Rec[];
|
|
52
|
+
agentsByQueue?: Record<string, Rec[]>;
|
|
53
|
+
phonenumbers?: Rec[];
|
|
54
|
+
autoattendants?: Rec[];
|
|
55
|
+
dialrulesByPlan?: Record<string, Rec[]>;
|
|
56
|
+
answerrulesByUser?: Record<string, Rec[]>;
|
|
57
|
+
/**
|
|
58
|
+
* Optional per-attendant menu detail, keyed by AA extension — the response of
|
|
59
|
+
* GET /domains/{d}/users/{ext}/autoattendants/{prompt} (an `auto-attendant` tier +
|
|
60
|
+
* top-level greeting `audio` + `intro-greetings[]` + `time-frame`). When present, the
|
|
61
|
+
* resolver renders the real keypress menu; when absent, it emits a "not captured" note.
|
|
62
|
+
*
|
|
63
|
+
* Two shapes are accepted:
|
|
64
|
+
* - `attendantDetails[ext]` — a single detail (current live fetch; SV builds AAs on `*`).
|
|
65
|
+
* - `attendantDetailsByUser[ext]` — an ARRAY of details (ns-onboard enriched backup: an AA may
|
|
66
|
+
* have multiple prompts/timeframes). The resolver picks the `*`/Default one as primary and
|
|
67
|
+
* flags the rest as a deviation (see the AA backup enrichment spec, Addendum 2026-07-11).
|
|
68
|
+
*/
|
|
69
|
+
attendantDetails?: Record<string, Rec>;
|
|
70
|
+
attendantDetailsByUser?: Record<string, Rec[]>;
|
|
71
|
+
/**
|
|
72
|
+
* Per-AA dialplan dialrules, keyed by AA extension — the AUTHORITATIVE menu + default routing that
|
|
73
|
+
* the /autoattendants detail omits (no-key/star/option). From GET /domains/{d}/dialplans/{domain}_{ext}/dialrules.
|
|
74
|
+
* The resolver reads `Prompt_<startingPrompt-id>.<suffix>` rules: .Default (no-key/timeout), .* (unassigned),
|
|
75
|
+
* .<digit> (press N), .Case_[...] (dial-by-ext). See CLAUDE.md → NetSapiens API notes.
|
|
76
|
+
*/
|
|
77
|
+
attendantDialrulesByExt?: Record<string, Rec[]>;
|
|
78
|
+
[k: string]: any;
|
|
79
|
+
}
|
|
80
|
+
//# sourceMappingURL=model.d.ts.map
|
package/dist/model.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalized call-flow graph — renderer-agnostic. The resolver emits this from a
|
|
3
|
+
* NetSapiens domain snapshot; the Mermaid emitter (and, later, any other renderer)
|
|
4
|
+
* consumes it. This is the "real IP" contract from the handoff: a normalized graph JSON.
|
|
5
|
+
*
|
|
6
|
+
* Runtime-portable by design: no Node-only imports here or in resolver.ts, so the same
|
|
7
|
+
* code can run in the ns-onboard CLI and in a Cloudflare Worker.
|
|
8
|
+
*/
|
|
9
|
+
export {};
|
|
10
|
+
//# sourceMappingURL=model.js.map
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Portable NetSapiens API read client — the seed of the eventual "NS API for Worker/Node"
|
|
3
|
+
* library (see CLAUDE.md → North star). Ported from NetSapiens-Onboarding-Backup (`src/api/client.ts`
|
|
4
|
+
* NsClient + `src/backup/snapshot.ts` backupDomain), trimmed to the READ-ONLY routing subset the
|
|
5
|
+
* resolver needs, and kept Node-free (fetch/URL only) so it runs in a Cloudflare Worker unchanged.
|
|
6
|
+
*
|
|
7
|
+
* `fetchDomainSnapshot()` assembles the same `Snapshot` shape the resolver already consumes, so a
|
|
8
|
+
* live domain flows end-to-end: domain + token → Snapshot → resolveFlow → FlowGraph.
|
|
9
|
+
*
|
|
10
|
+
* This tool never writes to NetSapiens — only GET is exposed.
|
|
11
|
+
*/
|
|
12
|
+
import type { Rec, Snapshot } from './model.js';
|
|
13
|
+
export declare class NsApiError extends Error {
|
|
14
|
+
readonly status: number;
|
|
15
|
+
readonly path: string;
|
|
16
|
+
readonly body: unknown;
|
|
17
|
+
constructor(message: string, status: number, path: string, body: unknown);
|
|
18
|
+
}
|
|
19
|
+
export interface NsClientConfig {
|
|
20
|
+
/** API host, e.g. "api.example.com". Base URL becomes https://{server}/ns-api/v2. */
|
|
21
|
+
server: string;
|
|
22
|
+
/** Bearer token (the portal user's `ns_t`, or an API key). */
|
|
23
|
+
token: string;
|
|
24
|
+
/** Injectable for tests / non-global fetch. */
|
|
25
|
+
fetchImpl?: typeof fetch;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* NS API v2 client — READ-ONLY BY DESIGN (the central gate for this whole tool).
|
|
29
|
+
*
|
|
30
|
+
* The ONLY method is `get()`, which hardcodes `method: 'GET'`. There is deliberately no
|
|
31
|
+
* post/put/delete/patch — the viewer must never mutate NetSapiens. Keep it that way: do not add a
|
|
32
|
+
* mutating method here. Any write capability must be a separate, explicitly-reviewed client, not a
|
|
33
|
+
* quiet addition to this one. This is the single choke point every NS call in the Worker flows through.
|
|
34
|
+
*/
|
|
35
|
+
export declare class NsClient {
|
|
36
|
+
private readonly baseUrl;
|
|
37
|
+
private readonly token;
|
|
38
|
+
private readonly fetchImpl;
|
|
39
|
+
constructor(cfg: NsClientConfig);
|
|
40
|
+
get<T = unknown>(path: string, query?: Record<string, string | number>): Promise<T>;
|
|
41
|
+
}
|
|
42
|
+
/** Normalize a v2 response to an array of records (endpoints return an array or a bare object). */
|
|
43
|
+
export declare function asArray(res: unknown): Rec[];
|
|
44
|
+
/** List domains the token can read (for the internal viewer's domain browser). `locked` is set only
|
|
45
|
+
* for domains flagged `is-domain-locked: yes` (config-locked in NetSapiens). */
|
|
46
|
+
export declare function listDomains(client: NsClient): Promise<{
|
|
47
|
+
domain: string;
|
|
48
|
+
description?: string;
|
|
49
|
+
locked?: boolean;
|
|
50
|
+
}[]>;
|
|
51
|
+
export interface FetchSnapshotOptions {
|
|
52
|
+
/** Fetch each AA's keypress menu (GET .../autoattendants/{prompt}). Default true. */
|
|
53
|
+
includeAttendantMenus?: boolean;
|
|
54
|
+
/** Also fetch the default-plan dialrules (rarely needed — classifyParam handles aliases). Default false. */
|
|
55
|
+
includeDialrules?: boolean;
|
|
56
|
+
/** Max concurrent per-item requests. Default 5. Mind Workers' subrequest cap on huge domains. */
|
|
57
|
+
concurrency?: number;
|
|
58
|
+
/**
|
|
59
|
+
* Shallow: fetch only the top-level lists (domain, timeframes, users, callqueues, phonenumbers,
|
|
60
|
+
* autoattendants) and skip the per-user/queue/AA fan-out. Enough for `listEntities()` (the entity
|
|
61
|
+
* picker) at a fraction of the requests. Default false.
|
|
62
|
+
*/
|
|
63
|
+
shallow?: boolean;
|
|
64
|
+
/**
|
|
65
|
+
* With `shallow`, also fetch answer rules for the DIDs' destination users (a handful of extra
|
|
66
|
+
* reads) so `listEntities()` can flag time-of-day (TOD) DIDs. Default false.
|
|
67
|
+
*/
|
|
68
|
+
includeDidDestRules?: boolean;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Read a live domain into the `Snapshot` shape the resolver consumes. Routing subset only:
|
|
72
|
+
* domain, timeframes, users, callqueues, phonenumbers, autoattendants, per-user answerrules,
|
|
73
|
+
* per-queue agents, and (by default) per-AA menu detail.
|
|
74
|
+
*
|
|
75
|
+
* A per-item read that fails is treated as "absent" (empty) so one missing child never aborts the
|
|
76
|
+
* whole flow — the resolver tolerates gaps. A failing top-level read (e.g. 401) DOES throw.
|
|
77
|
+
*/
|
|
78
|
+
export declare function fetchDomainSnapshot(client: NsClient, domain: string, opts?: FetchSnapshotOptions): Promise<Snapshot>;
|
|
79
|
+
//# sourceMappingURL=nsClient.d.ts.map
|