feat(shared): add a browser-safe @finos/calm-shared/browser entry point - #3022
Conversation
…owser graph never imports it
…he loader interface module
… and url strategies
…s out of the browser graph
…exports subpaths type-resolve module and moduleResolution were set at the top level of cli/tsconfig.json, outside compilerOptions, so TypeScript silently ignored them and fell back to the base config's Node resolution, which is not exports-aware. This broke type resolution for package.json "exports" subpaths such as @finos/calm-shared/browser and @finos/calm-models/types.
… pin SSRF ip-literal cases The guard's runtime probe ran under Node, so process/Buffer globals were still visible even though builtin module requests were stubbed; a stray process.cwd() or Buffer.from() anywhere in the browser graph would pass the guard and only throw in a real browser. Define process and Buffer as undefined for the probe bundle so it sees browser semantics. Also pin several SSRF-relevant host shapes (IPv4-mapped IPv6, zone IDs, leading zeros, trailing colons, embedded IPv4 tails) in ipLiteralVersion's test table, each confirmed against net.isIP. Also tighten the guard's shared/src importer check to require a path separator after the prefix, and document why the probe bundle's builtin requests are intentionally not re-checked.
…mports and specs Move ResourceType, RESOURCE_TYPES and isValidResourceType out of calm-hub-client.ts (which imports axios) into a new resource-types.ts, and have document-id-utils.ts import from there instead. calm-hub-client.ts re-exports the moved names so its public surface and the root barrel are unchanged. This keeps the axios-based hub client out of the browser bundle's graph, since document-id-utils.ts is imported by browser.ts. Add the missing .js suffixes to generate-core.ts's local imports for consistency with its other imports. Move the unmocked 'generate core' describe block out of generate.spec.ts into a new generate-core.spec.ts that imports generate-core directly and never mocks anything, so it needs no vi.doUnmock/resetModules dance that only worked because it ran last in the file.
…ld assumption Comment above the registerNodeLoggerFactory/registerOutputFormatter calls in index.ts explaining they are load-bearing side effects, so "sideEffects": false must never be added to shared/package.json. Note in InMemoryDocumentLoader's doc comment that it returns documents by reference rather than cloning them, unlike FileSystemDocumentLoader, which re-parses from disk on every load. Note in the README that the browser entry guard's allowlist assumes bundlers resolve with the browser main field first, and that a node/SSR bundle target will see more builtins than the allowlist covers.
…ral check ipLiteralVersion() rejected IPv6 zone identifiers (e.g. fe80::1%eth0), diverging from net.isIP's contract, which classifies them as version 6. Split off a %zone suffix before classification: the zone must be non-empty and match Node's accepted zone charset, and the part before % must classify as IPv6 on its own (an IPv4 address with a zone id, e.g. 1.2.3.4%eth0, is never an IP literal, matching net.isIP). Pinned test rows now match net.isIP's actual output for all zone-id shapes checked (fe80::1%eth0 -> 6, fe80::1% -> 0, 1.2.3.4%eth0 -> 0, ::1%25 -> 6, fe80::1%eth0%x -> 0) instead of the implementation's previous (incorrect) behaviour.
…ays out of the browser graph Mirrors the generate-core/generate and diff-core/diff seam: validate.ts kept exitBasedOffOfValidationOutcome (three process.exit calls) inside the module graph reachable from browser.ts. Moving validate() and its helpers into validate-core.ts lets browser.ts import the pure core directly, dropping exitBasedOffOfValidationOutcome out of the browser bundle entirely.
…harden the allowlist check - browser-probe.ts now exercises generate() and diffDocuments() through the browser entry, not just validate(), so process.exit-free core logic outside validate is also guarded. - check-browser-entry.mjs normalises importer path separators before matching against ALLOWED (so the guard behaves the same on Windows) and reports any ALLOWED entry that matched nothing, so the allowlist can't silently rot. - browser-capabilities.spec.ts asserts the unsupported-command list is non-empty before looping over it, and its doc comment states the drift-test's exact granularity.
…eral check The IPV4_TAIL regex is unanchored at the start, so it could partial-match into the middle of a hextet (e.g. matching "1.2.3.4" inside "a1.2.3.4") whenever any colon appeared earlier in the string, wrongly classifying strings like "db8::a1.2.3.4" as valid IPv6. A mapped tail is now only accepted when it is immediately preceded by ':' (or starts the string), matching net.isIP.
…rift-test granularity hub-commands.spec.ts mocked @finos/calm-shared via three separately-named aliases (documentIdUtils/semver/canonical) that all pointed at the same importActual barrel; collapsed to a single actual spread, with a comment noting the vi.fn(...) overrides must come after it. browser-manifest.spec.ts now documents exactly what granularity the drift check operates at: top-level commands plus the hub subgroups, with workspace subcommands covered by the single workspace entry.
There was a problem hiding this comment.
Pull request overview
Adds a browser-safe @finos/calm-shared/browser entry point while isolating Node-specific functionality from pure CALM engines.
Changes:
- Adds browser-safe validation, generation, diffing, loaders, and capability metadata.
- Separates Node filesystem, logging, and output integrations.
- Adds package exports, browser guards, tests, documentation, and CLI updates.
Reviewed changes
Copilot reviewed 60 out of 61 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Review result |
|---|---|
shared/vitest.config.ts |
No final finding. |
shared/src/util/ip-literal.ts |
No final finding. |
shared/src/util/ip-literal.spec.ts |
No final finding. |
shared/src/schema-directory.spec.ts |
No final finding. |
shared/src/resolver/schema-directory-reference-resolver.ts |
No final finding. |
shared/src/resolver/caching-tracking-resolver.ts |
No final finding. |
shared/src/logger.ts |
No final finding. |
shared/src/logger.spec.ts |
No final finding. |
shared/src/logger.node.ts |
No final finding. |
shared/src/index.ts |
No final finding. |
shared/src/hub/resource-types.ts |
No final finding. |
shared/src/hub/document-id-utils.ts |
No final finding. |
shared/src/hub/calm-hub-client.ts |
No final finding. |
shared/src/document-loader/node-document-loader.ts |
No final finding. |
shared/src/document-loader/node-document-loader.spec.ts |
No final finding. |
shared/src/document-loader/in-memory-document-loader.ts |
No final finding. |
shared/src/document-loader/in-memory-document-loader.spec.ts |
No final finding. |
shared/src/document-loader/document-loader.ts |
No final finding. |
shared/src/document-loader/document-loader.spec.ts |
No final finding. |
shared/src/document-loader/direct-url-document-loader.ts |
No final finding. |
shared/src/document-loader/browser-document-loader.ts |
Critical (1 vote): Browser Axios redirects can bypass origin and private-address checks; redirects must be rejected/revalidated or remote loaders disabled. |
shared/src/document-loader/browser-document-loader.spec.ts |
No final finding. |
shared/src/commands/validate/validate.ts |
No final finding. |
shared/src/commands/validate/validate-core.ts |
No final finding. |
shared/src/commands/validate/output-formats/pretty-output.ts |
No final finding. |
shared/src/commands/validate/output-formats/junit-output.ts |
No final finding. |
shared/src/commands/validate/format-output.ts |
No final finding. |
shared/src/commands/validate/format-output.spec.ts |
No final finding. |
shared/src/commands/generate/generate.ts |
No final finding. |
shared/src/commands/generate/generate-core.ts |
No final finding. |
shared/src/commands/generate/generate-core.spec.ts |
No final finding. |
shared/src/commands/diff/diff.ts |
No final finding. |
shared/src/commands/diff/diff.spec.ts |
No final finding. |
shared/src/commands/diff/diff-core.ts |
No final finding. |
shared/src/browser.ts |
No final finding. |
shared/src/browser-surface.spec.ts |
No final finding. |
shared/src/browser-capabilities.ts |
Moderate (2 votes): timeline is advertised as supported although the registered command uses Node-only runTimelineGenerate; expose a pure core or mark it unsupported. |
shared/src/browser-capabilities.spec.ts |
No final finding. |
shared/scripts/check-browser-entry.mjs |
No final finding. |
shared/scripts/browser-probe.ts |
No final finding. |
shared/README.md |
No final finding. |
shared/package.json |
No final finding. |
shared/AGENTS.md |
No final finding. |
package-lock.json |
No final finding. |
cli/tsconfig.json |
No final finding. |
cli/src/command-helpers/workspace/rm.ts |
No final finding. |
cli/src/command-helpers/workspace/ref-rewrite.ts |
No final finding. |
cli/src/command-helpers/workspace/ref-rewrite.spec.ts |
No final finding. |
cli/src/command-helpers/workspace/push.ts |
No final finding. |
cli/src/command-helpers/workspace/push.spec.ts |
No final finding. |
cli/src/command-helpers/workspace/document-id-prompt.ts |
No final finding. |
cli/src/command-helpers/workspace/config.ts |
No final finding. |
cli/src/command-helpers/workspace/commands.ts |
No final finding. |
cli/src/command-helpers/workspace/commands.spec.ts |
No final finding. |
cli/src/command-helpers/workspace/bump.ts |
No final finding. |
cli/src/command-helpers/workspace/bump.spec.ts |
No final finding. |
cli/src/command-helpers/hub-commands.spec.ts |
No final finding. |
cli/src/command-helpers/ai-tools.ts |
No final finding. |
cli/src/cli.spec.ts |
No final finding. |
cli/src/cli.e2e.spec.ts |
No final finding. |
cli/src/browser-manifest.spec.ts |
No final finding. |
Suppressed comments (2)
shared/src/commands/generate/generate-core.ts:16
generate()initializes the schema directory here, butinstantiate()still callsschemaDirectory.loadSchemas()atshared/src/commands/generate/components/instantiate.ts:152. Every generation therefore reloads and reparses all filesystem schemas (and repeats any custom loader initialization); keep initialization in one layer or make it explicitly idempotent.
shared/src/index.ts:65- Removing this export changes the package-root API:
expectDirectoryMatchandexpectFilesMatchwere previously re-exported from@finos/calm-shared, while the CLI's new relative test import does not preserve existing consumers. Restore the re-export; it is Node-only and does not affect the./browsergraph.
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
…ability manifest The CLI's timeline command synthesises a timeline from versioned architecture files on the local filesystem, but the browser entry only exports diffTimeline (the diff --timeline core). Correct the manifest so browser consumers report timeline as unsupported instead of claiming it works.
axios's maxRedirects only applies in Node; browsers follow redirects transparently, so a redirect from an allowed origin could be answered by a different one, bypassing DirectUrlDocumentLoader's host allowlist and CalmHubDocumentLoader's configured origin. Revalidate the final response origin (XHR responseURL, fetch Response.url) against the origin requested, and reject the response on a mismatch.
Code review (medium effort)Five findings, all in the new
|
|
@jpgough-ms thanks — verdicts per item: 1, 2, 3, 5 — all pre-exist at this PR's merge base ( 4 — intentional: |
Description
Adds a browser-safe entry point,
@finos/calm-shared/browser, exposing the pure CALM engine — validate (JSON Schema + Spectral rules), generate, diff/timeline,SchemaDirectory, the document-loader seam and a manifest of which CLI commands are available in browsers — with no Node-only module evaluated in its import graph. The existing root entry keeps identical behaviour for the CLI and calm-server.This is the prerequisite for the in-browser learning lab (#2879, Phase B): until now the lab's
calm validatewas a hand-wired Ajv re-implementation because importing any real command implementation dragged infs, winston, playwright and friends at load time.Closes #2880.
What changed
Seams (behaviour-preserving for Node callers)
logger.node.ts; the root barrel registers it at load viaregisterNodeLoggerFactory.initLoggerkeeps its signature and Node output is byte-identical; the browser graph only ever sees loglevel.document-loader.tsis now interface-only; the fs-backedbuildDocumentLoaderlives innode-document-loader.ts(same name re-exported from the root). NewInMemoryDocumentLoader(caller-supplieddocumentId → objectmap) andbuildBrowserDocumentLoader(in-memory → CalmHub → direct URL).DirectUrlDocumentLoader'snet.isIPbecomes a browser-safeipLiteralVersionwith the same contract (incl. IPv4-mapped tails and IPv6 zone ids), so the SSRF decision is unchanged.format-output.ts);json/prettyare built in,junitis registered only from the root entry.pretty-output.tsno longer importspathor readsprocessunguarded.validate-core.ts(validate),generate-core.ts(generate) anddiff-core.ts(diffDocuments,diffTimeline);validate.ts(which keepsexitBasedOffOfValidationOutcome),runGenerate/runDiff/runTimelineDiffare thin Node wrappers with unchanged signatures and messages.import typeCalmReferenceResolver, severing the staticfs/axios link from the validate path;hub/resource-types.tsis a leaf sodocument-id-utilsno longer pulls the axios Hub client into browser bundles.Package surface
exportsmap:"."→dist/index.js,"./browser"→dist/browser.js(types alongside). This seals deep imports; the CLI's eleven@finos/calm-shared/src/...imports (and the spec files that mocked those paths) now go through the root barrel, which additionally exportsRESOURCE_TYPES."module": "esnext"field and the unused@stoplight/spectral-clidependency (imported by zero files). The lockfile delta is that subtree's removal plusesbuildas a shared devDependency. It also carries acli1.55.0 → 1.56.0version-sync line — pre-existing lockfile drift thatnpm install --package-lock-onlycorrected, unrelated to this change.cli/tsconfig.jsonhadmodule/moduleResolutionoutsidecompilerOptions(silently ignored, so the CLI resolved with node10 rules); they now sit inside it, which is what lets@finos/calm-shared/browsertype-resolve.Capability manifest —
BROWSER_COMMAND_SUPPORT/browserSupportFor(command)list every top-level CLI command (and thehubsubgroups) assupportedorunsupportedwith a reason, so browser consumers can report honestly why e.g.calm docifyisn't available.cli/src/browser-manifest.spec.tsbuilds the Commander program and asserts the manifest and the registered commands match, so they can't drift.Guard —
shared/scripts/check-browser-entry.mjsruns innpm test --workspace shared: bundlessrc/browser.tswith esbuild for the browser, fails on any Node builtin request outside a four-entry allowlist (Spectral's dependency chain requestsfs/path/bufferbut never touchesfs/pathat runtime — verified by a spike), then executes a probe that runs realvalidate(),generate()anddiffDocuments()calls with those builtins stubbed to throw on touch andprocess/Bufferdefined away; the guard also fails if an allowlist entry stops matching, so the list can't silently rot.browser-surface.spec.tsmirrors the probe inside vitest (schema + Spectral through injected loaders, no fs).Docs —
shared/AGENTS.mdandshared/README.mddescribe the two entry points, the browser-safe-by-default rule, the guard/allowlist, and the consumer bundler fallbacks (resolve.fallback: { fs: false, path: false, buffer: false },browsermain field first).Deliberate departures / notes for reviewers
formatOutputno longer logsFormatting output as …— that line depended onvalidate()having run first and was never reliable.exitBasedOffOfValidationOutcome(process.exit) stays exported from the root entry (invalidate.ts, outside the browser graph); its own TODO says it belongs in the CLI — follow-up below.(cli)-scoped commits are on this branch (refactor(cli)import migration,fix(cli)tsconfig), so merging will trigger a CLI patch release.Follow-ups (to be filed as issues)
learning-labbranch (In-browser interactive learning lab: guided CALM lessons with no tool installation #2879 Phase B).exitBasedOffOfValidationOutcomeinto the CLI.Type of Change
Affected Components
cli/)calm/)calm-ai/)calm-hub/)calm-hub-ui/)calm-server/)calm-widgets/)docs/)shared/)calm-plugins/vscode/)Commit Message Format ✅
Testing
Full workspace sweep on a fresh
npm ci:npm run build,npm run lint,npm testgreen across all workspaces (0 test failures;shared100 files / 1142 tests incl. the guard,cli29 / 649,calm-server51).npm run build:cli && node cli/dist/index.js --helpprints the command list.npx tsc -p cli/tsconfig.json --noEmiterror count drops from 72 to 60 (all removed errors wereexports-resolution failures from the misplaced tsconfig settings; no new ones).Checklist