@dszp/netsapiens-lib 0.1.0 → 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 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
- Three capabilities, one dependency-free package:
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
- * ns-onboard's review page / build-preview. Mermaid renders client-side from a CDN.
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 (hex) for subtle highlights — links, the "Legend:" lead, the card's top rule, and
27
- * the pan/zoom controls' hover. Themed path only; defaults to the theme's link color. Pass your
28
- * own brand color here (from your host's config) rather than baking one into a theme, e.g.
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. ns-onboard's review report). `securityLevel: 'strict'` is MANDATORY — it is the
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
- * ns-onboard's review page / build-preview. Mermaid renders client-side from a CDN.
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).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
47
+ return String(t)
48
+ .replace(/&/g, '&amp;')
49
+ .replace(/</g, '&lt;')
50
+ .replace(/>/g, '&gt;')
51
+ .replace(/"/g, '&quot;')
52
+ .replace(/'/g, '&#39;');
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
- return `flow-${g.entity.kind}-${String(g.entity.ref).replace(/[^A-Za-z0-9_-]/g, '-')}`;
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. ns-onboard's review report). `securityLevel: 'strict'` is MANDATORY — it is the
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 ns-onboard, or explicit dark-neo). No theme → the original dark
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 (ns-onboard review context) or explicit dark-neo. Parallel to
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 || c.link;
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, ns-onboard CLI / review page / build-preview, the portal viewer). Everything
4
- * re-exported here is Node-free and runtime-portable. The Node-only CLI (`cli.ts`) is NOT
5
- * part of this surface.
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, ns-onboard CLI / review page / build-preview, the portal viewer). Everything
4
- * re-exported here is Node-free and runtime-portable. The Node-only CLI (`cli.ts`) is NOT
5
- * part of this surface.
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 the ns-onboard CLI.
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
- /** Simple in-isolate cache for dev / a single Worker isolate (not shared across isolates). */
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 the ns-onboard CLI.
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
- /** Simple in-isolate cache for dev / a single Worker isolate (not shared across isolates). */
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
- this.store.set(key, { verdict, expiresAtMs: Date.now() + ttlSeconds * 1000 });
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
- const data = new TextEncoder().encode(normalizeToken(token));
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 expired ⇒ reject without a roundtrip.
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
- const key = await tokenKey(token);
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://${opts.server}/ns-api/v2/jwt`, {
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 ns-onboard's light review report. */
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 the ns-onboard CLI and in a Cloudflare Worker.
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 (ns-onboard enriched backup: an AA may
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 CLAUDE.md → NetSapiens API notes.
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 the ns-onboard CLI and in a Cloudflare Worker.
7
+ * code can run in an onboarding CLI and in a Cloudflare Worker.
8
8
  */
9
9
  export {};
10
- //# sourceMappingURL=model.js.map
@@ -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 (see CLAUDE.md North star). Ported from NetSapiens-Onboarding-Backup (`src/api/client.ts`
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 readonly baseUrl;
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 (see CLAUDE.md North star). Ported from NetSapiens-Onboarding-Backup (`src/api/client.ts`
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.baseUrl = `https://${cfg.server.replace(/\/+$/, '')}/ns-api/v2`;
38
- this.token = cfg.token;
39
- this.fetchImpl = cfg.fetchImpl ?? fetch;
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.baseUrl + path);
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.fetchImpl(...)`: invoking the global fetch as a method of 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.fetchImpl;
69
+ const doFetch = this.#fetchImpl;
48
70
  const res = await doFetch(url.toString(), {
49
71
  method: 'GET',
50
- headers: { Authorization: `Bearer ${this.token}`, Accept: 'application/json' },
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
- const detail = typeof parsed === 'object' && parsed !== null ? JSON.stringify(parsed) : String(parsed).slice(0, 500);
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 CLAUDE.md → API notes.
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 && !inList(p.scope, 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
@@ -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
@@ -42,4 +42,3 @@ export function toPrincipal(ctx) {
42
42
  operator,
43
43
  };
44
44
  }
45
- //# sourceMappingURL=principal.js.map
package/dist/raster.d.ts CHANGED
@@ -18,4 +18,3 @@ export declare function resolveSvgSize(svgString: string): {
18
18
  * (no eval/new Function; uses a data: image URL — ensure any host CSP allows `img-src data:`).
19
19
  */
20
20
  export declare function rasterizerScript(): string;
21
- //# sourceMappingURL=raster.d.ts.map
package/dist/raster.js CHANGED
@@ -78,4 +78,3 @@ async function svgToPngBlob(svg, opts) {
78
78
  }
79
79
  `.trim();
80
80
  }
81
- //# sourceMappingURL=raster.js.map
@@ -55,4 +55,3 @@ export declare function listEntities(snap: Snapshot): {
55
55
  label: string;
56
56
  }[];
57
57
  };
58
- //# sourceMappingURL=resolver.d.ts.map
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 CLAUDE.md → API notes; this suffix guess is the cheap approximation.
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 ns-onboard/backup path, not the viewer.
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
@@ -29,4 +29,3 @@ export interface RouteAuth {
29
29
  }
30
30
  /** Human note surfaced in docs/registries so the rule is visible where routes are defined. */
31
31
  export declare const SENSITIVITY_NOTE: string;
32
- //# sourceMappingURL=sensitivity.d.ts.map
@@ -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
@@ -101,4 +101,3 @@ export const THEMES = {
101
101
  /** System-auto defaults (host picks by `prefers-color-scheme` on first load). */
102
102
  export const DEFAULT_LIGHT_THEME = 'light-neo';
103
103
  export const DEFAULT_DARK_THEME = 'slate-dark';
104
- //# sourceMappingURL=themes.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dszp/netsapiens-lib",
3
- "version": "0.1.0",
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,8 +27,12 @@
27
27
  "exports": {
28
28
  ".": {
29
29
  "types": "./dist/index.d.ts",
30
- "import": "./dist/index.js"
31
- }
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"
34
+ },
35
+ "./package.json": "./package.json"
32
36
  },
33
37
  "types": "./dist/index.d.ts",
34
38
  "module": "./dist/index.js",
@@ -43,7 +47,8 @@
43
47
  "scripts": {
44
48
  "build": "tsc -p tsconfig.json",
45
49
  "build:watch": "tsc -p tsconfig.json --watch",
46
- "prepublishOnly": "tsc -p tsconfig.json",
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",
47
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.",
48
53
  "test": "pnpm run test:jwt && pnpm run test:principal && pnpm run test:resolver && pnpm run test:raster",
49
54
  "test:jwt": "tsx src/jwt.selftest.ts",