@dszp/netsapiens-lib 0.1.1 → 0.1.2
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 +7 -1
- package/dist/html.d.ts +15 -8
- package/dist/html.js +27 -9
- package/dist/index.d.ts +4 -5
- package/dist/index.js +4 -5
- package/dist/jwt.d.ts +18 -4
- package/dist/jwt.js +75 -10
- package/dist/mermaid.d.ts +1 -2
- package/dist/mermaid.js +0 -0
- package/dist/model.d.ts +3 -4
- package/dist/model.js +1 -2
- package/dist/nsClient.d.ts +11 -5
- package/dist/nsClient.js +38 -14
- package/dist/policy.d.ts +0 -1
- package/dist/policy.js +14 -2
- package/dist/principal.d.ts +0 -1
- package/dist/principal.js +0 -1
- package/dist/raster.d.ts +0 -1
- package/dist/raster.js +0 -1
- package/dist/resolver.d.ts +0 -1
- package/dist/resolver.js +2 -3
- package/dist/sensitivity.d.ts +0 -1
- package/dist/sensitivity.js +0 -1
- package/dist/themes.d.ts +0 -1
- package/dist/themes.js +0 -1
- package/package.json +7 -3
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@ Portable, **Node-free** NetSapiens toolkit. The same code runs unchanged in a Cl
|
|
|
4
4
|
Node, or in the browser — it uses only Web APIs (`fetch`, `atob`, `TextDecoder`, `crypto.subtle`),
|
|
5
5
|
never `node:*`.
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
Five capabilities, one dependency-free package:
|
|
8
8
|
|
|
9
9
|
- **Read-only NS API v2 client** — `NsClient` (bearer auth, injectable `fetch`) +
|
|
10
10
|
`fetchDomainSnapshot(client, domain)` which assembles a routing-relevant domain snapshot.
|
|
@@ -14,6 +14,12 @@ Three capabilities, one dependency-free package:
|
|
|
14
14
|
- **Call-flow resolver + renderers** — `resolveFlow(snapshot, ref)` walks a NetSapiens domain snapshot
|
|
15
15
|
into a normalized `FlowGraph`; `toMermaid()` renders it to a Mermaid flowchart; `renderGalleryHtml()`
|
|
16
16
|
/ `renderFlowCards()` return HTML strings the caller can place anywhere.
|
|
17
|
+
- **Identity + policy** — `toPrincipal()` normalizes a validated token into an effective identity
|
|
18
|
+
(masking-aware: the effective user is the masked one, the `operator` is the reseller behind a
|
|
19
|
+
`mask_chain`), and `can()` / `isAllowed()` gate features against it with a declarative,
|
|
20
|
+
**fail-closed** policy. So "who is this, and may they?" isn't re-invented per consumer.
|
|
21
|
+
- **Themes** — `THEMES`, a vendor-neutral registry (node palettes + Mermaid base/look + app chrome)
|
|
22
|
+
as plain data. Add one here and every host picks it up; nothing is bound to one deployment's brand.
|
|
17
23
|
|
|
18
24
|
## Install
|
|
19
25
|
|
package/dist/html.d.ts
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* FlowGraph[] -> a self-contained gallery HTML string. Portable (no Node deps): the caller
|
|
3
3
|
* decides where the string goes — a file (CLI), an HTTP response (Worker), or embedded in
|
|
4
|
-
*
|
|
4
|
+
* a host review page / build-preview. Mermaid renders client-side from a CDN.
|
|
5
5
|
*/
|
|
6
6
|
import type { FlowGraph } from './model.js';
|
|
7
7
|
import { type FlowTheme } from './mermaid.js';
|
|
8
|
-
/** Stable, collision-safe DOM id for a flow card so a host page can deep-link one diagram.
|
|
8
|
+
/** Stable, collision-safe DOM id for a flow card so a host page can deep-link one diagram.
|
|
9
|
+
* `kind` is sanitized alongside `ref`: resolveFlow only ever emits the four literals, but
|
|
10
|
+
* FlowGraph.entity.kind is typed `string` and hand-built graphs are a supported use, so an
|
|
11
|
+
* unsanitized kind would land unescaped in `id="…"`/`href="#…"`. */
|
|
9
12
|
export declare function flowAnchorId(g: FlowGraph): string;
|
|
10
13
|
export interface CardOptions {
|
|
11
14
|
/** Themed rendering (light/dark palette + `look: neo` + a card anchor id). OMIT for the
|
|
@@ -23,10 +26,15 @@ export interface GalleryOptions extends CardOptions {
|
|
|
23
26
|
mermaidSrc?: string;
|
|
24
27
|
/** Extra note shown under the title. */
|
|
25
28
|
subtitle?: string;
|
|
26
|
-
/** Brand accent
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* `accent: '#1a6bb0'`.
|
|
29
|
+
/** Brand accent for subtle highlights — links, the "Legend:" lead, the card's top rule, and the
|
|
30
|
+
* pan/zoom controls' hover. Themed path only; defaults to the theme's link color. Pass your own
|
|
31
|
+
* brand color here (from your host's config) rather than baking one into a theme, e.g.
|
|
32
|
+
* `accent: '#1a6bb0'`.
|
|
33
|
+
*
|
|
34
|
+
* MUST be a CSS hex color (`#rgb` … `#rrggbbaa`); this value is interpolated into a `<style>`
|
|
35
|
+
* block, so anything else is IGNORED in favour of the theme's link color rather than escaped.
|
|
36
|
+
* If you source it per-tenant, that rejection is the only thing between a hostile value and a
|
|
37
|
+
* `</style><script>` breakout — don't defeat it by pre-formatting the string. */
|
|
30
38
|
accent?: string;
|
|
31
39
|
/** Themed galleries only: anchor ids ({@link flowAnchorId}) to render pre-expanded. Cards not in
|
|
32
40
|
* the set render collapsed. A themed gallery is always collapsible with a table-of-contents. */
|
|
@@ -39,7 +47,7 @@ export declare function renderFlowCard(g: FlowGraph, opts?: CardOptions): string
|
|
|
39
47
|
export declare function renderFlowCards(graphs: FlowGraph[], opts?: CardOptions): string;
|
|
40
48
|
/**
|
|
41
49
|
* The `<script>` tags a host page needs to render embedded `.mermaid` blocks itself
|
|
42
|
-
* (e.g.
|
|
50
|
+
* (e.g. a review report). `securityLevel: 'strict'` is MANDATORY — it is the
|
|
43
51
|
* second escaping layer `mermaid.ts` depends on; do not make it caller-configurable.
|
|
44
52
|
*/
|
|
45
53
|
export declare function mermaidBootstrap(opts?: {
|
|
@@ -48,4 +56,3 @@ export declare function mermaidBootstrap(opts?: {
|
|
|
48
56
|
}): string;
|
|
49
57
|
/** Full standalone HTML document for a set of flows. */
|
|
50
58
|
export declare function renderGalleryHtml(domain: string, graphs: FlowGraph[], opts?: GalleryOptions): string;
|
|
51
|
-
//# sourceMappingURL=html.d.ts.map
|
package/dist/html.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* FlowGraph[] -> a self-contained gallery HTML string. Portable (no Node deps): the caller
|
|
3
3
|
* decides where the string goes — a file (CLI), an HTTP response (Worker), or embedded in
|
|
4
|
-
*
|
|
4
|
+
* a host review page / build-preview. Mermaid renders client-side from a CDN.
|
|
5
5
|
*/
|
|
6
6
|
import { toMermaid } from './mermaid.js';
|
|
7
7
|
// Pinned Mermaid build + Subresource Integrity. A floating `mermaid@11` tag lets jsDelivr serve whatever
|
|
@@ -40,15 +40,34 @@ const FLOW_LABEL_CSS = `.mermaid g.agents div, .mermaid g.agents span, .mermaid
|
|
|
40
40
|
* not by padding — so this keeps Mermaid's default node padding (a larger override made wide nodes
|
|
41
41
|
* like AAs balloon). */
|
|
42
42
|
const FLOWCHART_CFG = `htmlLabels:true, curve:'basis', nodeSpacing:45, rankSpacing:55`;
|
|
43
|
+
/** Escapes for BOTH text and quoted-attribute contexts — `mermaidScriptTag` interpolates into
|
|
44
|
+
* `src="…"`, so omitting the quote entities made `escapeHtml(url)` look safe while allowing
|
|
45
|
+
* `x.js" onload="…`. Over-escaping in text position is inert, so one function covers both. */
|
|
43
46
|
function escapeHtml(t) {
|
|
44
|
-
return String(t)
|
|
47
|
+
return String(t)
|
|
48
|
+
.replace(/&/g, '&')
|
|
49
|
+
.replace(/</g, '<')
|
|
50
|
+
.replace(/>/g, '>')
|
|
51
|
+
.replace(/"/g, '"')
|
|
52
|
+
.replace(/'/g, ''');
|
|
45
53
|
}
|
|
46
54
|
function cap(t) {
|
|
47
55
|
return t.charAt(0).toUpperCase() + t.slice(1);
|
|
48
56
|
}
|
|
49
|
-
/** Stable, collision-safe DOM id for a flow card so a host page can deep-link one diagram.
|
|
57
|
+
/** Stable, collision-safe DOM id for a flow card so a host page can deep-link one diagram.
|
|
58
|
+
* `kind` is sanitized alongside `ref`: resolveFlow only ever emits the four literals, but
|
|
59
|
+
* FlowGraph.entity.kind is typed `string` and hand-built graphs are a supported use, so an
|
|
60
|
+
* unsanitized kind would land unescaped in `id="…"`/`href="#…"`. */
|
|
50
61
|
export function flowAnchorId(g) {
|
|
51
|
-
|
|
62
|
+
const safe = (s) => String(s).replace(/[^A-Za-z0-9_-]/g, '-');
|
|
63
|
+
return `flow-${safe(g.entity.kind)}-${safe(g.entity.ref)}`;
|
|
64
|
+
}
|
|
65
|
+
/** A CSS color safe to interpolate into a `<style>` block. Anything else is refused rather than
|
|
66
|
+
* escaped: `accent` is documented as host-supplied config, and a white-label host that sources it
|
|
67
|
+
* per-tenant would otherwise hand any tenant a `</style><script>` breakout. Callers that were
|
|
68
|
+
* already passing hex (the documented contract) see no change. */
|
|
69
|
+
function safeAccent(accent, fallback) {
|
|
70
|
+
return accent && /^#[0-9a-fA-F]{3,8}$/.test(accent) ? accent : fallback;
|
|
52
71
|
}
|
|
53
72
|
/** Render one flow as a gallery `<section>` card. Themed cards carry an `id` anchor
|
|
54
73
|
* ({@link flowAnchorId}); the legacy (no-theme) card is byte-identical to the original. */
|
|
@@ -78,7 +97,7 @@ export function renderFlowCards(graphs, opts = {}) {
|
|
|
78
97
|
}
|
|
79
98
|
/**
|
|
80
99
|
* The `<script>` tags a host page needs to render embedded `.mermaid` blocks itself
|
|
81
|
-
* (e.g.
|
|
100
|
+
* (e.g. a review report). `securityLevel: 'strict'` is MANDATORY — it is the
|
|
82
101
|
* second escaping layer `mermaid.ts` depends on; do not make it caller-configurable.
|
|
83
102
|
*/
|
|
84
103
|
export function mermaidBootstrap(opts = {}) {
|
|
@@ -91,7 +110,7 @@ ${mermaidScriptTag(opts.mermaidSrc)}
|
|
|
91
110
|
export function renderGalleryHtml(domain, graphs, opts = {}) {
|
|
92
111
|
const mermaidSrc = opts.mermaidSrc ?? MERMAID_CDN;
|
|
93
112
|
const subtitle = opts.subtitle ?? `resolved from snapshot · ${graphs.length} flows`;
|
|
94
|
-
// Themed path (light for
|
|
113
|
+
// Themed path (light for a review context, or explicit dark-neo). No theme → the original dark
|
|
95
114
|
// document below, byte-identical (the Worker depends on this).
|
|
96
115
|
if (opts.theme)
|
|
97
116
|
return themedGalleryHtml(domain, graphs, mermaidSrc, subtitle, opts.theme, opts.expand, opts.accent);
|
|
@@ -156,7 +175,7 @@ ${mermaidScriptTag(mermaidSrc)}
|
|
|
156
175
|
</script>
|
|
157
176
|
</body></html>`;
|
|
158
177
|
}
|
|
159
|
-
/** Themed gallery document — light (
|
|
178
|
+
/** Themed gallery document — light (review context) or explicit dark-neo. Parallel to
|
|
160
179
|
* the legacy dark document in {@link renderGalleryHtml}; kept separate so that path stays byte-identical. */
|
|
161
180
|
function themedGalleryHtml(domain, graphs, mermaidSrc, subtitle, theme, expand, accent) {
|
|
162
181
|
const light = theme === 'light';
|
|
@@ -164,7 +183,7 @@ function themedGalleryHtml(domain, graphs, mermaidSrc, subtitle, theme, expand,
|
|
|
164
183
|
? { scheme: 'light', pageBg: '#fafafa', text: '#1e293b', sub: '#64748b', cardBg: '#ffffff', cardBorder: '#e2e8f0', meta: '#64748b', mermaidBg: '#f8fafc', notes: '#b45309', legendText: '#475569', legendBg: '#ffffff', lightboxBg: 'rgba(248,250,252,.96)', hint: '#64748b', shadow: 'box-shadow:0 1px 2px rgba(0,0,0,.04);', link: '#21618c' }
|
|
165
184
|
: { scheme: 'dark', pageBg: '#0f1115', text: '#e6e6e6', sub: '#8a94a6', cardBg: '#161a22', cardBorder: '#232a36', meta: '#7b8494', mermaidBg: '#0c0e12', notes: '#c9a24a', legendText: '#aab', legendBg: '#161a22', lightboxBg: 'rgba(6,8,12,.95)', hint: '#8a94a6', shadow: '', link: '#7db3e6' };
|
|
166
185
|
const initTheme = light ? 'base' : 'dark';
|
|
167
|
-
const brandAccent = accent
|
|
186
|
+
const brandAccent = safeAccent(accent, c.link);
|
|
168
187
|
const single = graphs.length === 1; // a one-flow gallery (e.g. the portal modal) needs no contents nav
|
|
169
188
|
const isOpen = (g) => single || (!!expand && expand.has(flowAnchorId(g)));
|
|
170
189
|
// Table of contents grouped by entity kind; ● marks the pre-expanded (notable) flows.
|
|
@@ -329,4 +348,3 @@ function mermaidBootstrapInline(initTheme) {
|
|
|
329
348
|
}
|
|
330
349
|
</script>`;
|
|
331
350
|
}
|
|
332
|
-
//# sourceMappingURL=html.js.map
|
package/dist/index.d.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Public API of the portable call-flow library — the surface any host imports (Cloudflare
|
|
3
|
-
* Worker,
|
|
4
|
-
* re-exported here is Node-free and runtime-portable
|
|
5
|
-
*
|
|
3
|
+
* Worker, an onboarding CLI / review page / build-preview, the portal viewer). Everything
|
|
4
|
+
* re-exported here is Node-free and runtime-portable; any Node-only host code (e.g. a CLI) lives
|
|
5
|
+
* outside this surface.
|
|
6
6
|
*
|
|
7
7
|
* Typical use in another project:
|
|
8
8
|
* import { resolveFlow, toMermaid, renderGalleryHtml, verify } from '@dszp/netsapiens-lib';
|
|
@@ -15,9 +15,8 @@ export { toMermaid, type FlowTheme, type MermaidOptions } from './mermaid.js';
|
|
|
15
15
|
export { THEMES, DEFAULT_LIGHT_THEME, DEFAULT_DARK_THEME, NODE_LIGHT, NODE_DARK, NODE_SLATE, NODE_A11Y, type ThemeDef, type ThemeChrome, type ThemeMode, type NodePalette, } from './themes.js';
|
|
16
16
|
export { renderGalleryHtml, renderFlowCards, renderFlowCard, mermaidBootstrap, flowAnchorId, type GalleryOptions, type CardOptions, } from './html.js';
|
|
17
17
|
export { resolveSvgSize, rasterizerScript } from './raster.js';
|
|
18
|
-
export { NsClient, NsApiError, fetchDomainSnapshot, listDomains, asArray, type NsClientConfig, type FetchSnapshotOptions } from './nsClient.js';
|
|
18
|
+
export { NsClient, NsApiError, assertBareServer, fetchDomainSnapshot, listDomains, asArray, type NsClientConfig, type FetchSnapshotOptions } from './nsClient.js';
|
|
19
19
|
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
20
|
export { type CallSensitivity, needsFreshAuth, SENSITIVITY_NOTE } from './sensitivity.js';
|
|
21
21
|
export { toPrincipal, parseOperator, isResellerScope, isAdminScope, type Principal, type Operator, type Scope, } from './principal.js';
|
|
22
22
|
export { ruleMatches, isAllowed, can, type PolicyRule, type Policy, type FeaturePolicies, } from './policy.js';
|
|
23
|
-
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Public API of the portable call-flow library — the surface any host imports (Cloudflare
|
|
3
|
-
* Worker,
|
|
4
|
-
* re-exported here is Node-free and runtime-portable
|
|
5
|
-
*
|
|
3
|
+
* Worker, an onboarding CLI / review page / build-preview, the portal viewer). Everything
|
|
4
|
+
* re-exported here is Node-free and runtime-portable; any Node-only host code (e.g. a CLI) lives
|
|
5
|
+
* outside this surface.
|
|
6
6
|
*
|
|
7
7
|
* Typical use in another project:
|
|
8
8
|
* import { resolveFlow, toMermaid, renderGalleryHtml, verify } from '@dszp/netsapiens-lib';
|
|
@@ -14,9 +14,8 @@ export { toMermaid } from './mermaid.js';
|
|
|
14
14
|
export { THEMES, DEFAULT_LIGHT_THEME, DEFAULT_DARK_THEME, NODE_LIGHT, NODE_DARK, NODE_SLATE, NODE_A11Y, } from './themes.js';
|
|
15
15
|
export { renderGalleryHtml, renderFlowCards, renderFlowCard, mermaidBootstrap, flowAnchorId, } from './html.js';
|
|
16
16
|
export { resolveSvgSize, rasterizerScript } from './raster.js';
|
|
17
|
-
export { NsClient, NsApiError, fetchDomainSnapshot, listDomains, asArray } from './nsClient.js';
|
|
17
|
+
export { NsClient, NsApiError, assertBareServer, fetchDomainSnapshot, listDomains, asArray } from './nsClient.js';
|
|
18
18
|
export { verify, validateJwtFormat, extractContext, assertClaims, verifyHs256Signature, normalizeToken, tokenKey, MemoryVerdictCache, } from './jwt.js';
|
|
19
19
|
export { needsFreshAuth, SENSITIVITY_NOTE } from './sensitivity.js';
|
|
20
20
|
export { toPrincipal, parseOperator, isResellerScope, isAdminScope, } from './principal.js';
|
|
21
21
|
export { ruleMatches, isAllowed, can, } from './policy.js';
|
|
22
|
-
//# sourceMappingURL=index.js.map
|
package/dist/jwt.d.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* with a TTL capped by the token's own `exp`. A bad or expired token never reaches the server.
|
|
14
14
|
*
|
|
15
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
|
|
16
|
+
* the same file runs in a Worker and in an onboarding CLI.
|
|
17
17
|
*/
|
|
18
18
|
export interface JwtContext {
|
|
19
19
|
/** The token's domain (from claims). Scopes downstream NS reads. When masking, this is the
|
|
@@ -105,6 +105,8 @@ export declare function extractContext(payload: Record<string, unknown>): JwtCon
|
|
|
105
105
|
export interface FormatResult {
|
|
106
106
|
validFormat: boolean;
|
|
107
107
|
unexpired: boolean;
|
|
108
|
+
/** `nbf` is set and still in the future (beyond the skew leeway) ⇒ the token is not yet valid. */
|
|
109
|
+
notYetValid?: boolean;
|
|
108
110
|
expiresAt?: string;
|
|
109
111
|
expiresInSeconds?: number;
|
|
110
112
|
reason?: string;
|
|
@@ -122,14 +124,27 @@ export interface VerdictCache {
|
|
|
122
124
|
/** ttlSeconds is a hint; the store may evict earlier. */
|
|
123
125
|
set(key: string, verdict: JwtVerdict, ttlSeconds: number): Promise<void>;
|
|
124
126
|
}
|
|
125
|
-
/**
|
|
127
|
+
/**
|
|
128
|
+
* Simple in-isolate cache for dev / a single Worker isolate (not shared across isolates).
|
|
129
|
+
*
|
|
130
|
+
* BOUNDED ON PURPOSE. Expiry alone is not a bound: entries expire lazily, on a `get()` for that
|
|
131
|
+
* exact key, so an attacker who uses each token once never triggers a sweep and every verdict is
|
|
132
|
+
* retained until the isolate dies. Since a *negative* verdict is cached for a token anyone can mint
|
|
133
|
+
* (correct `aud`/`iss`/`exp` need no signing key), an unbounded map is a remote OOM. Hence: sweep on
|
|
134
|
+
* insert, and hard-cap with FIFO eviction. `maxEntries` is generous — a real portal's working set is
|
|
135
|
+
* its live sessions, far below the cap, so eviction only ever bites the pathological case.
|
|
136
|
+
*/
|
|
126
137
|
export declare class MemoryVerdictCache implements VerdictCache {
|
|
138
|
+
private readonly maxEntries;
|
|
127
139
|
private store;
|
|
140
|
+
constructor(maxEntries?: number);
|
|
128
141
|
get(key: string): Promise<JwtVerdict | undefined>;
|
|
129
142
|
set(key: string, verdict: JwtVerdict, ttlSeconds: number): Promise<void>;
|
|
143
|
+
/** Live entry count. Exposed for tests/observability; not part of the VerdictCache contract. */
|
|
144
|
+
get size(): number;
|
|
130
145
|
}
|
|
131
146
|
/** SHA-256 hex of the token — cache key that never stores the raw token. */
|
|
132
|
-
export declare function tokenKey(token: string): Promise<string>;
|
|
147
|
+
export declare function tokenKey(token: string, server?: string): Promise<string>;
|
|
133
148
|
export interface VerifyOptions {
|
|
134
149
|
/** NS API host, e.g. "api.example.com". */
|
|
135
150
|
server: string;
|
|
@@ -171,4 +186,3 @@ export interface VerifyOptions {
|
|
|
171
186
|
* A malformed/expired token returns immediately and never touches the server.
|
|
172
187
|
*/
|
|
173
188
|
export declare function verify(token: string, opts: VerifyOptions): Promise<JwtVerdict>;
|
|
174
|
-
//# sourceMappingURL=jwt.d.ts.map
|
package/dist/jwt.js
CHANGED
|
@@ -13,8 +13,9 @@
|
|
|
13
13
|
* with a TTL capped by the token's own `exp`. A bad or expired token never reaches the server.
|
|
14
14
|
*
|
|
15
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
|
|
16
|
+
* the same file runs in a Worker and in an onboarding CLI.
|
|
17
17
|
*/
|
|
18
|
+
import { assertBareServer } from './nsClient.js';
|
|
18
19
|
// ---------------------------------------------------------------------------
|
|
19
20
|
// Local decode (base64url) — Buffer-free
|
|
20
21
|
// ---------------------------------------------------------------------------
|
|
@@ -130,6 +131,9 @@ export function extractContext(payload) {
|
|
|
130
131
|
territory: pick('territory'),
|
|
131
132
|
};
|
|
132
133
|
}
|
|
134
|
+
/** Clock-skew leeway for `nbf`, in seconds. Matches the Cloudflare-Access verifier (access.ts) so
|
|
135
|
+
* the two JWT paths treat "not yet valid" identically and a small clock drift can't lock anyone out. */
|
|
136
|
+
const NBF_LEEWAY_SECONDS = 60;
|
|
133
137
|
/**
|
|
134
138
|
* Local format + expiry check. No network, no signature verification.
|
|
135
139
|
* `nowMs` is injectable for testing.
|
|
@@ -155,6 +159,24 @@ export function validateJwtFormat(token, nowMs = Date.now()) {
|
|
|
155
159
|
if (expSeconds === undefined) {
|
|
156
160
|
return { validFormat: true, unexpired: false, reason: 'Missing or invalid exp claim', payload, context };
|
|
157
161
|
}
|
|
162
|
+
// nbf: a token dated in the future is not yet valid. Without this it passes every local check and
|
|
163
|
+
// still triggers an upstream /jwt roundtrip a local gate should have refused; and format-mode
|
|
164
|
+
// callers (verify(..., {mode:'format', signingSecret})) would return ok:true for a not-yet-valid
|
|
165
|
+
// token. Defense-in-depth for the live path (the server is the real authority), authoritative for
|
|
166
|
+
// format mode. Leeway matches access.ts.
|
|
167
|
+
const nbfSeconds = toEpochSeconds(payload.nbf);
|
|
168
|
+
if (nbfSeconds !== undefined && nbfSeconds > nowSeconds + NBF_LEEWAY_SECONDS) {
|
|
169
|
+
return {
|
|
170
|
+
validFormat: true,
|
|
171
|
+
unexpired: expSeconds - nowSeconds > 0,
|
|
172
|
+
notYetValid: true,
|
|
173
|
+
expiresAt: new Date(expSeconds * 1000).toISOString(),
|
|
174
|
+
expiresInSeconds: expSeconds - nowSeconds,
|
|
175
|
+
reason: 'Token not yet valid (nbf is in the future)',
|
|
176
|
+
payload,
|
|
177
|
+
context,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
158
180
|
const expiresInSeconds = expSeconds - nowSeconds;
|
|
159
181
|
return {
|
|
160
182
|
validFormat: true,
|
|
@@ -166,9 +188,22 @@ export function validateJwtFormat(token, nowMs = Date.now()) {
|
|
|
166
188
|
context,
|
|
167
189
|
};
|
|
168
190
|
}
|
|
169
|
-
/**
|
|
191
|
+
/**
|
|
192
|
+
* Simple in-isolate cache for dev / a single Worker isolate (not shared across isolates).
|
|
193
|
+
*
|
|
194
|
+
* BOUNDED ON PURPOSE. Expiry alone is not a bound: entries expire lazily, on a `get()` for that
|
|
195
|
+
* exact key, so an attacker who uses each token once never triggers a sweep and every verdict is
|
|
196
|
+
* retained until the isolate dies. Since a *negative* verdict is cached for a token anyone can mint
|
|
197
|
+
* (correct `aud`/`iss`/`exp` need no signing key), an unbounded map is a remote OOM. Hence: sweep on
|
|
198
|
+
* insert, and hard-cap with FIFO eviction. `maxEntries` is generous — a real portal's working set is
|
|
199
|
+
* its live sessions, far below the cap, so eviction only ever bites the pathological case.
|
|
200
|
+
*/
|
|
170
201
|
export class MemoryVerdictCache {
|
|
202
|
+
maxEntries;
|
|
171
203
|
store = new Map();
|
|
204
|
+
constructor(maxEntries = 1000) {
|
|
205
|
+
this.maxEntries = maxEntries;
|
|
206
|
+
}
|
|
172
207
|
async get(key) {
|
|
173
208
|
const hit = this.store.get(key);
|
|
174
209
|
if (!hit)
|
|
@@ -180,12 +215,32 @@ export class MemoryVerdictCache {
|
|
|
180
215
|
return hit.verdict;
|
|
181
216
|
}
|
|
182
217
|
async set(key, verdict, ttlSeconds) {
|
|
183
|
-
|
|
218
|
+
const now = Date.now();
|
|
219
|
+
for (const [k, v] of this.store)
|
|
220
|
+
if (v.expiresAtMs <= now)
|
|
221
|
+
this.store.delete(k);
|
|
222
|
+
this.store.delete(key); // re-insert so Map iteration order == insertion order == eviction order
|
|
223
|
+
this.store.set(key, { verdict, expiresAtMs: now + ttlSeconds * 1000 });
|
|
224
|
+
while (this.store.size > this.maxEntries) {
|
|
225
|
+
const oldest = this.store.keys().next();
|
|
226
|
+
if (oldest.done)
|
|
227
|
+
break;
|
|
228
|
+
this.store.delete(oldest.value);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
/** Live entry count. Exposed for tests/observability; not part of the VerdictCache contract. */
|
|
232
|
+
get size() {
|
|
233
|
+
return this.store.size;
|
|
184
234
|
}
|
|
185
235
|
}
|
|
186
236
|
/** SHA-256 hex of the token — cache key that never stores the raw token. */
|
|
187
|
-
export async function tokenKey(token) {
|
|
188
|
-
|
|
237
|
+
export async function tokenKey(token, server) {
|
|
238
|
+
// Scope the key by `server` when given: a consumer that fronts two NS cores with ONE cache (a
|
|
239
|
+
// reseller tool, or prod+staging sharing a KV namespace) would otherwise serve a token validated
|
|
240
|
+
// against server A as ok:true for a request bound to server B — B never contacted. verify() passes
|
|
241
|
+
// its server; the NUL separator can't appear in a hostname, so the two fields can't collide.
|
|
242
|
+
const material = server ? `${server}\u0000${normalizeToken(token)}` : normalizeToken(token);
|
|
243
|
+
const data = new TextEncoder().encode(material);
|
|
189
244
|
const digest = await crypto.subtle.digest('SHA-256', data);
|
|
190
245
|
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
191
246
|
}
|
|
@@ -209,8 +264,8 @@ export async function verify(token, opts) {
|
|
|
209
264
|
payload: fmt.payload,
|
|
210
265
|
checkedAt,
|
|
211
266
|
};
|
|
212
|
-
// Local gate: bad format or
|
|
213
|
-
if (!fmt.validFormat || !fmt.unexpired)
|
|
267
|
+
// Local gate: bad format, expired, or not-yet-valid (nbf) ⇒ reject without a roundtrip.
|
|
268
|
+
if (!fmt.validFormat || !fmt.unexpired || fmt.notYetValid)
|
|
214
269
|
return base;
|
|
215
270
|
// Claim assertions (always, no key needed): aud must be "ns"; iss must match unless opted out.
|
|
216
271
|
const claims = assertClaims(fmt.payload ?? {}, { aud: opts.expectedAud, iss: opts.expectedIss, validateIss: opts.validateIss });
|
|
@@ -235,8 +290,19 @@ export async function verify(token, opts) {
|
|
|
235
290
|
? { ...withSig, live: 'skipped', ok: true }
|
|
236
291
|
: { ...withSig, live: 'skipped', ok: false, reason: 'Signature not verified (format mode without signingSecret)' };
|
|
237
292
|
}
|
|
293
|
+
// Validate the server host BEFORE it reaches the URL: a caller that derives `server` from request
|
|
294
|
+
// input would otherwise let `host@evil` / `host#…` redirect the Bearer token off-origin. Fail
|
|
295
|
+
// closed (uncached error) rather than throw, so a misconfigured server can't crash the caller.
|
|
296
|
+
let safeServer;
|
|
297
|
+
try {
|
|
298
|
+
safeServer = assertBareServer(opts.server);
|
|
299
|
+
}
|
|
300
|
+
catch (err) {
|
|
301
|
+
return { ...withSig, live: 'error', ok: false, reason: err.message };
|
|
302
|
+
}
|
|
238
303
|
// Live mode — consult cache first (unless force-fresh: writes/sensitive reads always re-check).
|
|
239
|
-
|
|
304
|
+
// Key is scoped by server: one cache fronting two NS cores must not cross-serve verdicts.
|
|
305
|
+
const key = await tokenKey(token, safeServer);
|
|
240
306
|
if (opts.cache && !opts.forceFresh) {
|
|
241
307
|
const cached = await opts.cache.get(key);
|
|
242
308
|
if (cached)
|
|
@@ -250,7 +316,7 @@ export async function verify(token, opts) {
|
|
|
250
316
|
const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? 4000);
|
|
251
317
|
let verdict;
|
|
252
318
|
try {
|
|
253
|
-
const res = await doFetch(`https://${
|
|
319
|
+
const res = await doFetch(`https://${safeServer}/ns-api/v2/jwt`, {
|
|
254
320
|
method: 'GET',
|
|
255
321
|
headers: { Authorization: `Bearer ${normalizeToken(token)}` },
|
|
256
322
|
redirect: 'manual',
|
|
@@ -287,4 +353,3 @@ export async function verify(token, opts) {
|
|
|
287
353
|
}
|
|
288
354
|
return verdict;
|
|
289
355
|
}
|
|
290
|
-
//# sourceMappingURL=jwt.js.map
|
package/dist/mermaid.d.ts
CHANGED
|
@@ -4,11 +4,10 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import type { FlowGraph } from './model.js';
|
|
6
6
|
/** Diagram theme. `dark` is the original palette (Cloudflare Worker / live-chart use);
|
|
7
|
-
* `light` matches
|
|
7
|
+
* `light` matches a light review report. */
|
|
8
8
|
export type FlowTheme = 'light' | 'dark';
|
|
9
9
|
export interface MermaidOptions {
|
|
10
10
|
/** Emit a themed diagram. OMIT for byte-identical legacy output (the Worker relies on this). */
|
|
11
11
|
theme?: FlowTheme;
|
|
12
12
|
}
|
|
13
13
|
export declare function toMermaid(g: FlowGraph, opts?: MermaidOptions): string;
|
|
14
|
-
//# sourceMappingURL=mermaid.d.ts.map
|
package/dist/mermaid.js
CHANGED
|
Binary file
|
package/dist/model.d.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* consumes it. This is the "real IP" contract from the handoff: a normalized graph JSON.
|
|
5
5
|
*
|
|
6
6
|
* Runtime-portable by design: no Node-only imports here or in resolver.ts, so the same
|
|
7
|
-
* code can run in
|
|
7
|
+
* code can run in an onboarding CLI and in a Cloudflare Worker.
|
|
8
8
|
*/
|
|
9
9
|
export type NodeKind = 'did' | 'timeframe' | 'user' | 'devices' | 'queue' | 'agents' | 'attendant' | 'prompt' | 'voicemail' | 'external' | 'trunk' | 'hangup' | 'unknown';
|
|
10
10
|
export type EdgeKind = 'route' | 'time' | 'always' | 'noanswer' | 'busy' | 'unreg' | 'dnd' | 'dispatch' | 'overflow' | 'menu' | 'ref';
|
|
@@ -62,7 +62,7 @@ export interface Snapshot {
|
|
|
62
62
|
*
|
|
63
63
|
* Two shapes are accepted:
|
|
64
64
|
* - `attendantDetails[ext]` — a single detail (current live fetch; SV builds AAs on `*`).
|
|
65
|
-
* - `attendantDetailsByUser[ext]` — an ARRAY of details (
|
|
65
|
+
* - `attendantDetailsByUser[ext]` — an ARRAY of details (an enriched backup: an AA may
|
|
66
66
|
* have multiple prompts/timeframes). The resolver picks the `*`/Default one as primary and
|
|
67
67
|
* flags the rest as a deviation (see the AA backup enrichment spec, Addendum 2026-07-11).
|
|
68
68
|
*/
|
|
@@ -72,9 +72,8 @@ export interface Snapshot {
|
|
|
72
72
|
* Per-AA dialplan dialrules, keyed by AA extension — the AUTHORITATIVE menu + default routing that
|
|
73
73
|
* the /autoattendants detail omits (no-key/star/option). From GET /domains/{d}/dialplans/{domain}_{ext}/dialrules.
|
|
74
74
|
* The resolver reads `Prompt_<startingPrompt-id>.<suffix>` rules: .Default (no-key/timeout), .* (unassigned),
|
|
75
|
-
* .<digit> (press N), .Case_[...] (dial-by-ext). See
|
|
75
|
+
* .<digit> (press N), .Case_[...] (dial-by-ext). See ARCHITECTURE.md → NetSapiens routing model.
|
|
76
76
|
*/
|
|
77
77
|
attendantDialrulesByExt?: Record<string, Rec[]>;
|
|
78
78
|
[k: string]: any;
|
|
79
79
|
}
|
|
80
|
-
//# sourceMappingURL=model.d.ts.map
|
package/dist/model.js
CHANGED
|
@@ -4,7 +4,6 @@
|
|
|
4
4
|
* consumes it. This is the "real IP" contract from the handoff: a normalized graph JSON.
|
|
5
5
|
*
|
|
6
6
|
* Runtime-portable by design: no Node-only imports here or in resolver.ts, so the same
|
|
7
|
-
* code can run in
|
|
7
|
+
* code can run in an onboarding CLI and in a Cloudflare Worker.
|
|
8
8
|
*/
|
|
9
9
|
export {};
|
|
10
|
-
//# sourceMappingURL=model.js.map
|
package/dist/nsClient.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Portable NetSapiens API read client — the seed of the eventual "NS API for Worker/Node"
|
|
3
|
-
* library
|
|
3
|
+
* library. Ported from an internal onboarding tool (its API client
|
|
4
4
|
* NsClient + `src/backup/snapshot.ts` backupDomain), trimmed to the READ-ONLY routing subset the
|
|
5
5
|
* resolver needs, and kept Node-free (fetch/URL only) so it runs in a Cloudflare Worker unchanged.
|
|
6
6
|
*
|
|
@@ -32,10 +32,17 @@ export interface NsClientConfig {
|
|
|
32
32
|
* mutating method here. Any write capability must be a separate, explicitly-reviewed client, not a
|
|
33
33
|
* quiet addition to this one. This is the single choke point every NS call in the Worker flows through.
|
|
34
34
|
*/
|
|
35
|
+
/**
|
|
36
|
+
* Reject a `server` that isn't a bare host or `host:port`. A caller that derives `server` from
|
|
37
|
+
* request input (a multi-tenant tool) would otherwise let `api.example.com@evil.example` or
|
|
38
|
+
* `evil.example#…` redirect the Bearer token to another origin — `new URL('https://'+server).host`
|
|
39
|
+
* is what actually gets contacted, not the string. Comparing the parsed host back to the input
|
|
40
|
+
* catches an embedded `@`, `/path`, `?query`, `#frag`, or scheme; credentials are refused explicitly.
|
|
41
|
+
* Host comparison is case-insensitive (URL lowercases the host; NS hostnames are case-insensitive).
|
|
42
|
+
*/
|
|
43
|
+
export declare function assertBareServer(server: string): string;
|
|
35
44
|
export declare class NsClient {
|
|
36
|
-
private
|
|
37
|
-
private readonly token;
|
|
38
|
-
private readonly fetchImpl;
|
|
45
|
+
#private;
|
|
39
46
|
constructor(cfg: NsClientConfig);
|
|
40
47
|
get<T = unknown>(path: string, query?: Record<string, string | number>): Promise<T>;
|
|
41
48
|
}
|
|
@@ -76,4 +83,3 @@ export interface FetchSnapshotOptions {
|
|
|
76
83
|
* whole flow — the resolver tolerates gaps. A failing top-level read (e.g. 401) DOES throw.
|
|
77
84
|
*/
|
|
78
85
|
export declare function fetchDomainSnapshot(client: NsClient, domain: string, opts?: FetchSnapshotOptions): Promise<Snapshot>;
|
|
79
|
-
//# sourceMappingURL=nsClient.d.ts.map
|
package/dist/nsClient.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Portable NetSapiens API read client — the seed of the eventual "NS API for Worker/Node"
|
|
3
|
-
* library
|
|
3
|
+
* library. Ported from an internal onboarding tool (its API client
|
|
4
4
|
* NsClient + `src/backup/snapshot.ts` backupDomain), trimmed to the READ-ONLY routing subset the
|
|
5
5
|
* resolver needs, and kept Node-free (fetch/URL only) so it runs in a Cloudflare Worker unchanged.
|
|
6
6
|
*
|
|
@@ -29,25 +29,47 @@ export class NsApiError extends Error {
|
|
|
29
29
|
* mutating method here. Any write capability must be a separate, explicitly-reviewed client, not a
|
|
30
30
|
* quiet addition to this one. This is the single choke point every NS call in the Worker flows through.
|
|
31
31
|
*/
|
|
32
|
+
/**
|
|
33
|
+
* Reject a `server` that isn't a bare host or `host:port`. A caller that derives `server` from
|
|
34
|
+
* request input (a multi-tenant tool) would otherwise let `api.example.com@evil.example` or
|
|
35
|
+
* `evil.example#…` redirect the Bearer token to another origin — `new URL('https://'+server).host`
|
|
36
|
+
* is what actually gets contacted, not the string. Comparing the parsed host back to the input
|
|
37
|
+
* catches an embedded `@`, `/path`, `?query`, `#frag`, or scheme; credentials are refused explicitly.
|
|
38
|
+
* Host comparison is case-insensitive (URL lowercases the host; NS hostnames are case-insensitive).
|
|
39
|
+
*/
|
|
40
|
+
export function assertBareServer(server) {
|
|
41
|
+
const s = String(server ?? '').trim().replace(/\/+$/, '');
|
|
42
|
+
let u;
|
|
43
|
+
try {
|
|
44
|
+
u = new URL(`https://${s}`);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
throw new Error(`Invalid NS server "${server}": expected a bare host or host:port`);
|
|
48
|
+
}
|
|
49
|
+
if (u.host !== s.toLowerCase() || u.username || u.password) {
|
|
50
|
+
throw new Error(`Invalid NS server "${server}": expected a bare host or host:port (no scheme, path, query, fragment, or credentials)`);
|
|
51
|
+
}
|
|
52
|
+
return u.host;
|
|
53
|
+
}
|
|
32
54
|
export class NsClient {
|
|
33
|
-
baseUrl;
|
|
34
|
-
token;
|
|
35
|
-
fetchImpl;
|
|
55
|
+
#baseUrl;
|
|
56
|
+
#token;
|
|
57
|
+
#fetchImpl;
|
|
36
58
|
constructor(cfg) {
|
|
37
|
-
this
|
|
38
|
-
this
|
|
39
|
-
this
|
|
59
|
+
this.#baseUrl = `https://${assertBareServer(cfg.server)}/ns-api/v2`;
|
|
60
|
+
this.#token = cfg.token;
|
|
61
|
+
this.#fetchImpl = cfg.fetchImpl ?? fetch;
|
|
40
62
|
}
|
|
41
63
|
async get(path, query) {
|
|
42
|
-
const url = new URL(this
|
|
64
|
+
const url = new URL(this.#baseUrl + path);
|
|
43
65
|
for (const [k, v] of Object.entries(query ?? {}))
|
|
44
66
|
url.searchParams.set(k, String(v));
|
|
45
|
-
// Call via a local, NOT `this
|
|
67
|
+
// Call via a local, NOT `this.#fetchImpl(...)`: invoking the global fetch as a method of this
|
|
46
68
|
// instance throws "Illegal invocation" in workerd (the global fetch requires a global `this`).
|
|
47
|
-
const doFetch = this
|
|
69
|
+
const doFetch = this.#fetchImpl;
|
|
48
70
|
const res = await doFetch(url.toString(), {
|
|
49
71
|
method: 'GET',
|
|
50
|
-
headers: { Authorization: `Bearer ${this
|
|
72
|
+
headers: { Authorization: `Bearer ${this.#token}`, Accept: 'application/json' },
|
|
51
73
|
});
|
|
52
74
|
const text = await res.text();
|
|
53
75
|
let parsed = text;
|
|
@@ -60,7 +82,10 @@ export class NsClient {
|
|
|
60
82
|
}
|
|
61
83
|
}
|
|
62
84
|
if (!res.ok) {
|
|
63
|
-
|
|
85
|
+
// Truncate BOTH branches to 500 chars: the object branch used to JSON.stringify the whole
|
|
86
|
+
// upstream body unbounded, so a consumer that logs err.message could log an arbitrarily large
|
|
87
|
+
// NS response. No credential is ever in it, but size alone is a footgun.
|
|
88
|
+
const detail = (typeof parsed === 'object' && parsed !== null ? JSON.stringify(parsed) : String(parsed)).slice(0, 500);
|
|
64
89
|
const hint = res.status === 401 ? ' (token expired/invalid or domain out of scope)' : res.status === 403 ? ' (token lacks permission)' : '';
|
|
65
90
|
throw new NsApiError(`GET ${path} → ${res.status}${hint}: ${detail}`, res.status, path, parsed);
|
|
66
91
|
}
|
|
@@ -163,7 +188,7 @@ export async function fetchDomainSnapshot(client, domain, opts = {}) {
|
|
|
163
188
|
// One list row per (user, prompt); an AA may have several (multi-timeframe). Collect ALL detail
|
|
164
189
|
// records per user (array) so the resolver can flag multi-prompt deviations, not just last-wins.
|
|
165
190
|
// Also fetch each AA's OWN dialplan dialrules ({domain}_{ext}) — the authoritative menu/default
|
|
166
|
-
// routing the /autoattendants detail omits (no-key/star/option). See
|
|
191
|
+
// routing the /autoattendants detail omits (no-key/star/option). See ARCHITECTURE.md → NetSapiens routing model.
|
|
167
192
|
let attendantDetailsByUser;
|
|
168
193
|
let attendantDialrulesByExt;
|
|
169
194
|
if (opts.includeAttendantMenus ?? true) {
|
|
@@ -202,4 +227,3 @@ export async function fetchDomainSnapshot(client, domain, opts = {}) {
|
|
|
202
227
|
...(dialrulesByPlan ? { dialrulesByPlan } : {}),
|
|
203
228
|
};
|
|
204
229
|
}
|
|
205
|
-
//# sourceMappingURL=nsClient.js.map
|
package/dist/policy.d.ts
CHANGED
|
@@ -46,4 +46,3 @@ export declare function ruleMatches(p: Principal, rule: PolicyRule): boolean;
|
|
|
46
46
|
export declare function isAllowed(p: Principal, policy: Policy | undefined): boolean;
|
|
47
47
|
/** Check a named feature against a registry. Unknown feature ⇒ deny (fail closed). */
|
|
48
48
|
export declare function can(p: Principal, feature: string, policies: FeaturePolicies): boolean;
|
|
49
|
-
//# sourceMappingURL=policy.d.ts.map
|
package/dist/policy.js
CHANGED
|
@@ -3,6 +3,19 @@ const inList = (value, list) => {
|
|
|
3
3
|
const v = lc(value);
|
|
4
4
|
return list.some((x) => lc(x) === v);
|
|
5
5
|
};
|
|
6
|
+
/** Collapse the interchangeable Super User spellings a NetSapiens core may emit ("Super User",
|
|
7
|
+
* "superuser", "super-user") to one canonical token. None of these is a valid OTHER scope, so this
|
|
8
|
+
* only ever unifies synonyms — a policy written with any one spelling matches a token carrying
|
|
9
|
+
* another, closing a fail-closed lockout where e.g. `user_scope: "superuser"` was denied at a rule
|
|
10
|
+
* listing `"Super User"`. */
|
|
11
|
+
const canonScope = (s) => {
|
|
12
|
+
const v = lc(s);
|
|
13
|
+
return v === 'superuser' || v === 'super-user' || v === 'super user' ? 'super user' : v;
|
|
14
|
+
};
|
|
15
|
+
const scopeInList = (value, list) => {
|
|
16
|
+
const v = canonScope(value);
|
|
17
|
+
return list.some((x) => canonScope(x) === v);
|
|
18
|
+
};
|
|
6
19
|
/** Does the principal satisfy every condition in this single rule? */
|
|
7
20
|
export function ruleMatches(p, rule) {
|
|
8
21
|
// A rule with NO matchable condition (e.g. `{}` or only `description`) is NOT allow-all — that would
|
|
@@ -14,7 +27,7 @@ export function ruleMatches(p, rule) {
|
|
|
14
27
|
rule.masking !== undefined;
|
|
15
28
|
if (!hasCondition)
|
|
16
29
|
return false;
|
|
17
|
-
if (rule.scopes && !
|
|
30
|
+
if (rule.scopes && !scopeInList(p.scope, rule.scopes))
|
|
18
31
|
return false;
|
|
19
32
|
if (rule.domains && !(rule.domains.includes('*') || inList(p.domain, rule.domains)))
|
|
20
33
|
return false;
|
|
@@ -36,4 +49,3 @@ export function isAllowed(p, policy) {
|
|
|
36
49
|
export function can(p, feature, policies) {
|
|
37
50
|
return isAllowed(p, policies[feature]);
|
|
38
51
|
}
|
|
39
|
-
//# sourceMappingURL=policy.js.map
|
package/dist/principal.d.ts
CHANGED
|
@@ -49,4 +49,3 @@ export declare function isAdminScope(scope: string | undefined): boolean;
|
|
|
49
49
|
export declare function parseOperator(maskChain: string | undefined): Operator | null;
|
|
50
50
|
/** Build a Principal from decoded ns_t context (a JwtContext / JwtVerdict — both carry the claims). */
|
|
51
51
|
export declare function toPrincipal(ctx: JwtContext): Principal;
|
|
52
|
-
//# sourceMappingURL=principal.d.ts.map
|
package/dist/principal.js
CHANGED
package/dist/raster.d.ts
CHANGED
package/dist/raster.js
CHANGED
package/dist/resolver.d.ts
CHANGED
package/dist/resolver.js
CHANGED
|
@@ -37,7 +37,7 @@ const trim = (v, max = GREET_MAX) => (v.length > max ? `${v.slice(0, max - 1).tr
|
|
|
37
37
|
* wp → SNAPmobile Web (browser phone) · t → Microsoft Teams · m → SNAPmobile (mobile) ·
|
|
38
38
|
* r → mobile/desktop app · b / other lower letters → usually a desk phone.
|
|
39
39
|
* Exact device info (model, MAC, transport) IS available via the device API but isn't pulled yet —
|
|
40
|
-
* see
|
|
40
|
+
* see ARCHITECTURE.md → NetSapiens routing model; this suffix guess is the cheap approximation.
|
|
41
41
|
*/
|
|
42
42
|
function deviceKindBySuffix(suffix) {
|
|
43
43
|
switch (suffix.toLowerCase()) {
|
|
@@ -907,7 +907,7 @@ function ensureAttendant(ext, idx, b) {
|
|
|
907
907
|
const greet = s(detail?.audio?.['file-script-text']);
|
|
908
908
|
// SV builds AAs on the always-available `*` timeframe; a specific timeframe is unusual. Show it
|
|
909
909
|
// plainly on the node (clear display) rather than as a loud warning — the loud validation belongs
|
|
910
|
-
// in the
|
|
910
|
+
// in the backup path, not the viewer.
|
|
911
911
|
const aaTf = s(detail?.['time-frame']);
|
|
912
912
|
const tfTag = aaTf && aaTf !== '*' ? ` · timeframe ${aaTf}` : '';
|
|
913
913
|
b.node(id, 'attendant', `🔀 Auto Attendant ${ext}${nm ? ` · ${nm}` : ''}`, (greet ? `“${trim(greet)}”` : 'plays menu') + tfTag, undefined, greet.length > GREET_MAX ? greet : undefined);
|
|
@@ -1089,4 +1089,3 @@ export function listEntities(snap) {
|
|
|
1089
1089
|
attendants: (snap.autoattendants ?? []).map((a) => ({ ref: s(a.user), label: s(a['attendant-name']) })),
|
|
1090
1090
|
};
|
|
1091
1091
|
}
|
|
1092
|
-
//# sourceMappingURL=resolver.js.map
|
package/dist/sensitivity.d.ts
CHANGED
package/dist/sensitivity.js
CHANGED
|
@@ -27,4 +27,3 @@ export function needsFreshAuth(sensitivity) {
|
|
|
27
27
|
export const SENSITIVITY_NOTE = 'Every portal route MUST declare `sensitivity` (read | sensitive | write). write/sensitive ⇒ verify ' +
|
|
28
28
|
'with forceFresh (bypass the JWT verdict cache) so logout/revocation is caught immediately; read ⇒ ' +
|
|
29
29
|
'cache-fronted verify. A missing classification is a compile error via `satisfies Record<string, RouteAuth>`.';
|
|
30
|
-
//# sourceMappingURL=sensitivity.js.map
|
package/dist/themes.d.ts
CHANGED
|
@@ -68,4 +68,3 @@ export declare const THEMES: Record<string, ThemeDef>;
|
|
|
68
68
|
/** System-auto defaults (host picks by `prefers-color-scheme` on first load). */
|
|
69
69
|
export declare const DEFAULT_LIGHT_THEME = "light-neo";
|
|
70
70
|
export declare const DEFAULT_DARK_THEME = "slate-dark";
|
|
71
|
-
//# sourceMappingURL=themes.d.ts.map
|
package/dist/themes.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dszp/netsapiens-lib",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
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",
|
|
@@ -27,7 +27,10 @@
|
|
|
27
27
|
"exports": {
|
|
28
28
|
".": {
|
|
29
29
|
"types": "./dist/index.d.ts",
|
|
30
|
-
"import": "./dist/index.js"
|
|
30
|
+
"import": "./dist/index.js",
|
|
31
|
+
"//require": "ESM-only package. This `require` condition lets Node >=22.12 load it via require(esm); older Node still gets an accurate ERR_REQUIRE_ESM here instead of the confusing ERR_PACKAGE_PATH_NOT_EXPORTED the bare export map produced.",
|
|
32
|
+
"require": "./dist/index.js",
|
|
33
|
+
"default": "./dist/index.js"
|
|
31
34
|
},
|
|
32
35
|
"./package.json": "./package.json"
|
|
33
36
|
},
|
|
@@ -44,7 +47,8 @@
|
|
|
44
47
|
"scripts": {
|
|
45
48
|
"build": "tsc -p tsconfig.json",
|
|
46
49
|
"build:watch": "tsc -p tsconfig.json --watch",
|
|
47
|
-
"prepublishOnly": "tsc
|
|
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
|
+
"prepublishOnly": "tsc -p tsconfig.json --sourceMap false --declarationMap false",
|
|
48
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.",
|
|
49
53
|
"test": "pnpm run test:jwt && pnpm run test:principal && pnpm run test:resolver && pnpm run test:raster",
|
|
50
54
|
"test:jwt": "tsx src/jwt.selftest.ts",
|