From 08304c938e2a29c96bb38c06fa226fbda2d5c02c Mon Sep 17 00:00:00 2001 From: Mark Scott Date: Sun, 22 Feb 2026 23:15:04 +0000 Subject: [PATCH 01/18] feat(calm-server): add initial files for calm-server --- calm-server/README.md | 34 ++++++++++++++++++++++++++ calm-server/eslint.config.mjs | 35 +++++++++++++++++++++++++++ calm-server/package.json | 43 +++++++++++++++++++++++++++++++++ calm-server/src/index.spec.ts | 7 ++++++ calm-server/src/index.ts | 15 ++++++++++++ calm-server/tsconfig.build.json | 4 +++ calm-server/tsconfig.json | 12 +++++++++ calm-server/tsup.config.ts | 23 ++++++++++++++++++ calm-server/vitest.config.ts | 17 +++++++++++++ package.json | 5 ++++ 10 files changed, 195 insertions(+) create mode 100644 calm-server/README.md create mode 100644 calm-server/eslint.config.mjs create mode 100644 calm-server/package.json create mode 100644 calm-server/src/index.spec.ts create mode 100644 calm-server/src/index.ts create mode 100644 calm-server/tsconfig.build.json create mode 100644 calm-server/tsconfig.json create mode 100644 calm-server/tsup.config.ts create mode 100644 calm-server/vitest.config.ts diff --git a/calm-server/README.md b/calm-server/README.md new file mode 100644 index 000000000..0462073e7 --- /dev/null +++ b/calm-server/README.md @@ -0,0 +1,34 @@ +# @finos/calm-server + +A server implementation for the Common Architecture Language Model (CALM). + +## Installation + +```bash +npm install +``` + +## Development + +```bash +# Build the package +npm run build + +# Watch mode for development +npm run watch + +# Run tests +npm run test + +# Lint code +npm run lint +npm run lint-fix +``` + +## Dependencies + +- `@finos/calm-shared` - Shared utilities and components + +## License + +Apache-2.0 diff --git a/calm-server/eslint.config.mjs b/calm-server/eslint.config.mjs new file mode 100644 index 000000000..d6f938c21 --- /dev/null +++ b/calm-server/eslint.config.mjs @@ -0,0 +1,35 @@ +import js from '@eslint/js'; +import tsParser from '@typescript-eslint/parser'; +import tsPlugin from '@typescript-eslint/eslint-plugin'; + +export default [ + { + ignores: ['dist/', 'node_modules/', 'coverage/'], + }, + { + files: ['src/**/*.ts'], + languageOptions: { + parser: tsParser, + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + project: './tsconfig.json', + }, + globals: { + console: 'readonly', + process: 'readonly', + }, + }, + plugins: { + '@typescript-eslint': tsPlugin, + }, + rules: { + ...js.configs.recommended.rules, + ...tsPlugin.configs.recommended.rules, + '@typescript-eslint/no-unused-vars': [ + 'error', + { argsIgnorePattern: '^_' }, + ], + }, + }, +]; diff --git a/calm-server/package.json b/calm-server/package.json new file mode 100644 index 000000000..0b9eb40f1 --- /dev/null +++ b/calm-server/package.json @@ -0,0 +1,43 @@ +{ + "name": "@finos/calm-server", + "version": "0.1.0", + "description": "CALM Server - A server implementation for the Common Architecture Language Model", + "homepage": "https://calm.finos.org", + "repository": { + "type": "git", + "url": "https://github.com/finos/architecture-as-code.git" + }, + "main": "dist/index.js", + "files": [ + "dist/" + ], + "bin": { + "calm-server": "dist/index.js" + }, + "scripts": { + "build": "tsup", + "watch": "tsup --watch", + "test": "vitest run", + "lint": "eslint src", + "lint-fix": "eslint src --fix" + }, + "keywords": [ + "calm", + "server", + "architecture" + ], + "author": "", + "license": "Apache-2.0", + "dependencies": { + "@finos/calm-shared": "file:../shared" + }, + "devDependencies": { + "@types/node": "^22.15.0", + "@typescript-eslint/eslint-plugin": "^8.0.0", + "@typescript-eslint/parser": "^8.0.0", + "eslint": "^9.0.0", + "tsup": "^8.3.5", + "typescript": "^5.8.2", + "vitest": "^3.1.1" + } +} \ No newline at end of file diff --git a/calm-server/src/index.spec.ts b/calm-server/src/index.spec.ts new file mode 100644 index 000000000..0470bda01 --- /dev/null +++ b/calm-server/src/index.spec.ts @@ -0,0 +1,7 @@ +import { describe, it, expect } from 'vitest'; + +describe('calm-server', () => { + it('should be importable', () => { + expect(true).toBe(true); + }); +}); diff --git a/calm-server/src/index.ts b/calm-server/src/index.ts new file mode 100644 index 000000000..d61ea45c4 --- /dev/null +++ b/calm-server/src/index.ts @@ -0,0 +1,15 @@ +/** + * CALM Server - A server implementation for the Common Architecture Language Model + */ + +import { version } from '../package.json'; + +const main = async () => { + console.log(`CALM Server v${version}`); + console.log('Server is starting...'); +}; + +main().catch((error) => { + console.error('Fatal error:', error); + process.exit(1); +}); diff --git a/calm-server/tsconfig.build.json b/calm-server/tsconfig.build.json new file mode 100644 index 000000000..8c0f74bb0 --- /dev/null +++ b/calm-server/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["**/*.spec.ts"] +} diff --git a/calm-server/tsconfig.json b/calm-server/tsconfig.json new file mode 100644 index 000000000..597039fe4 --- /dev/null +++ b/calm-server/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../tsconfig.base.json", + "module": "Preserve", + "moduleResolution": "bundler", + "compilerOptions": { + "outDir": "dist" + }, + "include": ["src", "../vitest-globals.d.ts"], + "lib": [ + "esnext" + ] +} diff --git a/calm-server/tsup.config.ts b/calm-server/tsup.config.ts new file mode 100644 index 000000000..ed0322a76 --- /dev/null +++ b/calm-server/tsup.config.ts @@ -0,0 +1,23 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: ['src/index.ts'], + format: ['cjs'], + sourcemap: false, + clean: true, + external: ['canvas', 'fsevents', '@apidevtools/json-schema-ref-parser', /node_modules/, 'ts-node'], + noExternal: ['@finos/calm-shared', '@finos/calm-widgets', '@finos/calm-models', /tsup/], + bundle: true, + splitting: false, + minify: false, + shims: true, + target: 'es2021', + treeshake: true, + banner: ({ format }) => { + if (format === 'cjs') { + return { + js: '#!/usr/bin/env node' + }; + } + } +}); diff --git a/calm-server/vitest.config.ts b/calm-server/vitest.config.ts new file mode 100644 index 000000000..042d2fcef --- /dev/null +++ b/calm-server/vitest.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html'], + exclude: [ + 'node_modules/', + 'dist/', + '**/*.spec.ts', + ], + }, + }, +}); diff --git a/package.json b/package.json index 37bd1f18d..b391a7d85 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ }, "workspaces": [ "calm-models", + "calm-server", "calm-widgets", "calm-ai", "shared", @@ -18,11 +19,13 @@ ], "scripts": { "build": "npm run build --workspaces --if-present", + "build:calm-server": "npm run build --workspace calm-models --workspace calm-widgets --workspace shared --workspace calm-server", "build:cli": "npm run build --workspace calm-models --workspace calm-widgets --workspace shared --workspace cli", "build:shared": "npm run build --workspace calm-models --workspace calm-widgets --workspace shared", "build:calm-widgets": "npm run build --workspace calm-models --workspace calm-widgets", "build:docs": "npm run build --workspace docs", "test": "npm run test --workspaces --if-present", + "test:calm-server": "npm run build:calm-server && npm run test --workspace calm-server", "test:cli": "npm run build:cli && npm run test --workspace cli", "test:shared": "npm run build:shared && npm run test --workspace shared", "test:models": "npm run build:calm-models && npm run test --workspace calm-models", @@ -32,10 +35,12 @@ "lint": "npm run lint --workspaces --if-present", "lint-fix": "npm run lint-fix --workspaces --if-present", "watch": "run-p watch:cli watch:shared", + "watch:calm-server": "npm run watch --workspace calm-server", "watch:cli": "npm run watch --workspace cli", "watch:shared": "npm run watch --workspace shared", "watch:models": "npm run watch --workspace calm-models", "watch:calm-widgets": "npm run watch --workspace calm-widgets", + "link:calm-server": "npm link --workspace calm-server", "link:cli": "npm link --workspace cli", "calm-hub-ui:run": "npm run start --workspace calm-hub-ui", "calm-hub-ui:prod": "npm run prod --workspace calm-hub-ui", From bb14d87f8743b3fd67c72cdf17a1cdcef8eace49 Mon Sep 17 00:00:00 2001 From: Mark Scott Date: Sun, 22 Feb 2026 23:35:07 +0000 Subject: [PATCH 02/18] feat(calm-server): copy server functionality into calm-server --- calm-server/AGENTS.md | 143 ++++++++++++++++++ calm-server/README.md | 133 ++++++++++++++-- calm-server/package.json | 6 +- calm-server/src/cli-config.ts | 32 ++++ calm-server/src/index.ts | 74 ++++++++- calm-server/src/server/cli-server.spec.ts | 33 ++++ calm-server/src/server/cli-server.ts | 18 +++ calm-server/src/server/routes/health-route.ts | 19 +++ calm-server/src/server/routes/routes.ts | 19 +++ .../src/server/routes/validation-route.ts | 75 +++++++++ package.json | 1 - 11 files changed, 531 insertions(+), 22 deletions(-) create mode 100644 calm-server/AGENTS.md create mode 100644 calm-server/src/cli-config.ts create mode 100644 calm-server/src/server/cli-server.spec.ts create mode 100644 calm-server/src/server/cli-server.ts create mode 100644 calm-server/src/server/routes/health-route.ts create mode 100644 calm-server/src/server/routes/routes.ts create mode 100644 calm-server/src/server/routes/validation-route.ts diff --git a/calm-server/AGENTS.md b/calm-server/AGENTS.md new file mode 100644 index 000000000..01d4e9899 --- /dev/null +++ b/calm-server/AGENTS.md @@ -0,0 +1,143 @@ +# @finos/calm-server + +The `calm-server` executable provides a standalone HTTP server implementation of CALM functionality. + +## Architecture + +The calm-server implements the same server functionality as the `calm server` CLI command, providing: + +- **Health Check Endpoint** (`/health`) - Status endpoint for monitoring +- **Validation Endpoint** (`/calm/validate`) - POST endpoint for validating CALM architectures +- **Rate Limiting** - Protects against abuse with 100 requests per 15 minutes per IP + +## Project Structure + +``` +calm-server/ +├── src/ +│ ├── index.ts # Entry point, CLI argument parsing +│ ├── cli-config.ts # Load user CALM configuration +│ ├── server/ +│ │ ├── cli-server.ts # Express server startup +│ │ └── routes/ +│ │ ├── routes.ts # Router setup +│ │ ├── health-route.ts # Health check endpoint +│ │ └── validation-route.ts # Architecture validation endpoint +│ └── *.spec.ts # Unit tests +├── package.json +├── tsconfig.json +├── tsconfig.build.json +├── tsup.config.ts # Build configuration +├── vitest.config.ts # Test configuration +└── eslint.config.mjs # Linting configuration +``` + +## Building & Running + +### Build the package +```bash +npm run build:calm-server +``` + +### Run the server locally +```bash +# With verbose logging +./calm-server/dist/index.js -s ../calm/release --port 3000 --verbose + +# Or using npm +npm run build:calm-server +node calm-server/dist/index.js -s calm/release --port 3000 +``` + +### Global installation (for development) +```bash +npm run link:calm-server +calm-server -s /path/to/schemas --port 3000 +``` + +## Command-Line Options + +``` +Usage: calm-server [options] + +CALM Server - A server implementation for the Common Architecture Language Model + +Options: + -V, --version output the version number + --port Port to run the server on (default: "3000") + -s, --schema-directory Path to the directory containing the meta schemas to use. (required) + -v, --verbose Enable verbose logging. (default: false) + -c, --calm-hub-url URL to CALMHub instance + -h, --help display help for message +``` + +## Testing + +### Run tests +```bash +npm run test:calm-server +``` + +### Test the health endpoint +```bash +# Start the server +node calm-server/dist/index.js -s calm/release & +SERVER_PID=$! + +# Test health +curl http://localhost:3000/health + +# Clean up +kill $SERVER_PID +``` + +### Test the validation endpoint +```bash +# With a CALM architecture JSON +curl -X POST http://localhost:3000/calm/validate \ + -H "Content-Type: application/json" \ + -d '{"architecture": "{\"$schema\": \"https://...\"...}"}' +``` + +## Dependencies + +- `@finos/calm-shared` - Shared utilities, validation logic, schema handling +- `express` - HTTP server framework +- `express-rate-limit` - Rate limiting middleware +- `commander` - CLI argument parsing + +## Development Notes + +### Copying from CLI + +The calm-server implementation mirrors the server functionality from the CLI package (`cli/src/server/`): + +- `src/server/cli-server.ts` - Express server setup +- `src/server/routes/routes.ts` - Route configuration +- `src/server/routes/health-route.ts` - Health check +- `src/server/routes/validation-route.ts` - Architecture validation + +Both implementations share the same core logic for validation and schema handling through the `@finos/calm-shared` package. + +### Build Configuration + +The tsup configuration: +- Bundles shared packages (`@finos/calm-shared`, `@finos/calm-models`, `@finos/calm-widgets`) +- Adds Node.js shebang via banner for executable +- Outputs CommonJS format with tree-shaking enabled +- Marks external `node_modules` as external (not bundled) + +## Linking for Development + +After building, you can link the executable globally: + +```bash +npm run link:calm-server +calm-server --help +``` + +This allows testing the executable without needing to build or reference the dist directory. + +## License + +Apache-2.0 diff --git a/calm-server/README.md b/calm-server/README.md index 0462073e7..633de1a51 100644 --- a/calm-server/README.md +++ b/calm-server/README.md @@ -1,34 +1,145 @@ # @finos/calm-server -A server implementation for the Common Architecture Language Model (CALM). +A standalone HTTP server for the Common Architecture Language Model (CALM) validation functionality. -## Installation +The `calm-server` executable provides HTTP endpoints for CALM architecture validation. + +## Features + +- **Health Check Endpoint** (`GET /health`) - Status endpoint for monitoring +- **Validation Endpoint** (`POST /calm/validate`) - Validate CALM architectures against schemas + +## Usage + +### Starting the Server + +```bash +# Basic usage (requires schema directory) +calm-server -s /path/to/calm/schemas + +# With custom port +calm-server -s ./calm/release --port 8080 + +# With verbose logging +calm-server -s ./calm/release --port 3000 --verbose +``` + +### Command-Line Options + +``` +Usage: calm-server [options] + +Options: + -V, --version output the version number + --port Port to run the server on (default: "3000") + -s, --schema-directory Path to the directory containing the meta schemas (required) + -v, --verbose Enable verbose logging (default: false) + -c, --calm-hub-url URL to CALMHub instance + -h, --help display help for command +``` + +## API Endpoints + +### Health Check + +Check if the server is running: + +```bash +curl http://localhost:3000/health +``` + +Response: +```json +{ + "status": "OK" +} +``` + +### Validate Architecture + +Validate a CALM architecture document: ```bash -npm install +curl -X POST http://localhost:3000/calm/validate \ + -H "Content-Type: application/json" \ + -d '{ + "architecture": "{\"$schema\":\"https://calm.finos.org/draft/2024-04/meta/core\",\"nodes\":[...]}" + }' +``` + +Response (success): +```json +{ + "errors": [], + "warnings": [], + "result": "success" +} +``` + +Response (validation errors): +```json +{ + "errors": [...], + "warnings": [...], + "result": "failure" +} ``` ## Development +### Building + ```bash -# Build the package +# From repository root +npm run build:calm-server + +# Or from calm-server directory +cd calm-server npm run build +``` + +### Testing + +```bash +# From repository root +npm run test:calm-server + +# Or from calm-server directory +cd calm-server +npm test -# Watch mode for development -npm run watch +# With coverage +npm test -- --coverage +``` -# Run tests -npm run test +### Linting -# Lint code +```bash +# From calm-server directory npm run lint npm run lint-fix ``` -## Dependencies +## Configuration + +The server can load configuration from `~/.calm.json`: -- `@finos/calm-shared` - Shared utilities and components +```json +{ + "calmHubUrl": "https://calm-hub.example.com" +} +``` + +This allows you to set a default CALM Hub URL without specifying it on every invocation. + +## Relationship to CLI + +The calm-server package extracts the server functionality from the `@finos/calm-cli` package into a standalone executable. Both implementations share the same core validation logic through `@finos/calm-shared`. + +**CLI**: `calm server -s ./calm/release --port 3000` +**Standalone**: `calm-server -s ./calm/release --port 3000` ## License Apache-2.0 + diff --git a/calm-server/package.json b/calm-server/package.json index 0b9eb40f1..c0b93d925 100644 --- a/calm-server/package.json +++ b/calm-server/package.json @@ -16,7 +16,6 @@ }, "scripts": { "build": "tsup", - "watch": "tsup --watch", "test": "vitest run", "lint": "eslint src", "lint-fix": "eslint src --fix" @@ -29,7 +28,10 @@ "author": "", "license": "Apache-2.0", "dependencies": { - "@finos/calm-shared": "file:../shared" + "@finos/calm-shared": "file:../shared", + "commander": "^14.0.0", + "express": "^4.18.2", + "express-rate-limit": "^8.0.0" }, "devDependencies": { "@types/node": "^22.15.0", diff --git a/calm-server/src/cli-config.ts b/calm-server/src/cli-config.ts new file mode 100644 index 000000000..1a9dda1fa --- /dev/null +++ b/calm-server/src/cli-config.ts @@ -0,0 +1,32 @@ +import { initLogger } from '@finos/calm-shared'; +import { readFile } from 'fs/promises'; +import { homedir } from 'os'; +import { join } from 'path'; + +export interface CLIConfig { + calmHubUrl?: string; +} + +function getUserConfigLocation(): string { + const homeDir = homedir(); + return join(homeDir, '.calm.json'); +} + +export async function loadCliConfig(): Promise { + const logger = initLogger(false, 'calm-server'); + + const configFilePath = getUserConfigLocation(); + try { + const config = await readFile(configFilePath, 'utf8'); + const parsed = JSON.parse(config) as CLIConfig; + logger.debug('Parsed user config: ' + config); + return parsed; + } catch (err) { + if ((err as any).code === 'ENOENT') { + logger.debug('No config file found at ' + configFilePath); + return null; + } + logger.error('Unexpected error loading user config: ', err); + return null; + } +} diff --git a/calm-server/src/index.ts b/calm-server/src/index.ts index d61ea45c4..08cfee1e2 100644 --- a/calm-server/src/index.ts +++ b/calm-server/src/index.ts @@ -2,14 +2,72 @@ * CALM Server - A server implementation for the Common Architecture Language Model */ +import { Command } from 'commander'; import { version } from '../package.json'; +import { startServer } from './server/cli-server'; +import { SchemaDirectory, initLogger } from '@finos/calm-shared'; +import { buildDocumentLoader, DocumentLoaderOptions } from '@finos/calm-shared/dist/document-loader/document-loader'; +import { loadCliConfig } from './cli-config'; -const main = async () => { - console.log(`CALM Server v${version}`); - console.log('Server is starting...'); -}; +const PORT_OPTION = '--port '; +const SCHEMAS_OPTION = '-s, --schema-directory '; +const VERBOSE_OPTION = '-v, --verbose'; +const CALMHUB_URL_OPTION = '-c, --calm-hub-url '; + +interface ParseDocumentLoaderOptions { + verbose?: boolean; + calmHubUrl?: string; + schemaDirectory?: string; +} + +async function parseDocumentLoaderConfig( + options: ParseDocumentLoaderOptions, + urlToLocalMap?: Map, + basePath?: string +): Promise { + const logger = initLogger(options.verbose, 'calm-server'); + const docLoaderOpts: DocumentLoaderOptions = { + calmHubUrl: options.calmHubUrl, + schemaDirectoryPath: options.schemaDirectory, + urlToLocalMap: urlToLocalMap, + basePath: basePath, + debug: !!options.verbose + }; + + const userConfig = await loadCliConfig(); + if (userConfig && userConfig.calmHubUrl && !options.calmHubUrl) { + logger.info('Using CALMHub URL from config file: ' + userConfig.calmHubUrl); + docLoaderOpts.calmHubUrl = userConfig.calmHubUrl; + } + return docLoaderOpts; +} + +async function buildSchemaDirectory(docLoader: any, debug: boolean): Promise { + return new SchemaDirectory(docLoader, debug); +} + +const program = new Command(); + +program + .name('calm-server') + .version(version) + .description('CALM Server - A server implementation for the Common Architecture Language Model') + .option(PORT_OPTION, 'Port to run the server on', '3000') + .requiredOption(SCHEMAS_OPTION, 'Path to the directory containing the meta schemas to use.') + .option(VERBOSE_OPTION, 'Enable verbose logging.', false) + .option(CALMHUB_URL_OPTION, 'URL to CALMHub instance') + .action(async (options) => { + try { + const debug = !!options.verbose; + const docLoaderOpts = await parseDocumentLoaderConfig(options); + const docLoader = buildDocumentLoader(docLoaderOpts); + const schemaDirectory = await buildSchemaDirectory(docLoader, debug); + startServer(options.port, schemaDirectory, debug); + } catch (error) { + console.error('Fatal error:', error); + process.exit(1); + } + }); + +program.parse(process.argv); -main().catch((error) => { - console.error('Fatal error:', error); - process.exit(1); -}); diff --git a/calm-server/src/server/cli-server.spec.ts b/calm-server/src/server/cli-server.spec.ts new file mode 100644 index 000000000..2ac764320 --- /dev/null +++ b/calm-server/src/server/cli-server.spec.ts @@ -0,0 +1,33 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { startServer } from './cli-server'; +import { SchemaDirectory } from '@finos/calm-shared'; +import fetch from 'node-fetch'; +import { Server } from 'http'; +import { vi } from 'vitest'; + +describe('startServer', () => { + let serverInstance: Server; + const port = '3001'; + const schemaDirectory = { + loadSchemas: vi.fn(), + getSchema: vi.fn(), + } as unknown as SchemaDirectory; + + afterEach(() => { + if (serverInstance) { + serverInstance.close(); + } + }); + + it('should start the server and respond to /health', async () => { + serverInstance = startServer(port, schemaDirectory, false); + + // Wait for server to be ready + await new Promise((resolve) => setTimeout(resolve, 100)); + + const response = await fetch(`http://localhost:${port}/health`); + expect(response.status).toBe(200); + const data = (await response.json()) as { status: string }; + expect(data.status).toBe('OK'); + }); +}); diff --git a/calm-server/src/server/cli-server.ts b/calm-server/src/server/cli-server.ts new file mode 100644 index 000000000..3e6cb64fc --- /dev/null +++ b/calm-server/src/server/cli-server.ts @@ -0,0 +1,18 @@ +import express, { Application } from 'express'; +import { CLIServerRoutes } from './routes/routes'; +import { initLogger, SchemaDirectory } from '@finos/calm-shared'; +import { Server } from 'http'; + +export function startServer(port: string, schemaDirectory: SchemaDirectory, verbose: boolean): Server { + const app: Application = express(); + const cliServerRoutesInstance = new CLIServerRoutes(schemaDirectory, verbose); + const allRoutes = cliServerRoutesInstance.router; + + app.use(express.json()); + app.use('/', allRoutes); + + return app.listen(port, () => { + const logger = initLogger(verbose, 'calm-server'); + logger.info(`CALM Server is running on http://localhost:${port}`); + }); +} diff --git a/calm-server/src/server/routes/health-route.ts b/calm-server/src/server/routes/health-route.ts new file mode 100644 index 000000000..4e96c9a99 --- /dev/null +++ b/calm-server/src/server/routes/health-route.ts @@ -0,0 +1,19 @@ +import { Router, Request, Response } from 'express'; + +export class HealthRouter { + constructor(router: Router) { + router.get('/', this.healthCheck); + } + + private healthCheck(_req: Request, res: Response) { + res.status(200).type('json').send(new StatusResponse('OK')); + } +} + +class StatusResponse { + status: string; + + constructor(status: string) { + this.status = status; + } +} diff --git a/calm-server/src/server/routes/routes.ts b/calm-server/src/server/routes/routes.ts new file mode 100644 index 000000000..920a8f737 --- /dev/null +++ b/calm-server/src/server/routes/routes.ts @@ -0,0 +1,19 @@ +import { Router } from 'express'; +import { ValidationRouter } from './validation-route'; +import { HealthRouter } from './health-route'; +import { SchemaDirectory } from '@finos/calm-shared'; + +const HEALTH_ROUTE_PATH = '/health'; +const VALIDATE_ROUTE_PATH = '/calm/validate'; + +export class CLIServerRoutes { + router: Router; + + constructor(schemaDirectory: SchemaDirectory, debug: boolean = false) { + this.router = Router(); + const validateRoute = this.router.use(VALIDATE_ROUTE_PATH, this.router); + new ValidationRouter(validateRoute, schemaDirectory, debug); + const healthRoute = this.router.use(HEALTH_ROUTE_PATH, this.router); + new HealthRouter(healthRoute); + } +} diff --git a/calm-server/src/server/routes/validation-route.ts b/calm-server/src/server/routes/validation-route.ts new file mode 100644 index 000000000..ad9753f88 --- /dev/null +++ b/calm-server/src/server/routes/validation-route.ts @@ -0,0 +1,75 @@ +import { SchemaDirectory, validate } from '@finos/calm-shared'; +import { Router, Request, Response } from 'express'; +import { ValidationOutcome } from '@finos/calm-shared'; +import rateLimit from 'express-rate-limit'; +import { initLogger, Logger } from '@finos/calm-shared/dist/logger'; + +export class ValidationRouter { + private schemaDirectory: SchemaDirectory; + private logger: Logger; + + constructor(router: Router, schemaDirectory: SchemaDirectory, debug: boolean = false) { + const limiter = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 100, // limit each IP to 100 requests per windowMs + }); + this.schemaDirectory = schemaDirectory; + this.logger = initLogger(debug, 'calm-server'); + router.use(limiter); + this.initializeRoutes(router); + } + + private initializeRoutes(router: Router) { + router.post('/', this.validateSchema); + } + + private validateSchema = async (req: Request, res: Response) => { + let architecture; + try { + architecture = JSON.parse(req.body.architecture); + } catch (error) { + this.logger.error('Invalid JSON format for architecture ' + error); + return res.status(400).type('json').send(new ErrorResponse('Invalid JSON format for architecture')); + } + + const schema = architecture['$schema']; + if (!schema) { + return res.status(400).type('json').send(new ErrorResponse('The "$schema" field is missing from the request body')); + } + + try { + await this.schemaDirectory.loadSchemas(); + } catch (error) { + this.logger.error('Failed to load schemas: ' + error); + return res.status(500).type('json').send(new ErrorResponse('Failed to load schemas')); + } + let foundSchema; + try { + foundSchema = await this.schemaDirectory.getSchema(schema); + if (!foundSchema) { + this.logger.error('Schema with $id ' + schema + ' not found'); + return res.status(400).type('json').send(new ErrorResponse('The "$schema" field referenced is not available to the server')); + } + } catch (err) { + this.logger.error('Failed to load schema: ' + err); + return res.status(500).type('json').send(new ErrorResponse('Failed to load schema: ' + err)); + } + try { + const outcome = await validate(architecture, foundSchema, undefined, this.schemaDirectory, true); + return res.status(201).type('json').send(outcome); + } catch (error) { + return res.status(500).type('json').send(new ErrorResponse(error.message)); + } + }; +} + +class ErrorResponse { + error: string; + constructor(error: string) { + this.error = error; + } +} + +class ValidationRequest { + architecture: string; +} diff --git a/package.json b/package.json index b391a7d85..5ce90b226 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,6 @@ "lint": "npm run lint --workspaces --if-present", "lint-fix": "npm run lint-fix --workspaces --if-present", "watch": "run-p watch:cli watch:shared", - "watch:calm-server": "npm run watch --workspace calm-server", "watch:cli": "npm run watch --workspace cli", "watch:shared": "npm run watch --workspace shared", "watch:models": "npm run watch --workspace calm-models", From 63903ba8cbb0d181b444bae718e09a5ddaad803d Mon Sep 17 00:00:00 2001 From: Mark Scott Date: Sun, 22 Feb 2026 23:50:10 +0000 Subject: [PATCH 03/18] feat(calm-server): bundle schema into calm-server --- calm-server/AGENTS.md | 27 ++++++++++++++++++----- calm-server/README.md | 15 ++++++++----- calm-server/eslint.config.mjs | 3 +++ calm-server/package.json | 4 +++- calm-server/src/cli-config.ts | 2 +- calm-server/src/index.ts | 9 +++++--- calm-server/src/server/cli-server.spec.ts | 2 +- 7 files changed, 46 insertions(+), 16 deletions(-) diff --git a/calm-server/AGENTS.md b/calm-server/AGENTS.md index 01d4e9899..20fffc63c 100644 --- a/calm-server/AGENTS.md +++ b/calm-server/AGENTS.md @@ -6,6 +6,7 @@ The `calm-server` executable provides a standalone HTTP server implementation of The calm-server implements the same server functionality as the `calm server` CLI command, providing: +- **Bundled CALM Schemas** - All CALM schemas (release and draft) are bundled during build - **Health Check Endpoint** (`/health`) - Status endpoint for monitoring - **Validation Endpoint** (`/calm/validate`) - POST endpoint for validating CALM architectures - **Rate Limiting** - Protects against abuse with 100 requests per 15 minutes per IP @@ -24,6 +25,11 @@ calm-server/ │ │ ├── health-route.ts # Health check endpoint │ │ └── validation-route.ts # Architecture validation endpoint │ └── *.spec.ts # Unit tests +├── dist/ +│ ├── index.js # Compiled executable +│ └── calm/ # Bundled CALM schemas +│ ├── release/ # Released schema versions +│ └── draft/ # Draft schema versions ├── package.json ├── tsconfig.json ├── tsconfig.build.json @@ -39,19 +45,29 @@ calm-server/ npm run build:calm-server ``` +This builds the TypeScript code and copies all CALM schemas from `calm/release` and `calm/draft` into `dist/calm/`. + ### Run the server locally ```bash -# With verbose logging +# Using bundled schemas (default) +./calm-server/dist/index.js --port 3000 --verbose + +# Or with custom schemas ./calm-server/dist/index.js -s ../calm/release --port 3000 --verbose # Or using npm npm run build:calm-server -node calm-server/dist/index.js -s calm/release --port 3000 +node calm-server/dist/index.js --port 3000 ``` ### Global installation (for development) ```bash npm run link:calm-server + +# Use bundled schemas (default) +calm-server --port 3000 + +# Or provide custom schemas calm-server -s /path/to/schemas --port 3000 ``` @@ -65,7 +81,8 @@ CALM Server - A server implementation for the Common Architecture Language Model Options: -V, --version output the version number --port Port to run the server on (default: "3000") - -s, --schema-directory Path to the directory containing the meta schemas to use. (required) + -s, --schema-directory Path to the directory containing the meta schemas to use. + (default: bundled schemas in dist/calm) -v, --verbose Enable verbose logging. (default: false) -c, --calm-hub-url URL to CALMHub instance -h, --help display help for message @@ -80,8 +97,8 @@ npm run test:calm-server ### Test the health endpoint ```bash -# Start the server -node calm-server/dist/index.js -s calm/release & +# Start the server (uses bundled schemas) +node calm-server/dist/index.js & SERVER_PID=$! # Test health diff --git a/calm-server/README.md b/calm-server/README.md index 633de1a51..f317fa695 100644 --- a/calm-server/README.md +++ b/calm-server/README.md @@ -6,6 +6,7 @@ The `calm-server` executable provides HTTP endpoints for CALM architecture valid ## Features +- **Bundled CALM Schemas** - All CALM schemas (release and draft) are bundled with the executable - **Health Check Endpoint** (`GET /health`) - Status endpoint for monitoring - **Validation Endpoint** (`POST /calm/validate`) - Validate CALM architectures against schemas @@ -14,14 +15,17 @@ The `calm-server` executable provides HTTP endpoints for CALM architecture valid ### Starting the Server ```bash -# Basic usage (requires schema directory) -calm-server -s /path/to/calm/schemas +# Basic usage (uses bundled schemas by default) +calm-server # With custom port -calm-server -s ./calm/release --port 8080 +calm-server --port 8080 # With verbose logging -calm-server -s ./calm/release --port 3000 --verbose +calm-server --port 3000 --verbose + +# Or provide a custom schema directory +calm-server -s /path/to/calm/schemas --port 3000 ``` ### Command-Line Options @@ -32,7 +36,8 @@ Usage: calm-server [options] Options: -V, --version output the version number --port Port to run the server on (default: "3000") - -s, --schema-directory Path to the directory containing the meta schemas (required) + -s, --schema-directory Path to the directory containing the meta schemas + (default: bundled schemas in dist/calm) -v, --verbose Enable verbose logging (default: false) -c, --calm-hub-url URL to CALMHub instance -h, --help display help for command diff --git a/calm-server/eslint.config.mjs b/calm-server/eslint.config.mjs index d6f938c21..71e750d44 100644 --- a/calm-server/eslint.config.mjs +++ b/calm-server/eslint.config.mjs @@ -18,6 +18,9 @@ export default [ globals: { console: 'readonly', process: 'readonly', + __dirname: 'readonly', + setTimeout: 'readonly', + NodeJS: 'readonly', }, }, plugins: { diff --git a/calm-server/package.json b/calm-server/package.json index c0b93d925..95e7251aa 100644 --- a/calm-server/package.json +++ b/calm-server/package.json @@ -15,7 +15,8 @@ "calm-server": "dist/index.js" }, "scripts": { - "build": "tsup", + "build": "tsup && npm run copy-calm-schema", + "copy-calm-schema": "copyfiles \"../calm/release/**/meta/*\" \"../calm/draft/**/meta/*\" dist/calm/", "test": "vitest run", "lint": "eslint src", "lint-fix": "eslint src --fix" @@ -30,6 +31,7 @@ "dependencies": { "@finos/calm-shared": "file:../shared", "commander": "^14.0.0", + "copyfiles": "^2.4.1", "express": "^4.18.2", "express-rate-limit": "^8.0.0" }, diff --git a/calm-server/src/cli-config.ts b/calm-server/src/cli-config.ts index 1a9dda1fa..a50b67438 100644 --- a/calm-server/src/cli-config.ts +++ b/calm-server/src/cli-config.ts @@ -22,7 +22,7 @@ export async function loadCliConfig(): Promise { logger.debug('Parsed user config: ' + config); return parsed; } catch (err) { - if ((err as any).code === 'ENOENT') { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { logger.debug('No config file found at ' + configFilePath); return null; } diff --git a/calm-server/src/index.ts b/calm-server/src/index.ts index 08cfee1e2..aed7862f1 100644 --- a/calm-server/src/index.ts +++ b/calm-server/src/index.ts @@ -6,8 +6,11 @@ import { Command } from 'commander'; import { version } from '../package.json'; import { startServer } from './server/cli-server'; import { SchemaDirectory, initLogger } from '@finos/calm-shared'; -import { buildDocumentLoader, DocumentLoaderOptions } from '@finos/calm-shared/dist/document-loader/document-loader'; +import { buildDocumentLoader, DocumentLoader, DocumentLoaderOptions } from '@finos/calm-shared/dist/document-loader/document-loader'; import { loadCliConfig } from './cli-config'; +import path from 'path'; + +const BUNDLED_SCHEMA_PATH = path.join(__dirname, 'calm'); const PORT_OPTION = '--port '; const SCHEMAS_OPTION = '-s, --schema-directory '; @@ -42,7 +45,7 @@ async function parseDocumentLoaderConfig( return docLoaderOpts; } -async function buildSchemaDirectory(docLoader: any, debug: boolean): Promise { +async function buildSchemaDirectory(docLoader: DocumentLoader, debug: boolean): Promise { return new SchemaDirectory(docLoader, debug); } @@ -53,7 +56,7 @@ program .version(version) .description('CALM Server - A server implementation for the Common Architecture Language Model') .option(PORT_OPTION, 'Port to run the server on', '3000') - .requiredOption(SCHEMAS_OPTION, 'Path to the directory containing the meta schemas to use.') + .option(SCHEMAS_OPTION, 'Path to the directory containing the meta schemas to use.', BUNDLED_SCHEMA_PATH) .option(VERBOSE_OPTION, 'Enable verbose logging.', false) .option(CALMHUB_URL_OPTION, 'URL to CALMHub instance') .action(async (options) => { diff --git a/calm-server/src/server/cli-server.spec.ts b/calm-server/src/server/cli-server.spec.ts index 2ac764320..065e7616c 100644 --- a/calm-server/src/server/cli-server.spec.ts +++ b/calm-server/src/server/cli-server.spec.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, afterEach } from 'vitest'; import { startServer } from './cli-server'; import { SchemaDirectory } from '@finos/calm-shared'; import fetch from 'node-fetch'; From 5fdab6ca91272cee8c811d997d18227bbfd07cb5 Mon Sep 17 00:00:00 2001 From: Mark Scott Date: Mon, 23 Feb 2026 21:46:14 +0000 Subject: [PATCH 04/18] feat(calm-server): add tests --- calm-server/package.json | 2 + .../src/server/routes/health-route.spec.ts | 25 ++ calm-server/src/server/routes/routes.spec.ts | 56 ++++ .../server/routes/validation-route.spec.ts | 88 ++++++ .../api-gateway/api-gateway.json | 254 +++++++++++++++ ...eway_instantiation_missing_schema_key.json | 3 + ...ation_schema_points_to_missing_schema.json | 3 + .../validation_route/valid_instantiation.json | 3 + package-lock.json | 298 ++++++++++++++++++ 9 files changed, 732 insertions(+) create mode 100644 calm-server/src/server/routes/health-route.spec.ts create mode 100644 calm-server/src/server/routes/routes.spec.ts create mode 100644 calm-server/src/server/routes/validation-route.spec.ts create mode 100644 calm-server/test_fixtures/api-gateway/api-gateway.json create mode 100644 calm-server/test_fixtures/validation_route/invalid_api_gateway_instantiation_missing_schema_key.json create mode 100644 calm-server/test_fixtures/validation_route/invalid_api_gateway_instantiation_schema_points_to_missing_schema.json create mode 100644 calm-server/test_fixtures/validation_route/valid_instantiation.json diff --git a/calm-server/package.json b/calm-server/package.json index 95e7251aa..7454ef545 100644 --- a/calm-server/package.json +++ b/calm-server/package.json @@ -36,10 +36,12 @@ "express-rate-limit": "^8.0.0" }, "devDependencies": { + "@types/express": "^4.17.21", "@types/node": "^22.15.0", "@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/parser": "^8.0.0", "eslint": "^9.0.0", + "node-fetch": "^2.7.0", "tsup": "^8.3.5", "typescript": "^5.8.2", "vitest": "^3.1.1" diff --git a/calm-server/src/server/routes/health-route.spec.ts b/calm-server/src/server/routes/health-route.spec.ts new file mode 100644 index 000000000..f3b240b69 --- /dev/null +++ b/calm-server/src/server/routes/health-route.spec.ts @@ -0,0 +1,25 @@ +import request from 'supertest'; + +import express, { Application } from 'express'; +import { HealthRouter } from './health-route'; + +describe('HealthRouter', () => { + let app: Application; + + beforeEach(() => { + app = express(); + app.use(express.json()); + + const router: express.Router = express.Router(); + app.use('/health', router); + new HealthRouter(router); + + }); + + test('should return 200 for health check', async () => { + const response = await request(app) + .get('/health'); + expect(response.status).toBe(200); + expect(response.body).toEqual({ status: 'OK' }); + }); +}); diff --git a/calm-server/src/server/routes/routes.spec.ts b/calm-server/src/server/routes/routes.spec.ts new file mode 100644 index 000000000..97d7fd8b5 --- /dev/null +++ b/calm-server/src/server/routes/routes.spec.ts @@ -0,0 +1,56 @@ +import { Router } from 'express'; +import { CLIServerRoutes } from './routes'; +import { ValidationRouter } from './validation-route'; +import { HealthRouter } from './health-route'; +import { SchemaDirectory } from '@finos/calm-shared'; + +const mockUse = vi.fn(); +const mockRouter = { + use: mockUse +}; + +vi.mock('express', () => ({ + Router: vi.fn(() => mockRouter) +})); + +vi.mock('./validation-route', () => { + return { + ValidationRouter: vi.fn() + }; +}); + +vi.mock('./health-route', () => { + return { + HealthRouter: vi.fn() + }; +}); + +vi.mock('@finos/calm-shared', () =>{ + return { + SchemaDirectory: vi.fn() + }; +}); +describe('CLIServerRoutes', () => { + let schemaDirectory: SchemaDirectory; + let cliServerRoutes: CLIServerRoutes; + let mockRouter: Router; + + beforeEach(() => { + cliServerRoutes = new CLIServerRoutes(schemaDirectory); + mockRouter = cliServerRoutes.router; + }); + + it('should initialize router', () => { + expect(Router).toHaveBeenCalled(); + }); + + it('should set up validate route', () => { + expect(mockRouter.use).toHaveBeenCalledWith('/calm/validate', mockRouter); + expect(ValidationRouter).toHaveBeenCalled(); + }); + + it('should set up health route', () => { + expect(mockRouter.use).toHaveBeenCalledWith('/health', mockRouter); + expect(HealthRouter).toHaveBeenCalled(); + }); +}); diff --git a/calm-server/src/server/routes/validation-route.spec.ts b/calm-server/src/server/routes/validation-route.spec.ts new file mode 100644 index 000000000..93f8ae0fb --- /dev/null +++ b/calm-server/src/server/routes/validation-route.spec.ts @@ -0,0 +1,88 @@ +import request from 'supertest'; +import * as fs from 'fs'; + +import express, { Application } from 'express'; +import { ValidationRouter } from './validation-route'; +import path from 'path'; +import { SchemaDirectory } from '@finos/calm-shared'; +import { FileSystemDocumentLoader } from '@finos/calm-shared/dist/document-loader/file-system-document-loader'; + +const schemaDirectoryPath: string = __dirname + '/../../../../calm/release'; +const apiGatewayPatternPath: string = + __dirname + '/../../../test_fixtures/api-gateway'; + +describe('ValidationRouter', () => { + let app: Application; + + beforeEach(() => { + app = express(); + app.use(express.json()); + + const router: express.Router = express.Router(); + new ValidationRouter( + router, + new SchemaDirectory( + new FileSystemDocumentLoader( + [schemaDirectoryPath, apiGatewayPatternPath], + false + ) + ) + ); + app.use('/calm/validate', router); + }); + + test('should return 400 when $schema is not specified', async () => { + const expectedFilePath = path.join( + __dirname, + '../../../test_fixtures/validation_route/invalid_api_gateway_instantiation_missing_schema_key.json' + ); + const invalidArchitectureMissingSchema = JSON.parse( + fs.readFileSync(expectedFilePath, 'utf-8') + ); + const response = await request(app) + .post('/calm/validate') + .send(invalidArchitectureMissingSchema); + + expect(response.status).toBe(400); + expect(response.body).toEqual({ + error: 'The "$schema" field is missing from the request body', + }); + }); + + test('should return 400 when the $schema specified in the instantiation is not found', async () => { + const expectedFilePath = path.join( + __dirname, + '../../../test_fixtures/validation_route/invalid_api_gateway_instantiation_schema_points_to_missing_schema.json' + ); + const invalidArchitectureMissingSchema = JSON.parse( + fs.readFileSync(expectedFilePath, 'utf-8') + ); + const response = await request(app) + .post('/calm/validate') + .send(invalidArchitectureMissingSchema); + + expect(response.status).toBe(400); + expect(response.body).toEqual({ + error: 'The "$schema" field referenced is not available to the server', + }); + }); + + test('should return 201 when the schema is valid', async () => { + const expectedFilePath = path.join( + __dirname, + '../../../test_fixtures/validation_route/valid_instantiation.json' + ); + const validArchitecture = JSON.parse( + fs.readFileSync(expectedFilePath, 'utf-8') + ); + const response = await request(app) + .post('/calm/validate') + .send(validArchitecture); + + expect(response.status).toBe(201); + expect(response.body).toHaveProperty('jsonSchemaValidationOutputs'); + expect(response.body).toHaveProperty('spectralSchemaValidationOutputs'); + expect(response.body).toHaveProperty('hasErrors'); + expect(response.body).toHaveProperty('hasWarnings'); + }); +}); diff --git a/calm-server/test_fixtures/api-gateway/api-gateway.json b/calm-server/test_fixtures/api-gateway/api-gateway.json new file mode 100644 index 000000000..1e9283e88 --- /dev/null +++ b/calm-server/test_fixtures/api-gateway/api-gateway.json @@ -0,0 +1,254 @@ +{ + "$schema": "https://calm.finos.org/release/1.2/meta/calm.json", + "$id": "https://raw.githubusercontent.com/finos/architecture-as-code/main/calm/pattern/api-gateway", + "title": "API Gateway Pattern", + "type": "object", + "properties": { + "nodes": { + "type": "array", + "minItems": 4, + "prefixItems": [ + { + "$ref": "https://calm.finos.org/release/1.2/meta/core.json#/defs/node", + "properties": { + "well-known-endpoint": { + "type": "string" + }, + "description": { + "const": "The API Gateway used to verify authorization and access to downstream system" + }, + "node-type": { + "const": "system" + }, + "name": { + "const": "API Gateway" + }, + "unique-id": { + "const": "api-gateway" + }, + "interfaces": { + "type": "array", + "minItems": 1, + "prefixItems": [ + { + "$ref": "https://calm.finos.org/release/1.0-rc1/meta/interface.json#/defs/host-port-interface", + "properties": { + "unique-id": { + "const": "api-gateway-ingress" + } + } + } + ] + } + }, + "required": [ + "well-known-endpoint", + "interfaces" + ] + }, + { + "$ref": "https://calm.finos.org/release/1.2/meta/core.json#/defs/node", + "properties": { + "description": { + "const": "The API Consumer making an authenticated and authorized request" + }, + "node-type": { + "const": "system" + }, + "name": { + "const": "API Consumer" + }, + "unique-id": { + "const": "api-consumer" + } + } + }, + { + "$ref": "https://calm.finos.org/release/1.2/meta/core.json#/defs/node", + "properties": { + "description": { + "const": "The API Producer serving content" + }, + "node-type": { + "const": "system" + }, + "name": { + "const": "API Producer" + }, + "unique-id": { + "const": "api-producer" + }, + "interfaces": { + "type": "array", + "minItems": 1, + "prefixItems": [ + { + "$ref": "https://calm.finos.org/release/1.2/meta/interface.json#/defs/interface-type", + "properties": { + "unique-id": { + "const": "producer-ingress" + }, + "host": { + "type": "string" + }, + "port": { + "type": "integer" + } + }, + "required": [ + "host", + "port" + ] + } + ] + } + }, + "required": [ + "interfaces" + ] + }, + { + "$ref": "https://calm.finos.org/release/1.2/meta/core.json#/defs/node", + "properties": { + "description": { + "const": "The Identity Provider used to verify the bearer token" + }, + "node-type": { + "const": "system" + }, + "name": { + "const": "Identity Provider" + }, + "unique-id": { + "const": "idp" + } + } + } + ] + }, + "relationships": { + "type": "array", + "minItems": 4, + "prefixItems": [ + { + "$ref": "https://calm.finos.org/release/1.2/meta/core.json#/defs/relationship", + "properties": { + "unique-id": { + "const": "api-consumer-api-gateway" + }, + "description": { + "const": "Issue calculation request" + }, + "relationship-type": { + "const": { + "connects": { + "source": { + "node": "api-consumer" + }, + "destination": { + "node": "api-gateway", + "interfaces": [ + "api-gateway-ingress" + ] + } + } + } + }, + "parties": {}, + "protocol": { + "const": "HTTPS" + }, + "authentication": { + "const": "OAuth2" + } + } + }, + { + "$ref": "https://calm.finos.org/release/1.2/meta/core.json#/defs/relationship", + "properties": { + "unique-id": { + "const": "api-gateway-idp" + }, + "description": { + "const": "Validate bearer token" + }, + "relationship-type": { + "const": { + "connects": { + "source": { + "node": "api-gateway" + }, + "destination": { + "node": "idp" + } + } + } + }, + "protocol": { + "const": "HTTPS" + } + } + }, + { + "$ref": "https://calm.finos.org/release/1.2/meta/core.json#/defs/relationship", + "properties": { + "unique-id": { + "const": "api-gateway-api-producer" + }, + "description": { + "const": "Forward request" + }, + "relationship-type": { + "const": { + "connects": { + "source": { + "node": "api-gateway" + }, + "destination": { + "node": "api-producer", + "interfaces": [ + "producer-ingress" + ] + } + } + } + }, + "protocol": { + "const": "HTTPS" + } + } + }, + { + "$ref": "https://calm.finos.org/release/1.2/meta/core.json#/defs/relationship", + "properties": { + "unique-id": { + "const": "api-consumer-idp" + }, + "description": { + "const": "Acquire a bearer token" + }, + "relationship-type": { + "const": { + "connects": { + "source": { + "node": "api-consumer" + }, + "destination": { + "node": "idp" + } + } + } + }, + "protocol": { + "const": "HTTPS" + } + } + } + ] + } + }, + "required": [ + "nodes", + "relationships" + ] +} diff --git a/calm-server/test_fixtures/validation_route/invalid_api_gateway_instantiation_missing_schema_key.json b/calm-server/test_fixtures/validation_route/invalid_api_gateway_instantiation_missing_schema_key.json new file mode 100644 index 000000000..8d9a36823 --- /dev/null +++ b/calm-server/test_fixtures/validation_route/invalid_api_gateway_instantiation_missing_schema_key.json @@ -0,0 +1,3 @@ +{ + "architecture": "{\"nodes\": [{\"unique-id\": \"api-gateway\",\"node-type\": \"system\",\"name\": \"API Gateway\",\"description\": \"The API Gateway used to verify authorization and access to downstream system\",\"interfaces\": [{\"unique-id\": \"api-gateway-ingress\",\"host\": \"https://api-gateway-ingress.example.com\",\"port\": 6000}],\"well-known-endpoint\": \"/api\"},{\"unique-id\": \"api-consumer\",\"node-type\": \"system\",\"name\": \"API Consumer\",\"description\": \"The API Consumer making an authenticated and authorized request\",\"interfaces\": []},{\"unique-id\": \"api-producer\",\"node-type\": \"system\",\"name\": \"API Producer\",\"description\": \"The API Producer serving content\",\"interfaces\": [{\"unique-id\": \"producer-ingress\",\"host\": \"https://api-producer.example.com\",\"port\": -1}]},{\"unique-id\": \"idp\",\"node-type\": \"system\",\"name\": \"Identity Provider\",\"description\": \"The Identity Provider used to verify the bearer token\",\"interfaces\": []}],\"relationships\": [{\"unique-id\": \"api-consumer-api-gateway\",\"description\": \"Issue calculation request\",\"relationship-type\": {\"connects\": {\"source\": {\"node\": \"api-consumer\"},\"destination\": {\"node\": \"api-gateway\",\"interfaces\": [\"api-gateway-ingress\"]}}},\"protocol\": \"HTTPS\",\"authentication\": \"OAuth2\"},{\"unique-id\": \"api-gateway-idp\",\"description\": \"Validate bearer token\",\"relationship-type\": {\"connects\": {\"source\": {\"node\": \"api-gateway\"},\"destination\": {\"node\": \"idp\"}}},\"protocol\": \"HTTPS\",\"authentication\": \"OAuth2\"},{\"unique-id\": \"api-gateway-api-producer\",\"description\": \"Forward request\",\"relationship-type\": {\"connects\": {\"source\": {\"node\": \"api-gateway\"},\"destination\": {\"node\": \"api-producer\",\"interfaces\": [\"producer-ingress\"]}}},\"protocol\": \"HTTPS\",\"authentication\": \"OAuth2\"},{\"unique-id\": \"api-consumer-idp\",\"description\": \"Acquire a bearer token\",\"relationship-type\": {\"connects\": {\"source\": {\"node\": \"api-consumer\"},\"destination\": {\"node\": \"idp\"}}},\"protocol\": \"HTTPS\",\"authentication\": \"OAuth2\"}]}" +} diff --git a/calm-server/test_fixtures/validation_route/invalid_api_gateway_instantiation_schema_points_to_missing_schema.json b/calm-server/test_fixtures/validation_route/invalid_api_gateway_instantiation_schema_points_to_missing_schema.json new file mode 100644 index 000000000..61aadcd10 --- /dev/null +++ b/calm-server/test_fixtures/validation_route/invalid_api_gateway_instantiation_schema_points_to_missing_schema.json @@ -0,0 +1,3 @@ +{ +"architecture" : "{\"nodes\": [{\"unique-id\": \"api-gateway\",\"node-type\": \"system\",\"name\": \"API Gateway\",\"description\": \"The API Gateway used to verify authorization and access to downstream system\",\"interfaces\": [{\"unique-id\": \"api-gateway-ingress\",\"host\": \"https://api-gateway-ingress.example.com\",\"port\": 6000}],\"well-known-endpoint\": \"/api\"},{\"unique-id\": \"api-consumer\",\"node-type\": \"system\",\"name\": \"API Consumer\",\"description\": \"The API Consumer making an authenticated and authorized request\",\"interfaces\": []},{\"unique-id\": \"api-producer\",\"node-type\": \"system\",\"name\": \"API Producer\",\"description\": \"The API Producer serving content\",\"interfaces\": [{\"unique-id\": \"producer-ingress\",\"host\": \"https://api-producer.example.com\",\"port\": -1}]},{\"unique-id\": \"idp\",\"node-type\": \"system\",\"name\": \"Identity Provider\",\"description\": \"The Identity Provider used to verify the bearer token\",\"interfaces\": []}],\"relationships\": [{\"unique-id\": \"api-consumer-api-gateway\",\"description\": \"Issue calculation request\",\"relationship-type\": {\"connects\": {\"source\": {\"node\": \"api-consumer\"},\"destination\": {\"node\": \"api-gateway\",\"interfaces\": [\"api-gateway-ingress\"]}}},\"protocol\": \"HTTPS\",\"authentication\": \"OAuth2\"},{\"unique-id\": \"api-gateway-idp\",\"description\": \"Validate bearer token\",\"relationship-type\": {\"connects\": {\"source\": {\"node\": \"api-gateway\"},\"destination\": {\"node\": \"idp\"}}},\"protocol\": \"HTTPS\",\"authentication\": \"OAuth2\"},{\"unique-id\": \"api-gateway-api-producer\",\"description\": \"Forward request\",\"relationship-type\": {\"connects\": {\"source\": {\"node\": \"api-gateway\"},\"destination\": {\"node\": \"api-producer\",\"interfaces\": [\"producer-ingress\"]}}},\"protocol\": \"HTTPS\",\"authentication\": \"OAuth2\"},{\"unique-id\": \"api-consumer-idp\",\"description\": \"Acquire a bearer token\",\"relationship-type\": {\"connects\": {\"source\": {\"node\": \"api-consumer\"},\"destination\": {\"node\": \"idp\"}}},\"protocol\": \"HTTPS\",\"authentication\": \"OAuth2\"}],\"$schema\": \"https://raw.githubusercontent.com/finos/architecture-as-code/main/calm/secure-service-pattern.json\"}" +} diff --git a/calm-server/test_fixtures/validation_route/valid_instantiation.json b/calm-server/test_fixtures/validation_route/valid_instantiation.json new file mode 100644 index 000000000..58e4c2ebf --- /dev/null +++ b/calm-server/test_fixtures/validation_route/valid_instantiation.json @@ -0,0 +1,3 @@ +{ + "architecture": "{\"$schema\": \"https://raw.githubusercontent.com/finos/architecture-as-code/main/calm/pattern/api-gateway\",\"nodes\": [{\"unique-id\": \"api-gateway\",\"node-type\": \"system\",\"name\": \"API Gateway\",\"description\": \"The API Gateway used to verify authorization and access to downstream system\",\"interfaces\": [{\"unique-id\": \"api-gateway-ingress\",\"host\": \"https://api-gateway-ingress.example.com\",\"port\": 6000}],\"well-known-endpoint\": \"/api\"},{\"unique-id\": \"api-consumer\",\"node-type\": \"system\",\"name\": \"API Consumer\",\"description\": \"The API Consumer making an authenticated and authorized request\",\"interfaces\": []},{\"unique-id\": \"api-producer\",\"node-type\": \"system\",\"name\": \"API Producer\",\"description\": \"The API Producer serving content\",\"interfaces\": [{\"unique-id\": \"producer-ingress\",\"host\": \"https://api-producer.example.com\",\"port\": 1000}]},{\"unique-id\": \"idp\",\"node-type\": \"system\",\"name\": \"Identity Provider\",\"description\": \"The Identity Provider used to verify the bearer token\",\"interfaces\": []}],\"relationships\": [{\"unique-id\": \"api-consumer-api-gateway\",\"description\": \"Issue calculation request\",\"relationship-type\": {\"connects\": {\"source\": {\"node\": \"api-consumer\"},\"destination\": {\"node\": \"api-gateway\",\"interfaces\": [\"api-gateway-ingress\"]}}},\"protocol\": \"HTTPS\",\"authentication\": \"OAuth2\"},{\"unique-id\": \"api-gateway-idp\",\"description\": \"Validate bearer token\",\"relationship-type\": {\"connects\": {\"source\": {\"node\": \"api-gateway\"},\"destination\": {\"node\": \"idp\"}}},\"protocol\": \"HTTPS\",\"authentication\": \"OAuth2\"},{\"unique-id\": \"api-gateway-api-producer\",\"description\": \"Forward request\",\"relationship-type\": {\"connects\": {\"source\": {\"node\": \"api-gateway\"},\"destination\": {\"node\": \"api-producer\",\"interfaces\": [\"producer-ingress\"]}}},\"protocol\": \"HTTPS\",\"authentication\": \"OAuth2\"},{\"unique-id\": \"api-consumer-idp\",\"description\": \"Acquire a bearer token\",\"relationship-type\": {\"connects\": {\"source\": {\"node\": \"api-consumer\"},\"destination\": {\"node\": \"idp\"}}},\"protocol\": \"HTTPS\",\"authentication\": \"OAuth2\"}]}" +} diff --git a/package-lock.json b/package-lock.json index 6a1c82e14..af8eb037d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.0", "workspaces": [ "calm-models", + "calm-server", "calm-widgets", "calm-ai", "shared", @@ -254,6 +255,299 @@ } } }, + "calm-server": { + "name": "@finos/calm-server", + "version": "0.1.0", + "license": "Apache-2.0", + "dependencies": { + "@finos/calm-shared": "file:../shared", + "commander": "^14.0.0", + "copyfiles": "^2.4.1", + "express": "^4.18.2", + "express-rate-limit": "^8.0.0" + }, + "bin": { + "calm-server": "dist/index.js" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/node": "^22.15.0", + "@typescript-eslint/eslint-plugin": "^8.0.0", + "@typescript-eslint/parser": "^8.0.0", + "eslint": "^9.0.0", + "node-fetch": "^2.7.0", + "tsup": "^8.3.5", + "typescript": "^5.8.2", + "vitest": "^3.1.1" + } + }, + "calm-server/node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "calm-server/node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "calm-server/node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "calm-server/node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "calm-server/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "calm-server/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "calm-server/node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "calm-server/node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "calm-server/node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "calm-server/node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "calm-server/node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "calm-server/node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "calm-server/node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "calm-server/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "calm-server/node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" + }, + "calm-server/node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "calm-server/node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "calm-server/node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "calm-server/node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, "calm-widgets": { "name": "@finos/calm-widgets", "version": "1.0.0", @@ -6899,6 +7193,10 @@ "resolved": "calm-models", "link": true }, + "node_modules/@finos/calm-server": { + "resolved": "calm-server", + "link": true + }, "node_modules/@finos/calm-shared": { "resolved": "shared", "link": true From 39a9951d31546984721cca44c7ddaa8449ebf0d1 Mon Sep 17 00:00:00 2001 From: Mark Scott Date: Mon, 23 Feb 2026 21:55:03 +0000 Subject: [PATCH 05/18] feat(calm-server): listen on 127.0.0.1 by default, with --host option --- calm-server/src/index.ts | 13 +- calm-server/src/server/cli-server.spec.ts | 5 +- calm-server/src/server/cli-server.ts | 6 +- .../src/server/routes/health-route.spec.ts | 2 +- calm-server/src/server/routes/routes.spec.ts | 2 +- .../api-gateway/api-gateway.json | 488 +++++++++--------- ...eway_instantiation_missing_schema_key.json | 4 +- ...ation_schema_points_to_missing_schema.json | 4 +- .../validation_route/valid_instantiation.json | 4 +- 9 files changed, 270 insertions(+), 258 deletions(-) diff --git a/calm-server/src/index.ts b/calm-server/src/index.ts index aed7862f1..1df09bbf0 100644 --- a/calm-server/src/index.ts +++ b/calm-server/src/index.ts @@ -13,6 +13,7 @@ import path from 'path'; const BUNDLED_SCHEMA_PATH = path.join(__dirname, 'calm'); const PORT_OPTION = '--port '; +const HOST_OPTION = '--host '; const SCHEMAS_OPTION = '-s, --schema-directory '; const VERBOSE_OPTION = '-v, --verbose'; const CALMHUB_URL_OPTION = '-c, --calm-hub-url '; @@ -56,16 +57,26 @@ program .version(version) .description('CALM Server - A server implementation for the Common Architecture Language Model') .option(PORT_OPTION, 'Port to run the server on', '3000') + .option(HOST_OPTION, 'Host to bind the server to', '127.0.0.1') .option(SCHEMAS_OPTION, 'Path to the directory containing the meta schemas to use.', BUNDLED_SCHEMA_PATH) .option(VERBOSE_OPTION, 'Enable verbose logging.', false) .option(CALMHUB_URL_OPTION, 'URL to CALMHub instance') .action(async (options) => { try { const debug = !!options.verbose; + const logger = initLogger(debug, 'calm-server'); + + // Warn if host is explicitly provided (not default) + if (options.host && options.host !== '127.0.0.1') { + logger.warn('⚠️ WARNING: Server is configured to listen on ' + options.host); + logger.warn('⚠️ This server has NO authentication or authorization controls.'); + logger.warn('⚠️ Only bind to non-localhost addresses in trusted network environments.'); + } + const docLoaderOpts = await parseDocumentLoaderConfig(options); const docLoader = buildDocumentLoader(docLoaderOpts); const schemaDirectory = await buildSchemaDirectory(docLoader, debug); - startServer(options.port, schemaDirectory, debug); + startServer(options.port, options.host, schemaDirectory, debug); } catch (error) { console.error('Fatal error:', error); process.exit(1); diff --git a/calm-server/src/server/cli-server.spec.ts b/calm-server/src/server/cli-server.spec.ts index 065e7616c..6b503838a 100644 --- a/calm-server/src/server/cli-server.spec.ts +++ b/calm-server/src/server/cli-server.spec.ts @@ -8,6 +8,7 @@ import { vi } from 'vitest'; describe('startServer', () => { let serverInstance: Server; const port = '3001'; + const host = '127.0.0.1'; const schemaDirectory = { loadSchemas: vi.fn(), getSchema: vi.fn(), @@ -20,12 +21,12 @@ describe('startServer', () => { }); it('should start the server and respond to /health', async () => { - serverInstance = startServer(port, schemaDirectory, false); + serverInstance = startServer(port, host, schemaDirectory, false); // Wait for server to be ready await new Promise((resolve) => setTimeout(resolve, 100)); - const response = await fetch(`http://localhost:${port}/health`); + const response = await fetch(`http://${host}:${port}/health`); expect(response.status).toBe(200); const data = (await response.json()) as { status: string }; expect(data.status).toBe('OK'); diff --git a/calm-server/src/server/cli-server.ts b/calm-server/src/server/cli-server.ts index 3e6cb64fc..2c22e95ff 100644 --- a/calm-server/src/server/cli-server.ts +++ b/calm-server/src/server/cli-server.ts @@ -3,7 +3,7 @@ import { CLIServerRoutes } from './routes/routes'; import { initLogger, SchemaDirectory } from '@finos/calm-shared'; import { Server } from 'http'; -export function startServer(port: string, schemaDirectory: SchemaDirectory, verbose: boolean): Server { +export function startServer(port: string, host: string, schemaDirectory: SchemaDirectory, verbose: boolean): Server { const app: Application = express(); const cliServerRoutesInstance = new CLIServerRoutes(schemaDirectory, verbose); const allRoutes = cliServerRoutesInstance.router; @@ -11,8 +11,8 @@ export function startServer(port: string, schemaDirectory: SchemaDirectory, verb app.use(express.json()); app.use('/', allRoutes); - return app.listen(port, () => { + return app.listen(parseInt(port), host, () => { const logger = initLogger(verbose, 'calm-server'); - logger.info(`CALM Server is running on http://localhost:${port}`); + logger.info(`CALM Server is running on http://${host}:${port}`); }); } diff --git a/calm-server/src/server/routes/health-route.spec.ts b/calm-server/src/server/routes/health-route.spec.ts index f3b240b69..09ce23b37 100644 --- a/calm-server/src/server/routes/health-route.spec.ts +++ b/calm-server/src/server/routes/health-route.spec.ts @@ -13,7 +13,7 @@ describe('HealthRouter', () => { const router: express.Router = express.Router(); app.use('/health', router); new HealthRouter(router); - + }); test('should return 200 for health check', async () => { diff --git a/calm-server/src/server/routes/routes.spec.ts b/calm-server/src/server/routes/routes.spec.ts index 97d7fd8b5..a445f7ff1 100644 --- a/calm-server/src/server/routes/routes.spec.ts +++ b/calm-server/src/server/routes/routes.spec.ts @@ -25,7 +25,7 @@ vi.mock('./health-route', () => { }; }); -vi.mock('@finos/calm-shared', () =>{ +vi.mock('@finos/calm-shared', () => { return { SchemaDirectory: vi.fn() }; diff --git a/calm-server/test_fixtures/api-gateway/api-gateway.json b/calm-server/test_fixtures/api-gateway/api-gateway.json index 1e9283e88..b265ecc56 100644 --- a/calm-server/test_fixtures/api-gateway/api-gateway.json +++ b/calm-server/test_fixtures/api-gateway/api-gateway.json @@ -1,254 +1,254 @@ { - "$schema": "https://calm.finos.org/release/1.2/meta/calm.json", - "$id": "https://raw.githubusercontent.com/finos/architecture-as-code/main/calm/pattern/api-gateway", - "title": "API Gateway Pattern", - "type": "object", - "properties": { - "nodes": { - "type": "array", - "minItems": 4, - "prefixItems": [ - { - "$ref": "https://calm.finos.org/release/1.2/meta/core.json#/defs/node", - "properties": { - "well-known-endpoint": { - "type": "string" - }, - "description": { - "const": "The API Gateway used to verify authorization and access to downstream system" - }, - "node-type": { - "const": "system" - }, - "name": { - "const": "API Gateway" - }, - "unique-id": { - "const": "api-gateway" - }, - "interfaces": { - "type": "array", - "minItems": 1, - "prefixItems": [ + "$schema": "https://calm.finos.org/release/1.2/meta/calm.json", + "$id": "https://raw.githubusercontent.com/finos/architecture-as-code/main/calm/pattern/api-gateway", + "title": "API Gateway Pattern", + "type": "object", + "properties": { + "nodes": { + "type": "array", + "minItems": 4, + "prefixItems": [ { - "$ref": "https://calm.finos.org/release/1.0-rc1/meta/interface.json#/defs/host-port-interface", - "properties": { - "unique-id": { - "const": "api-gateway-ingress" + "$ref": "https://calm.finos.org/release/1.2/meta/core.json#/defs/node", + "properties": { + "well-known-endpoint": { + "type": "string" + }, + "description": { + "const": "The API Gateway used to verify authorization and access to downstream system" + }, + "node-type": { + "const": "system" + }, + "name": { + "const": "API Gateway" + }, + "unique-id": { + "const": "api-gateway" + }, + "interfaces": { + "type": "array", + "minItems": 1, + "prefixItems": [ + { + "$ref": "https://calm.finos.org/release/1.0-rc1/meta/interface.json#/defs/host-port-interface", + "properties": { + "unique-id": { + "const": "api-gateway-ingress" + } + } + } + ] + } + }, + "required": [ + "well-known-endpoint", + "interfaces" + ] + }, + { + "$ref": "https://calm.finos.org/release/1.2/meta/core.json#/defs/node", + "properties": { + "description": { + "const": "The API Consumer making an authenticated and authorized request" + }, + "node-type": { + "const": "system" + }, + "name": { + "const": "API Consumer" + }, + "unique-id": { + "const": "api-consumer" + } } - } - } - ] - } - }, - "required": [ - "well-known-endpoint", - "interfaces" - ] - }, - { - "$ref": "https://calm.finos.org/release/1.2/meta/core.json#/defs/node", - "properties": { - "description": { - "const": "The API Consumer making an authenticated and authorized request" - }, - "node-type": { - "const": "system" - }, - "name": { - "const": "API Consumer" - }, - "unique-id": { - "const": "api-consumer" - } - } - }, - { - "$ref": "https://calm.finos.org/release/1.2/meta/core.json#/defs/node", - "properties": { - "description": { - "const": "The API Producer serving content" - }, - "node-type": { - "const": "system" - }, - "name": { - "const": "API Producer" - }, - "unique-id": { - "const": "api-producer" - }, - "interfaces": { - "type": "array", - "minItems": 1, - "prefixItems": [ + }, { - "$ref": "https://calm.finos.org/release/1.2/meta/interface.json#/defs/interface-type", - "properties": { - "unique-id": { - "const": "producer-ingress" - }, - "host": { - "type": "string" + "$ref": "https://calm.finos.org/release/1.2/meta/core.json#/defs/node", + "properties": { + "description": { + "const": "The API Producer serving content" + }, + "node-type": { + "const": "system" + }, + "name": { + "const": "API Producer" + }, + "unique-id": { + "const": "api-producer" + }, + "interfaces": { + "type": "array", + "minItems": 1, + "prefixItems": [ + { + "$ref": "https://calm.finos.org/release/1.2/meta/interface.json#/defs/interface-type", + "properties": { + "unique-id": { + "const": "producer-ingress" + }, + "host": { + "type": "string" + }, + "port": { + "type": "integer" + } + }, + "required": [ + "host", + "port" + ] + } + ] + } }, - "port": { - "type": "integer" - } - }, - "required": [ - "host", - "port" - ] - } - ] - } - }, - "required": [ - "interfaces" - ] - }, - { - "$ref": "https://calm.finos.org/release/1.2/meta/core.json#/defs/node", - "properties": { - "description": { - "const": "The Identity Provider used to verify the bearer token" - }, - "node-type": { - "const": "system" - }, - "name": { - "const": "Identity Provider" - }, - "unique-id": { - "const": "idp" - } - } - } - ] - }, - "relationships": { - "type": "array", - "minItems": 4, - "prefixItems": [ - { - "$ref": "https://calm.finos.org/release/1.2/meta/core.json#/defs/relationship", - "properties": { - "unique-id": { - "const": "api-consumer-api-gateway" - }, - "description": { - "const": "Issue calculation request" - }, - "relationship-type": { - "const": { - "connects": { - "source": { - "node": "api-consumer" - }, - "destination": { - "node": "api-gateway", - "interfaces": [ - "api-gateway-ingress" - ] - } - } - } - }, - "parties": {}, - "protocol": { - "const": "HTTPS" - }, - "authentication": { - "const": "OAuth2" - } - } - }, - { - "$ref": "https://calm.finos.org/release/1.2/meta/core.json#/defs/relationship", - "properties": { - "unique-id": { - "const": "api-gateway-idp" - }, - "description": { - "const": "Validate bearer token" - }, - "relationship-type": { - "const": { - "connects": { - "source": { - "node": "api-gateway" - }, - "destination": { - "node": "idp" - } - } - } - }, - "protocol": { - "const": "HTTPS" - } - } - }, - { - "$ref": "https://calm.finos.org/release/1.2/meta/core.json#/defs/relationship", - "properties": { - "unique-id": { - "const": "api-gateway-api-producer" - }, - "description": { - "const": "Forward request" - }, - "relationship-type": { - "const": { - "connects": { - "source": { - "node": "api-gateway" - }, - "destination": { - "node": "api-producer", - "interfaces": [ - "producer-ingress" + "required": [ + "interfaces" ] - } + }, + { + "$ref": "https://calm.finos.org/release/1.2/meta/core.json#/defs/node", + "properties": { + "description": { + "const": "The Identity Provider used to verify the bearer token" + }, + "node-type": { + "const": "system" + }, + "name": { + "const": "Identity Provider" + }, + "unique-id": { + "const": "idp" + } + } } - } - }, - "protocol": { - "const": "HTTPS" - } - } + ] }, - { - "$ref": "https://calm.finos.org/release/1.2/meta/core.json#/defs/relationship", - "properties": { - "unique-id": { - "const": "api-consumer-idp" - }, - "description": { - "const": "Acquire a bearer token" - }, - "relationship-type": { - "const": { - "connects": { - "source": { - "node": "api-consumer" - }, - "destination": { - "node": "idp" - } + "relationships": { + "type": "array", + "minItems": 4, + "prefixItems": [ + { + "$ref": "https://calm.finos.org/release/1.2/meta/core.json#/defs/relationship", + "properties": { + "unique-id": { + "const": "api-consumer-api-gateway" + }, + "description": { + "const": "Issue calculation request" + }, + "relationship-type": { + "const": { + "connects": { + "source": { + "node": "api-consumer" + }, + "destination": { + "node": "api-gateway", + "interfaces": [ + "api-gateway-ingress" + ] + } + } + } + }, + "parties": {}, + "protocol": { + "const": "HTTPS" + }, + "authentication": { + "const": "OAuth2" + } + } + }, + { + "$ref": "https://calm.finos.org/release/1.2/meta/core.json#/defs/relationship", + "properties": { + "unique-id": { + "const": "api-gateway-idp" + }, + "description": { + "const": "Validate bearer token" + }, + "relationship-type": { + "const": { + "connects": { + "source": { + "node": "api-gateway" + }, + "destination": { + "node": "idp" + } + } + } + }, + "protocol": { + "const": "HTTPS" + } + } + }, + { + "$ref": "https://calm.finos.org/release/1.2/meta/core.json#/defs/relationship", + "properties": { + "unique-id": { + "const": "api-gateway-api-producer" + }, + "description": { + "const": "Forward request" + }, + "relationship-type": { + "const": { + "connects": { + "source": { + "node": "api-gateway" + }, + "destination": { + "node": "api-producer", + "interfaces": [ + "producer-ingress" + ] + } + } + } + }, + "protocol": { + "const": "HTTPS" + } + } + }, + { + "$ref": "https://calm.finos.org/release/1.2/meta/core.json#/defs/relationship", + "properties": { + "unique-id": { + "const": "api-consumer-idp" + }, + "description": { + "const": "Acquire a bearer token" + }, + "relationship-type": { + "const": { + "connects": { + "source": { + "node": "api-consumer" + }, + "destination": { + "node": "idp" + } + } + } + }, + "protocol": { + "const": "HTTPS" + } + } } - } - }, - "protocol": { - "const": "HTTPS" - } - } + ] } - ] - } - }, - "required": [ - "nodes", - "relationships" - ] -} + }, + "required": [ + "nodes", + "relationships" + ] +} \ No newline at end of file diff --git a/calm-server/test_fixtures/validation_route/invalid_api_gateway_instantiation_missing_schema_key.json b/calm-server/test_fixtures/validation_route/invalid_api_gateway_instantiation_missing_schema_key.json index 8d9a36823..390cd0800 100644 --- a/calm-server/test_fixtures/validation_route/invalid_api_gateway_instantiation_missing_schema_key.json +++ b/calm-server/test_fixtures/validation_route/invalid_api_gateway_instantiation_missing_schema_key.json @@ -1,3 +1,3 @@ { - "architecture": "{\"nodes\": [{\"unique-id\": \"api-gateway\",\"node-type\": \"system\",\"name\": \"API Gateway\",\"description\": \"The API Gateway used to verify authorization and access to downstream system\",\"interfaces\": [{\"unique-id\": \"api-gateway-ingress\",\"host\": \"https://api-gateway-ingress.example.com\",\"port\": 6000}],\"well-known-endpoint\": \"/api\"},{\"unique-id\": \"api-consumer\",\"node-type\": \"system\",\"name\": \"API Consumer\",\"description\": \"The API Consumer making an authenticated and authorized request\",\"interfaces\": []},{\"unique-id\": \"api-producer\",\"node-type\": \"system\",\"name\": \"API Producer\",\"description\": \"The API Producer serving content\",\"interfaces\": [{\"unique-id\": \"producer-ingress\",\"host\": \"https://api-producer.example.com\",\"port\": -1}]},{\"unique-id\": \"idp\",\"node-type\": \"system\",\"name\": \"Identity Provider\",\"description\": \"The Identity Provider used to verify the bearer token\",\"interfaces\": []}],\"relationships\": [{\"unique-id\": \"api-consumer-api-gateway\",\"description\": \"Issue calculation request\",\"relationship-type\": {\"connects\": {\"source\": {\"node\": \"api-consumer\"},\"destination\": {\"node\": \"api-gateway\",\"interfaces\": [\"api-gateway-ingress\"]}}},\"protocol\": \"HTTPS\",\"authentication\": \"OAuth2\"},{\"unique-id\": \"api-gateway-idp\",\"description\": \"Validate bearer token\",\"relationship-type\": {\"connects\": {\"source\": {\"node\": \"api-gateway\"},\"destination\": {\"node\": \"idp\"}}},\"protocol\": \"HTTPS\",\"authentication\": \"OAuth2\"},{\"unique-id\": \"api-gateway-api-producer\",\"description\": \"Forward request\",\"relationship-type\": {\"connects\": {\"source\": {\"node\": \"api-gateway\"},\"destination\": {\"node\": \"api-producer\",\"interfaces\": [\"producer-ingress\"]}}},\"protocol\": \"HTTPS\",\"authentication\": \"OAuth2\"},{\"unique-id\": \"api-consumer-idp\",\"description\": \"Acquire a bearer token\",\"relationship-type\": {\"connects\": {\"source\": {\"node\": \"api-consumer\"},\"destination\": {\"node\": \"idp\"}}},\"protocol\": \"HTTPS\",\"authentication\": \"OAuth2\"}]}" -} + "architecture": "{\"nodes\": [{\"unique-id\": \"api-gateway\",\"node-type\": \"system\",\"name\": \"API Gateway\",\"description\": \"The API Gateway used to verify authorization and access to downstream system\",\"interfaces\": [{\"unique-id\": \"api-gateway-ingress\",\"host\": \"https://api-gateway-ingress.example.com\",\"port\": 6000}],\"well-known-endpoint\": \"/api\"},{\"unique-id\": \"api-consumer\",\"node-type\": \"system\",\"name\": \"API Consumer\",\"description\": \"The API Consumer making an authenticated and authorized request\",\"interfaces\": []},{\"unique-id\": \"api-producer\",\"node-type\": \"system\",\"name\": \"API Producer\",\"description\": \"The API Producer serving content\",\"interfaces\": [{\"unique-id\": \"producer-ingress\",\"host\": \"https://api-producer.example.com\",\"port\": -1}]},{\"unique-id\": \"idp\",\"node-type\": \"system\",\"name\": \"Identity Provider\",\"description\": \"The Identity Provider used to verify the bearer token\",\"interfaces\": []}],\"relationships\": [{\"unique-id\": \"api-consumer-api-gateway\",\"description\": \"Issue calculation request\",\"relationship-type\": {\"connects\": {\"source\": {\"node\": \"api-consumer\"},\"destination\": {\"node\": \"api-gateway\",\"interfaces\": [\"api-gateway-ingress\"]}}},\"protocol\": \"HTTPS\",\"authentication\": \"OAuth2\"},{\"unique-id\": \"api-gateway-idp\",\"description\": \"Validate bearer token\",\"relationship-type\": {\"connects\": {\"source\": {\"node\": \"api-gateway\"},\"destination\": {\"node\": \"idp\"}}},\"protocol\": \"HTTPS\",\"authentication\": \"OAuth2\"},{\"unique-id\": \"api-gateway-api-producer\",\"description\": \"Forward request\",\"relationship-type\": {\"connects\": {\"source\": {\"node\": \"api-gateway\"},\"destination\": {\"node\": \"api-producer\",\"interfaces\": [\"producer-ingress\"]}}},\"protocol\": \"HTTPS\",\"authentication\": \"OAuth2\"},{\"unique-id\": \"api-consumer-idp\",\"description\": \"Acquire a bearer token\",\"relationship-type\": {\"connects\": {\"source\": {\"node\": \"api-consumer\"},\"destination\": {\"node\": \"idp\"}}},\"protocol\": \"HTTPS\",\"authentication\": \"OAuth2\"}]}" +} \ No newline at end of file diff --git a/calm-server/test_fixtures/validation_route/invalid_api_gateway_instantiation_schema_points_to_missing_schema.json b/calm-server/test_fixtures/validation_route/invalid_api_gateway_instantiation_schema_points_to_missing_schema.json index 61aadcd10..58367a646 100644 --- a/calm-server/test_fixtures/validation_route/invalid_api_gateway_instantiation_schema_points_to_missing_schema.json +++ b/calm-server/test_fixtures/validation_route/invalid_api_gateway_instantiation_schema_points_to_missing_schema.json @@ -1,3 +1,3 @@ { -"architecture" : "{\"nodes\": [{\"unique-id\": \"api-gateway\",\"node-type\": \"system\",\"name\": \"API Gateway\",\"description\": \"The API Gateway used to verify authorization and access to downstream system\",\"interfaces\": [{\"unique-id\": \"api-gateway-ingress\",\"host\": \"https://api-gateway-ingress.example.com\",\"port\": 6000}],\"well-known-endpoint\": \"/api\"},{\"unique-id\": \"api-consumer\",\"node-type\": \"system\",\"name\": \"API Consumer\",\"description\": \"The API Consumer making an authenticated and authorized request\",\"interfaces\": []},{\"unique-id\": \"api-producer\",\"node-type\": \"system\",\"name\": \"API Producer\",\"description\": \"The API Producer serving content\",\"interfaces\": [{\"unique-id\": \"producer-ingress\",\"host\": \"https://api-producer.example.com\",\"port\": -1}]},{\"unique-id\": \"idp\",\"node-type\": \"system\",\"name\": \"Identity Provider\",\"description\": \"The Identity Provider used to verify the bearer token\",\"interfaces\": []}],\"relationships\": [{\"unique-id\": \"api-consumer-api-gateway\",\"description\": \"Issue calculation request\",\"relationship-type\": {\"connects\": {\"source\": {\"node\": \"api-consumer\"},\"destination\": {\"node\": \"api-gateway\",\"interfaces\": [\"api-gateway-ingress\"]}}},\"protocol\": \"HTTPS\",\"authentication\": \"OAuth2\"},{\"unique-id\": \"api-gateway-idp\",\"description\": \"Validate bearer token\",\"relationship-type\": {\"connects\": {\"source\": {\"node\": \"api-gateway\"},\"destination\": {\"node\": \"idp\"}}},\"protocol\": \"HTTPS\",\"authentication\": \"OAuth2\"},{\"unique-id\": \"api-gateway-api-producer\",\"description\": \"Forward request\",\"relationship-type\": {\"connects\": {\"source\": {\"node\": \"api-gateway\"},\"destination\": {\"node\": \"api-producer\",\"interfaces\": [\"producer-ingress\"]}}},\"protocol\": \"HTTPS\",\"authentication\": \"OAuth2\"},{\"unique-id\": \"api-consumer-idp\",\"description\": \"Acquire a bearer token\",\"relationship-type\": {\"connects\": {\"source\": {\"node\": \"api-consumer\"},\"destination\": {\"node\": \"idp\"}}},\"protocol\": \"HTTPS\",\"authentication\": \"OAuth2\"}],\"$schema\": \"https://raw.githubusercontent.com/finos/architecture-as-code/main/calm/secure-service-pattern.json\"}" -} + "architecture": "{\"nodes\": [{\"unique-id\": \"api-gateway\",\"node-type\": \"system\",\"name\": \"API Gateway\",\"description\": \"The API Gateway used to verify authorization and access to downstream system\",\"interfaces\": [{\"unique-id\": \"api-gateway-ingress\",\"host\": \"https://api-gateway-ingress.example.com\",\"port\": 6000}],\"well-known-endpoint\": \"/api\"},{\"unique-id\": \"api-consumer\",\"node-type\": \"system\",\"name\": \"API Consumer\",\"description\": \"The API Consumer making an authenticated and authorized request\",\"interfaces\": []},{\"unique-id\": \"api-producer\",\"node-type\": \"system\",\"name\": \"API Producer\",\"description\": \"The API Producer serving content\",\"interfaces\": [{\"unique-id\": \"producer-ingress\",\"host\": \"https://api-producer.example.com\",\"port\": -1}]},{\"unique-id\": \"idp\",\"node-type\": \"system\",\"name\": \"Identity Provider\",\"description\": \"The Identity Provider used to verify the bearer token\",\"interfaces\": []}],\"relationships\": [{\"unique-id\": \"api-consumer-api-gateway\",\"description\": \"Issue calculation request\",\"relationship-type\": {\"connects\": {\"source\": {\"node\": \"api-consumer\"},\"destination\": {\"node\": \"api-gateway\",\"interfaces\": [\"api-gateway-ingress\"]}}},\"protocol\": \"HTTPS\",\"authentication\": \"OAuth2\"},{\"unique-id\": \"api-gateway-idp\",\"description\": \"Validate bearer token\",\"relationship-type\": {\"connects\": {\"source\": {\"node\": \"api-gateway\"},\"destination\": {\"node\": \"idp\"}}},\"protocol\": \"HTTPS\",\"authentication\": \"OAuth2\"},{\"unique-id\": \"api-gateway-api-producer\",\"description\": \"Forward request\",\"relationship-type\": {\"connects\": {\"source\": {\"node\": \"api-gateway\"},\"destination\": {\"node\": \"api-producer\",\"interfaces\": [\"producer-ingress\"]}}},\"protocol\": \"HTTPS\",\"authentication\": \"OAuth2\"},{\"unique-id\": \"api-consumer-idp\",\"description\": \"Acquire a bearer token\",\"relationship-type\": {\"connects\": {\"source\": {\"node\": \"api-consumer\"},\"destination\": {\"node\": \"idp\"}}},\"protocol\": \"HTTPS\",\"authentication\": \"OAuth2\"}],\"$schema\": \"https://raw.githubusercontent.com/finos/architecture-as-code/main/calm/secure-service-pattern.json\"}" +} \ No newline at end of file diff --git a/calm-server/test_fixtures/validation_route/valid_instantiation.json b/calm-server/test_fixtures/validation_route/valid_instantiation.json index 58e4c2ebf..ef16095ec 100644 --- a/calm-server/test_fixtures/validation_route/valid_instantiation.json +++ b/calm-server/test_fixtures/validation_route/valid_instantiation.json @@ -1,3 +1,3 @@ { - "architecture": "{\"$schema\": \"https://raw.githubusercontent.com/finos/architecture-as-code/main/calm/pattern/api-gateway\",\"nodes\": [{\"unique-id\": \"api-gateway\",\"node-type\": \"system\",\"name\": \"API Gateway\",\"description\": \"The API Gateway used to verify authorization and access to downstream system\",\"interfaces\": [{\"unique-id\": \"api-gateway-ingress\",\"host\": \"https://api-gateway-ingress.example.com\",\"port\": 6000}],\"well-known-endpoint\": \"/api\"},{\"unique-id\": \"api-consumer\",\"node-type\": \"system\",\"name\": \"API Consumer\",\"description\": \"The API Consumer making an authenticated and authorized request\",\"interfaces\": []},{\"unique-id\": \"api-producer\",\"node-type\": \"system\",\"name\": \"API Producer\",\"description\": \"The API Producer serving content\",\"interfaces\": [{\"unique-id\": \"producer-ingress\",\"host\": \"https://api-producer.example.com\",\"port\": 1000}]},{\"unique-id\": \"idp\",\"node-type\": \"system\",\"name\": \"Identity Provider\",\"description\": \"The Identity Provider used to verify the bearer token\",\"interfaces\": []}],\"relationships\": [{\"unique-id\": \"api-consumer-api-gateway\",\"description\": \"Issue calculation request\",\"relationship-type\": {\"connects\": {\"source\": {\"node\": \"api-consumer\"},\"destination\": {\"node\": \"api-gateway\",\"interfaces\": [\"api-gateway-ingress\"]}}},\"protocol\": \"HTTPS\",\"authentication\": \"OAuth2\"},{\"unique-id\": \"api-gateway-idp\",\"description\": \"Validate bearer token\",\"relationship-type\": {\"connects\": {\"source\": {\"node\": \"api-gateway\"},\"destination\": {\"node\": \"idp\"}}},\"protocol\": \"HTTPS\",\"authentication\": \"OAuth2\"},{\"unique-id\": \"api-gateway-api-producer\",\"description\": \"Forward request\",\"relationship-type\": {\"connects\": {\"source\": {\"node\": \"api-gateway\"},\"destination\": {\"node\": \"api-producer\",\"interfaces\": [\"producer-ingress\"]}}},\"protocol\": \"HTTPS\",\"authentication\": \"OAuth2\"},{\"unique-id\": \"api-consumer-idp\",\"description\": \"Acquire a bearer token\",\"relationship-type\": {\"connects\": {\"source\": {\"node\": \"api-consumer\"},\"destination\": {\"node\": \"idp\"}}},\"protocol\": \"HTTPS\",\"authentication\": \"OAuth2\"}]}" -} + "architecture": "{\"$schema\": \"https://raw.githubusercontent.com/finos/architecture-as-code/main/calm/pattern/api-gateway\",\"nodes\": [{\"unique-id\": \"api-gateway\",\"node-type\": \"system\",\"name\": \"API Gateway\",\"description\": \"The API Gateway used to verify authorization and access to downstream system\",\"interfaces\": [{\"unique-id\": \"api-gateway-ingress\",\"host\": \"https://api-gateway-ingress.example.com\",\"port\": 6000}],\"well-known-endpoint\": \"/api\"},{\"unique-id\": \"api-consumer\",\"node-type\": \"system\",\"name\": \"API Consumer\",\"description\": \"The API Consumer making an authenticated and authorized request\",\"interfaces\": []},{\"unique-id\": \"api-producer\",\"node-type\": \"system\",\"name\": \"API Producer\",\"description\": \"The API Producer serving content\",\"interfaces\": [{\"unique-id\": \"producer-ingress\",\"host\": \"https://api-producer.example.com\",\"port\": 1000}]},{\"unique-id\": \"idp\",\"node-type\": \"system\",\"name\": \"Identity Provider\",\"description\": \"The Identity Provider used to verify the bearer token\",\"interfaces\": []}],\"relationships\": [{\"unique-id\": \"api-consumer-api-gateway\",\"description\": \"Issue calculation request\",\"relationship-type\": {\"connects\": {\"source\": {\"node\": \"api-consumer\"},\"destination\": {\"node\": \"api-gateway\",\"interfaces\": [\"api-gateway-ingress\"]}}},\"protocol\": \"HTTPS\",\"authentication\": \"OAuth2\"},{\"unique-id\": \"api-gateway-idp\",\"description\": \"Validate bearer token\",\"relationship-type\": {\"connects\": {\"source\": {\"node\": \"api-gateway\"},\"destination\": {\"node\": \"idp\"}}},\"protocol\": \"HTTPS\",\"authentication\": \"OAuth2\"},{\"unique-id\": \"api-gateway-api-producer\",\"description\": \"Forward request\",\"relationship-type\": {\"connects\": {\"source\": {\"node\": \"api-gateway\"},\"destination\": {\"node\": \"api-producer\",\"interfaces\": [\"producer-ingress\"]}}},\"protocol\": \"HTTPS\",\"authentication\": \"OAuth2\"},{\"unique-id\": \"api-consumer-idp\",\"description\": \"Acquire a bearer token\",\"relationship-type\": {\"connects\": {\"source\": {\"node\": \"api-consumer\"},\"destination\": {\"node\": \"idp\"}}},\"protocol\": \"HTTPS\",\"authentication\": \"OAuth2\"}]}" +} \ No newline at end of file From 4b1ca5bf14f5d69a1b804e172905944d751bb8cc Mon Sep 17 00:00:00 2001 From: Mark Scott Date: Tue, 24 Feb 2026 20:17:20 +0000 Subject: [PATCH 06/18] feat(calm-server): rate limiting options rather than hardcoded --- calm-server/src/index.ts | 15 ++++++++++++++- calm-server/src/server/cli-server.spec.ts | 2 +- calm-server/src/server/cli-server.ts | 16 ++++++++++++++-- calm-server/src/server/routes/routes.ts | 9 +++++++-- .../src/server/routes/validation-route.ts | 17 +++++++++++------ 5 files changed, 47 insertions(+), 12 deletions(-) diff --git a/calm-server/src/index.ts b/calm-server/src/index.ts index 1df09bbf0..ae12f45cc 100644 --- a/calm-server/src/index.ts +++ b/calm-server/src/index.ts @@ -17,11 +17,15 @@ const HOST_OPTION = '--host '; const SCHEMAS_OPTION = '-s, --schema-directory '; const VERBOSE_OPTION = '-v, --verbose'; const CALMHUB_URL_OPTION = '-c, --calm-hub-url '; +const RATE_LIMIT_WINDOW_OPTION = '--rate-limit-window '; +const RATE_LIMIT_MAX_OPTION = '--rate-limit-max '; interface ParseDocumentLoaderOptions { verbose?: boolean; calmHubUrl?: string; schemaDirectory?: string; + rateLimitWindow?: number; + rateLimitMax?: number; } async function parseDocumentLoaderConfig( @@ -61,6 +65,8 @@ program .option(SCHEMAS_OPTION, 'Path to the directory containing the meta schemas to use.', BUNDLED_SCHEMA_PATH) .option(VERBOSE_OPTION, 'Enable verbose logging.', false) .option(CALMHUB_URL_OPTION, 'URL to CALMHub instance') + .option(RATE_LIMIT_WINDOW_OPTION, 'Rate limit window in milliseconds (default: 900000 = 15 minutes)', '900000') + .option(RATE_LIMIT_MAX_OPTION, 'Max requests per IP within the rate limit window (default: 100)', '100') .action(async (options) => { try { const debug = !!options.verbose; @@ -76,7 +82,14 @@ program const docLoaderOpts = await parseDocumentLoaderConfig(options); const docLoader = buildDocumentLoader(docLoaderOpts); const schemaDirectory = await buildSchemaDirectory(docLoader, debug); - startServer(options.port, options.host, schemaDirectory, debug); + startServer( + options.port, + options.host, + schemaDirectory, + debug, + parseInt(options.rateLimitWindow), + parseInt(options.rateLimitMax) + ); } catch (error) { console.error('Fatal error:', error); process.exit(1); diff --git a/calm-server/src/server/cli-server.spec.ts b/calm-server/src/server/cli-server.spec.ts index 6b503838a..025dc4c43 100644 --- a/calm-server/src/server/cli-server.spec.ts +++ b/calm-server/src/server/cli-server.spec.ts @@ -21,7 +21,7 @@ describe('startServer', () => { }); it('should start the server and respond to /health', async () => { - serverInstance = startServer(port, host, schemaDirectory, false); + serverInstance = startServer(port, host, schemaDirectory, false, 900000, 100); // Wait for server to be ready await new Promise((resolve) => setTimeout(resolve, 100)); diff --git a/calm-server/src/server/cli-server.ts b/calm-server/src/server/cli-server.ts index 2c22e95ff..ba72ec669 100644 --- a/calm-server/src/server/cli-server.ts +++ b/calm-server/src/server/cli-server.ts @@ -3,9 +3,21 @@ import { CLIServerRoutes } from './routes/routes'; import { initLogger, SchemaDirectory } from '@finos/calm-shared'; import { Server } from 'http'; -export function startServer(port: string, host: string, schemaDirectory: SchemaDirectory, verbose: boolean): Server { +export function startServer( + port: string, + host: string, + schemaDirectory: SchemaDirectory, + verbose: boolean, + rateLimitWindowMs: number = 900000, // 15 minutes + rateLimitMaxRequests: number = 100 +): Server { const app: Application = express(); - const cliServerRoutesInstance = new CLIServerRoutes(schemaDirectory, verbose); + const cliServerRoutesInstance = new CLIServerRoutes( + schemaDirectory, + verbose, + rateLimitWindowMs, + rateLimitMaxRequests + ); const allRoutes = cliServerRoutesInstance.router; app.use(express.json()); diff --git a/calm-server/src/server/routes/routes.ts b/calm-server/src/server/routes/routes.ts index 920a8f737..cf284d636 100644 --- a/calm-server/src/server/routes/routes.ts +++ b/calm-server/src/server/routes/routes.ts @@ -9,10 +9,15 @@ const VALIDATE_ROUTE_PATH = '/calm/validate'; export class CLIServerRoutes { router: Router; - constructor(schemaDirectory: SchemaDirectory, debug: boolean = false) { + constructor( + schemaDirectory: SchemaDirectory, + debug: boolean = false, + rateLimitWindowMs: number = 900000, // 15 minutes + rateLimitMaxRequests: number = 100 + ) { this.router = Router(); const validateRoute = this.router.use(VALIDATE_ROUTE_PATH, this.router); - new ValidationRouter(validateRoute, schemaDirectory, debug); + new ValidationRouter(validateRoute, schemaDirectory, debug, rateLimitWindowMs, rateLimitMaxRequests); const healthRoute = this.router.use(HEALTH_ROUTE_PATH, this.router); new HealthRouter(healthRoute); } diff --git a/calm-server/src/server/routes/validation-route.ts b/calm-server/src/server/routes/validation-route.ts index ad9753f88..fd7453e2f 100644 --- a/calm-server/src/server/routes/validation-route.ts +++ b/calm-server/src/server/routes/validation-route.ts @@ -1,17 +1,22 @@ -import { SchemaDirectory, validate } from '@finos/calm-shared'; +import { SchemaDirectory, validate, ValidationOutcome, initLogger } from '@finos/calm-shared'; +import type { Logger } from '@finos/calm-shared/dist/logger'; import { Router, Request, Response } from 'express'; -import { ValidationOutcome } from '@finos/calm-shared'; import rateLimit from 'express-rate-limit'; -import { initLogger, Logger } from '@finos/calm-shared/dist/logger'; export class ValidationRouter { private schemaDirectory: SchemaDirectory; private logger: Logger; - constructor(router: Router, schemaDirectory: SchemaDirectory, debug: boolean = false) { + constructor( + router: Router, + schemaDirectory: SchemaDirectory, + debug: boolean = false, + rateLimitWindowMs: number = 900000, // 15 minutes + rateLimitMaxRequests: number = 100 + ) { const limiter = rateLimit({ - windowMs: 15 * 60 * 1000, // 15 minutes - max: 100, // limit each IP to 100 requests per windowMs + windowMs: rateLimitWindowMs, + max: rateLimitMaxRequests, }); this.schemaDirectory = schemaDirectory; this.logger = initLogger(debug, 'calm-server'); From e9df2d865c55bed743cfe703af2e4a29708b2bfd Mon Sep 17 00:00:00 2001 From: Mark Scott Date: Wed, 25 Feb 2026 08:27:24 +0000 Subject: [PATCH 07/18] feat(calm-server): remove cli config - expect options on the command line --- calm-server/src/cli-config.ts | 32 -------------------------------- calm-server/src/index.ts | 6 ------ 2 files changed, 38 deletions(-) delete mode 100644 calm-server/src/cli-config.ts diff --git a/calm-server/src/cli-config.ts b/calm-server/src/cli-config.ts deleted file mode 100644 index a50b67438..000000000 --- a/calm-server/src/cli-config.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { initLogger } from '@finos/calm-shared'; -import { readFile } from 'fs/promises'; -import { homedir } from 'os'; -import { join } from 'path'; - -export interface CLIConfig { - calmHubUrl?: string; -} - -function getUserConfigLocation(): string { - const homeDir = homedir(); - return join(homeDir, '.calm.json'); -} - -export async function loadCliConfig(): Promise { - const logger = initLogger(false, 'calm-server'); - - const configFilePath = getUserConfigLocation(); - try { - const config = await readFile(configFilePath, 'utf8'); - const parsed = JSON.parse(config) as CLIConfig; - logger.debug('Parsed user config: ' + config); - return parsed; - } catch (err) { - if ((err as NodeJS.ErrnoException).code === 'ENOENT') { - logger.debug('No config file found at ' + configFilePath); - return null; - } - logger.error('Unexpected error loading user config: ', err); - return null; - } -} diff --git a/calm-server/src/index.ts b/calm-server/src/index.ts index ae12f45cc..9b8214aa3 100644 --- a/calm-server/src/index.ts +++ b/calm-server/src/index.ts @@ -7,7 +7,6 @@ import { version } from '../package.json'; import { startServer } from './server/cli-server'; import { SchemaDirectory, initLogger } from '@finos/calm-shared'; import { buildDocumentLoader, DocumentLoader, DocumentLoaderOptions } from '@finos/calm-shared/dist/document-loader/document-loader'; -import { loadCliConfig } from './cli-config'; import path from 'path'; const BUNDLED_SCHEMA_PATH = path.join(__dirname, 'calm'); @@ -42,11 +41,6 @@ async function parseDocumentLoaderConfig( debug: !!options.verbose }; - const userConfig = await loadCliConfig(); - if (userConfig && userConfig.calmHubUrl && !options.calmHubUrl) { - logger.info('Using CALMHub URL from config file: ' + userConfig.calmHubUrl); - docLoaderOpts.calmHubUrl = userConfig.calmHubUrl; - } return docLoaderOpts; } From bb7023806afea4e46d83ba2f7385b3537aad979b Mon Sep 17 00:00:00 2001 From: Mark Scott Date: Wed, 25 Feb 2026 08:34:13 +0000 Subject: [PATCH 08/18] feat(calm-server): update README and AGENTS.md --- calm-server/AGENTS.md | 28 ++++++++++++++++++---------- calm-server/README.md | 23 ++++++++++++++++------- 2 files changed, 34 insertions(+), 17 deletions(-) diff --git a/calm-server/AGENTS.md b/calm-server/AGENTS.md index 20fffc63c..aee6d18a0 100644 --- a/calm-server/AGENTS.md +++ b/calm-server/AGENTS.md @@ -1,10 +1,10 @@ # @finos/calm-server -The `calm-server` executable provides a standalone HTTP server implementation of CALM functionality. +The `calm-server` executable provides a standalone HTTP server implementation of CALM validation functionality. ## Architecture -The calm-server implements the same server functionality as the `calm server` CLI command, providing: +The calm-server provides: - **Bundled CALM Schemas** - All CALM schemas (release and draft) are bundled during build - **Health Check Endpoint** (`/health`) - Status endpoint for monitoring @@ -17,7 +17,6 @@ The calm-server implements the same server functionality as the `calm server` CL calm-server/ ├── src/ │ ├── index.ts # Entry point, CLI argument parsing -│ ├── cli-config.ts # Load user CALM configuration │ ├── server/ │ │ ├── cli-server.ts # Express server startup │ │ └── routes/ @@ -79,15 +78,24 @@ Usage: calm-server [options] CALM Server - A server implementation for the Common Architecture Language Model Options: - -V, --version output the version number - --port Port to run the server on (default: "3000") - -s, --schema-directory Path to the directory containing the meta schemas to use. - (default: bundled schemas in dist/calm) - -v, --verbose Enable verbose logging. (default: false) - -c, --calm-hub-url URL to CALMHub instance - -h, --help display help for message + -V, --version output the version number + --port Port to run the server on (default: "3000") + --host Host to bind the server to (default: "127.0.0.1") + -s, --schema-directory Path to the directory containing the meta schemas to use. + (default: bundled schemas in dist/calm) + -v, --verbose Enable verbose logging (default: false) + -c, --calm-hub-url URL to CALMHub instance + --rate-limit-window Rate limit window in milliseconds (default: 900000 = 15 minutes) + --rate-limit-max Max requests per IP within the rate limit window (default: 100) + -h, --help display help for command ``` +### Security Notes + +- **Default Host**: The server binds to `127.0.0.1` (localhost) by default +- **No Authentication**: The server has **NO authentication or authorization controls** +- **Network Exposure Warning**: If you bind to a non-localhost host (e.g., `0.0.0.0`, `::`, public IP), a warning will be logged. Only do this in trusted network environments + ## Testing ### Run tests diff --git a/calm-server/README.md b/calm-server/README.md index f317fa695..70bc0ef3b 100644 --- a/calm-server/README.md +++ b/calm-server/README.md @@ -34,15 +34,24 @@ calm-server -s /path/to/calm/schemas --port 3000 Usage: calm-server [options] Options: - -V, --version output the version number - --port Port to run the server on (default: "3000") - -s, --schema-directory Path to the directory containing the meta schemas - (default: bundled schemas in dist/calm) - -v, --verbose Enable verbose logging (default: false) - -c, --calm-hub-url URL to CALMHub instance - -h, --help display help for command + -V, --version output the version number + --port Port to run the server on (default: "3000") + --host Host to bind the server to (default: "127.0.0.1") + -s, --schema-directory Path to the directory containing the meta schemas + (default: bundled schemas in dist/calm) + -v, --verbose Enable verbose logging (default: false) + -c, --calm-hub-url URL to CALMHub instance + --rate-limit-window Rate limit window in milliseconds (default: 900000 = 15 minutes) + --rate-limit-max Max requests per IP within the rate limit window (default: 100) + -h, --help display help for command ``` +### Security Considerations + +- **Default Host is Localhost**: By default, the server binds to `127.0.0.1` for security +- **No Built-in Authentication**: This server has no authentication or authorization controls +- **Network Exposure**: When binding to non-localhost addresses, a security warning is logged. Only expose to the network in trusted environments + ## API Endpoints ### Health Check From 802adef7c289c626f1292dcb40dbf1a9f4ab63c5 Mon Sep 17 00:00:00 2001 From: Mark Scott Date: Wed, 25 Feb 2026 08:53:28 +0000 Subject: [PATCH 09/18] feat(ci): add calm-server build workflow --- .github/workflows/build-calm-server.yml | 40 +++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 .github/workflows/build-calm-server.yml diff --git a/.github/workflows/build-calm-server.yml b/.github/workflows/build-calm-server.yml new file mode 100644 index 000000000..7fcb5d26f --- /dev/null +++ b/.github/workflows/build-calm-server.yml @@ -0,0 +1,40 @@ +name: Build CALM Server + +permissions: + contents: read + +on: + pull_request: + branches: + - 'main' + - 'release*' + push: + branches: + - 'main' + - 'release*' + +jobs: + calm-server: + name: Build, Test, and Lint CALM Server Module + runs-on: ubuntu-latest + + steps: + - name: Checkout PR Branch + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Setup Node.js + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6 + with: + node-version: v22 + + - name: Install workspace + run: npm ci + + - name: Lint CALM Server Module + run: npm run lint --workspace=calm-server + + - name: Build workspace + run: npm run build:calm-server + + - name: Run tests with coverage for CALM Server + run: npm run test:calm-server From 65310278c1fe334cdc635dc1e55bbf6953679fa6 Mon Sep 17 00:00:00 2001 From: Mark Scott Date: Wed, 25 Feb 2026 08:58:38 +0000 Subject: [PATCH 10/18] chore(calm-server): update PR template to include missing modules --- .github/pull_request_template.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index e4e1a08a2..b290a6e35 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -16,11 +16,14 @@ ## Affected Components - [ ] CLI (`cli/`) -- [ ] Shared (`shared/`) -- [ ] CALM Widgets (`calm-widgets/`) +- [ ] Schema (`calm/`) +- [ ] CALM AI (`calm-ai/`) - [ ] CALM Hub (`calm-hub/`) - [ ] CALM Hub UI (`calm-hub-ui/`) +- [ ] CALM Server (`calm-server/`) +- [ ] CALM Widgets (`calm-widgets/`) - [ ] Documentation (`docs/`) +- [ ] Shared (`shared/`) - [ ] VS Code Extension (`calm-plugins/vscode/`) - [ ] Dependencies - [ ] CI/CD From 8b7a18ff595960a9bad3cd1bf6a6b73427563278 Mon Sep 17 00:00:00 2001 From: Mark Scott Date: Wed, 25 Feb 2026 11:00:12 +0000 Subject: [PATCH 11/18] chore(calm-server): fix lint issues --- calm-server/eslint.config.mjs | 105 +++++++++++++++++++++++----------- calm-server/src/index.ts | 1 - 2 files changed, 73 insertions(+), 33 deletions(-) diff --git a/calm-server/eslint.config.mjs b/calm-server/eslint.config.mjs index 71e750d44..0cff31321 100644 --- a/calm-server/eslint.config.mjs +++ b/calm-server/eslint.config.mjs @@ -1,38 +1,79 @@ -import js from '@eslint/js'; -import tsParser from '@typescript-eslint/parser'; -import tsPlugin from '@typescript-eslint/eslint-plugin'; +import typescriptEslint from "@typescript-eslint/eslint-plugin"; +import globals from "globals"; +import tsParser from "@typescript-eslint/parser"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import js from "@eslint/js"; +import { FlatCompat } from "@eslint/eslintrc"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const compat = new FlatCompat({ + baseDirectory: __dirname, + recommendedConfig: js.configs.recommended, + allConfig: js.configs.all +}); export default [ - { - ignores: ['dist/', 'node_modules/', 'coverage/'], - }, - { - files: ['src/**/*.ts'], - languageOptions: { - parser: tsParser, - parserOptions: { - ecmaVersion: 'latest', - sourceType: 'module', - project: './tsconfig.json', - }, - globals: { - console: 'readonly', - process: 'readonly', - __dirname: 'readonly', - setTimeout: 'readonly', - NodeJS: 'readonly', - }, + ...compat.extends("eslint:recommended", "plugin:@typescript-eslint/recommended"), + { + ignores: ['dist/', 'node_modules/', 'coverage/', 'test_fixtures/'], }, - plugins: { - '@typescript-eslint': tsPlugin, + { + files: ['src/**/*.ts'], + plugins: { + "@typescript-eslint": typescriptEslint, + }, + + languageOptions: { + globals: { + ...globals.node, + }, + + parser: tsParser, + ecmaVersion: "latest", + sourceType: "module", + }, + + rules: { + indent: ["error", 4], + "linebreak-style": ["error", "unix"], + quotes: ["error", "single"], + semi: ["error", "always"], + "no-unused-vars": "off", + "@typescript-eslint/no-unused-vars": [ + "error", + { + "argsIgnorePattern": "^_", + "varsIgnorePattern": "^_", + "caughtErrorsIgnorePattern": "^_" + } + ], + "@typescript-eslint/no-unused-expressions": [ + "error", + { + "allowShortCircuit": true, + "allowTernary": true, + "allowTaggedTemplates": true + } + ] + } }, - rules: { - ...js.configs.recommended.rules, - ...tsPlugin.configs.recommended.rules, - '@typescript-eslint/no-unused-vars': [ - 'error', - { argsIgnorePattern: '^_' }, - ], + { + files: ['src/**/*.spec.ts'], + languageOptions: { + globals: { + ...globals.node, + describe: 'readonly', + it: 'readonly', + test: 'readonly', + beforeEach: 'readonly', + afterEach: 'readonly', + beforeAll: 'readonly', + afterAll: 'readonly', + expect: 'readonly', + vi: 'readonly', + }, + }, }, - }, ]; diff --git a/calm-server/src/index.ts b/calm-server/src/index.ts index 9b8214aa3..a6ca4c9c3 100644 --- a/calm-server/src/index.ts +++ b/calm-server/src/index.ts @@ -32,7 +32,6 @@ async function parseDocumentLoaderConfig( urlToLocalMap?: Map, basePath?: string ): Promise { - const logger = initLogger(options.verbose, 'calm-server'); const docLoaderOpts: DocumentLoaderOptions = { calmHubUrl: options.calmHubUrl, schemaDirectoryPath: options.schemaDirectory, From 5d0cc41d372002d12159ffb8aaa6e0e3b39feef2 Mon Sep 17 00:00:00 2001 From: Mark Scott Date: Wed, 25 Feb 2026 11:39:43 +0000 Subject: [PATCH 12/18] feat(calm-server): address review comments --- calm-server/README.md | 19 ------------ calm-server/package.json | 2 ++ calm-server/src/server/routes/routes.spec.ts | 29 +++++++++++-------- calm-server/src/server/routes/routes.ts | 11 ++++--- .../src/server/routes/validation-route.ts | 19 ++++++++++-- calm-server/tsconfig.json | 14 ++++----- package-lock.json | 12 ++++---- 7 files changed, 57 insertions(+), 49 deletions(-) diff --git a/calm-server/README.md b/calm-server/README.md index 70bc0ef3b..a3deefc49 100644 --- a/calm-server/README.md +++ b/calm-server/README.md @@ -134,25 +134,6 @@ npm run lint npm run lint-fix ``` -## Configuration - -The server can load configuration from `~/.calm.json`: - -```json -{ - "calmHubUrl": "https://calm-hub.example.com" -} -``` - -This allows you to set a default CALM Hub URL without specifying it on every invocation. - -## Relationship to CLI - -The calm-server package extracts the server functionality from the `@finos/calm-cli` package into a standalone executable. Both implementations share the same core validation logic through `@finos/calm-shared`. - -**CLI**: `calm server -s ./calm/release --port 3000` -**Standalone**: `calm-server -s ./calm/release --port 3000` - ## License Apache-2.0 diff --git a/calm-server/package.json b/calm-server/package.json index 7454ef545..e25ea211a 100644 --- a/calm-server/package.json +++ b/calm-server/package.json @@ -38,10 +38,12 @@ "devDependencies": { "@types/express": "^4.17.21", "@types/node": "^22.15.0", + "@types/supertest": "^6.0.3", "@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/parser": "^8.0.0", "eslint": "^9.0.0", "node-fetch": "^2.7.0", + "supertest": "^7.0.0", "tsup": "^8.3.5", "typescript": "^5.8.2", "vitest": "^3.1.1" diff --git a/calm-server/src/server/routes/routes.spec.ts b/calm-server/src/server/routes/routes.spec.ts index a445f7ff1..c40230fd1 100644 --- a/calm-server/src/server/routes/routes.spec.ts +++ b/calm-server/src/server/routes/routes.spec.ts @@ -4,13 +4,8 @@ import { ValidationRouter } from './validation-route'; import { HealthRouter } from './health-route'; import { SchemaDirectory } from '@finos/calm-shared'; -const mockUse = vi.fn(); -const mockRouter = { - use: mockUse -}; - vi.mock('express', () => ({ - Router: vi.fn(() => mockRouter) + Router: vi.fn() })); vi.mock('./validation-route', () => { @@ -33,11 +28,21 @@ vi.mock('@finos/calm-shared', () => { describe('CLIServerRoutes', () => { let schemaDirectory: SchemaDirectory; let cliServerRoutes: CLIServerRoutes; - let mockRouter: Router; + let mainRouter: Router; + let validateRouter: Router; + let healthRouter: Router; beforeEach(() => { + mainRouter = { use: vi.fn() } as unknown as Router; + validateRouter = { use: vi.fn() } as unknown as Router; + healthRouter = { use: vi.fn() } as unknown as Router; + const routerMock = Router as unknown as vi.Mock; + routerMock.mockReset(); + routerMock + .mockImplementationOnce(() => mainRouter) + .mockImplementationOnce(() => validateRouter) + .mockImplementationOnce(() => healthRouter); cliServerRoutes = new CLIServerRoutes(schemaDirectory); - mockRouter = cliServerRoutes.router; }); it('should initialize router', () => { @@ -45,12 +50,12 @@ describe('CLIServerRoutes', () => { }); it('should set up validate route', () => { - expect(mockRouter.use).toHaveBeenCalledWith('/calm/validate', mockRouter); - expect(ValidationRouter).toHaveBeenCalled(); + expect(mainRouter.use).toHaveBeenCalledWith('/calm/validate', validateRouter); + expect(ValidationRouter).toHaveBeenCalledWith(validateRouter, schemaDirectory, false, 900000, 100); }); it('should set up health route', () => { - expect(mockRouter.use).toHaveBeenCalledWith('/health', mockRouter); - expect(HealthRouter).toHaveBeenCalled(); + expect(mainRouter.use).toHaveBeenCalledWith('/health', healthRouter); + expect(HealthRouter).toHaveBeenCalledWith(healthRouter); }); }); diff --git a/calm-server/src/server/routes/routes.ts b/calm-server/src/server/routes/routes.ts index cf284d636..78e9af034 100644 --- a/calm-server/src/server/routes/routes.ts +++ b/calm-server/src/server/routes/routes.ts @@ -16,9 +16,12 @@ export class CLIServerRoutes { rateLimitMaxRequests: number = 100 ) { this.router = Router(); - const validateRoute = this.router.use(VALIDATE_ROUTE_PATH, this.router); - new ValidationRouter(validateRoute, schemaDirectory, debug, rateLimitWindowMs, rateLimitMaxRequests); - const healthRoute = this.router.use(HEALTH_ROUTE_PATH, this.router); - new HealthRouter(healthRoute); + const validateRouter = Router(); + this.router.use(VALIDATE_ROUTE_PATH, validateRouter); + new ValidationRouter(validateRouter, schemaDirectory, debug, rateLimitWindowMs, rateLimitMaxRequests); + + const healthRouter = Router(); + this.router.use(HEALTH_ROUTE_PATH, healthRouter); + new HealthRouter(healthRouter); } } diff --git a/calm-server/src/server/routes/validation-route.ts b/calm-server/src/server/routes/validation-route.ts index fd7453e2f..8690c2d0d 100644 --- a/calm-server/src/server/routes/validation-route.ts +++ b/calm-server/src/server/routes/validation-route.ts @@ -6,6 +6,7 @@ import rateLimit from 'express-rate-limit'; export class ValidationRouter { private schemaDirectory: SchemaDirectory; private logger: Logger; + private schemaLoadPromise: Promise | null = null; constructor( router: Router, @@ -28,7 +29,21 @@ export class ValidationRouter { router.post('/', this.validateSchema); } - private validateSchema = async (req: Request, res: Response) => { + private async ensureSchemasLoaded() { + if (!this.schemaLoadPromise) { + this.schemaLoadPromise = this.schemaDirectory.loadSchemas().catch((error) => { + this.schemaLoadPromise = null; + throw error; + }); + } + + await this.schemaLoadPromise; + } + + private validateSchema = async ( + req: Request, ValidationOutcome | ErrorResponse, ValidationRequest>, + res: Response + ) => { let architecture; try { architecture = JSON.parse(req.body.architecture); @@ -43,7 +58,7 @@ export class ValidationRouter { } try { - await this.schemaDirectory.loadSchemas(); + await this.ensureSchemasLoaded(); } catch (error) { this.logger.error('Failed to load schemas: ' + error); return res.status(500).type('json').send(new ErrorResponse('Failed to load schemas')); diff --git a/calm-server/tsconfig.json b/calm-server/tsconfig.json index 597039fe4..fe9ce15d5 100644 --- a/calm-server/tsconfig.json +++ b/calm-server/tsconfig.json @@ -1,12 +1,12 @@ { "extends": "../tsconfig.base.json", - "module": "Preserve", - "moduleResolution": "bundler", "compilerOptions": { - "outDir": "dist" + "outDir": "dist", + "module": "Preserve", + "moduleResolution": "bundler", + "lib": [ + "esnext" + ] }, - "include": ["src", "../vitest-globals.d.ts"], - "lib": [ - "esnext" - ] + "include": ["src", "../vitest-globals.d.ts"] } diff --git a/package-lock.json b/package-lock.json index af8eb037d..4c3c3c1ce 100644 --- a/package-lock.json +++ b/package-lock.json @@ -172,7 +172,7 @@ "devDependencies": { "@types/lodash": "^4.17.16", "@types/markdown-it": "^14.1.2", - "@types/node": "^22.19.7", + "@types/node": "^22.0.0", "@types/svg-pan-zoom": "^3.3.0", "@types/vscode": "^1.88.0", "@typescript-eslint/eslint-plugin": "^8.29.1", @@ -272,10 +272,12 @@ "devDependencies": { "@types/express": "^4.17.21", "@types/node": "^22.15.0", + "@types/supertest": "^6.0.3", "@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/parser": "^8.0.0", "eslint": "^9.0.0", "node-fetch": "^2.7.0", + "supertest": "^7.0.0", "tsup": "^8.3.5", "typescript": "^5.8.2", "vitest": "^3.1.1" @@ -563,7 +565,7 @@ "@types/json-pointer": "^1.0.34", "@types/junit-report-builder": "^3.0.2", "@types/lodash": "^4.17.16", - "@types/node": "^22.19.7", + "@types/node": "^22.15.0", "@typescript-eslint/eslint-plugin": "^8.29.1", "@typescript-eslint/parser": "^8.29.1", "axios-mock-adapter": "^2.1.0", @@ -611,7 +613,7 @@ "@types/json-pointer": "^1.0.34", "@types/junit-report-builder": "^3.0.2", "@types/lodash": "^4.17.16", - "@types/node": "^22.19.7", + "@types/node": "^22.15.0", "@types/supertest": "^6.0.3", "@types/xml2js": "^0.4.14", "@typescript-eslint/eslint-plugin": "^8.29.1", @@ -730,7 +732,7 @@ "@tailwindcss/typography": "^0.5.16", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.0", - "@types/node": "^22.19.7", + "@types/node": "^22.16.5", "@types/react": "^18.3.23", "@types/react-dom": "^18.3.7", "@types/vscode": "^1.105.0", @@ -44563,7 +44565,7 @@ "@types/json-pointer": "^1.0.34", "@types/junit-report-builder": "^3.0.2", "@types/lodash": "^4.17.16", - "@types/node": "^22.19.7", + "@types/node": "^22.15.0", "@typescript-eslint/eslint-plugin": "^8.29.1", "@typescript-eslint/parser": "^8.29.1", "axios-mock-adapter": "^2.1.0", From 49a698ff7c3105291140fa3fde95300759e20390 Mon Sep 17 00:00:00 2001 From: Mark Scott Date: Wed, 25 Feb 2026 11:40:30 +0000 Subject: [PATCH 13/18] chore(ci): add calm-server as a valid scope for commitlint --- commitlint.config.js | 1 + 1 file changed, 1 insertion(+) diff --git a/commitlint.config.js b/commitlint.config.js index c9954212a..4e661eb15 100644 --- a/commitlint.config.js +++ b/commitlint.config.js @@ -9,6 +9,7 @@ module.exports = { [ 'cli', 'shared', + 'calm-server', 'calm-widgets', 'calm-hub', 'calm-hub-ui', From e72edcb3026dcce30037b36ea476f3fb07b469ba Mon Sep 17 00:00:00 2001 From: Mark Scott Date: Wed, 25 Feb 2026 11:43:51 +0000 Subject: [PATCH 14/18] fix(calm-server): suppress unused var in tests --- calm-server/src/server/routes/routes.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/calm-server/src/server/routes/routes.spec.ts b/calm-server/src/server/routes/routes.spec.ts index c40230fd1..ad5a890d8 100644 --- a/calm-server/src/server/routes/routes.spec.ts +++ b/calm-server/src/server/routes/routes.spec.ts @@ -43,6 +43,7 @@ describe('CLIServerRoutes', () => { .mockImplementationOnce(() => validateRouter) .mockImplementationOnce(() => healthRouter); cliServerRoutes = new CLIServerRoutes(schemaDirectory); + void cliServerRoutes; }); it('should initialize router', () => { From 0bfe35b4329d7d24acdccb539ff829acde382318 Mon Sep 17 00:00:00 2001 From: Mark Scott Date: Thu, 26 Feb 2026 22:06:38 +0000 Subject: [PATCH 15/18] feat(calm-server): improve test coverage, remove CLI references --- calm-server/README.md | 16 +++-- calm-server/src/index.spec.ts | 7 -- calm-server/src/index.ts | 2 +- calm-server/src/server/routes/routes.spec.ts | 10 +-- calm-server/src/server/routes/routes.ts | 2 +- .../server/routes/validation-route.spec.ts | 68 +++++++++++++++++++ .../{cli-server.spec.ts => server.spec.ts} | 2 +- .../src/server/{cli-server.ts => server.ts} | 6 +- calm-server/vitest.config.ts | 25 +++++-- 9 files changed, 107 insertions(+), 31 deletions(-) delete mode 100644 calm-server/src/index.spec.ts rename calm-server/src/server/{cli-server.spec.ts => server.spec.ts} (96%) rename calm-server/src/server/{cli-server.ts => server.ts} (82%) diff --git a/calm-server/README.md b/calm-server/README.md index a3deefc49..b570bba7b 100644 --- a/calm-server/README.md +++ b/calm-server/README.md @@ -77,25 +77,27 @@ Validate a CALM architecture document: curl -X POST http://localhost:3000/calm/validate \ -H "Content-Type: application/json" \ -d '{ - "architecture": "{\"$schema\":\"https://calm.finos.org/draft/2024-04/meta/core\",\"nodes\":[...]}" + "architecture": "{\"$schema\":\"https://calm.finos.org/release/1.2/meta/calm.json\",\"nodes\":[]}" }' ``` Response (success): ```json { - "errors": [], - "warnings": [], - "result": "success" + "jsonSchemaValidationOutputs":[], + "spectralSchemaValidationOutputs":[], + "hasErrors":false, + "hasWarnings":false } ``` Response (validation errors): ```json { - "errors": [...], - "warnings": [...], - "result": "failure" + "jsonSchemaValidationOutputs":[], + "spectralSchemaValidationOutputs":[...], + "hasErrors":true, + "hasWarnings":false } ``` diff --git a/calm-server/src/index.spec.ts b/calm-server/src/index.spec.ts deleted file mode 100644 index 0470bda01..000000000 --- a/calm-server/src/index.spec.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { describe, it, expect } from 'vitest'; - -describe('calm-server', () => { - it('should be importable', () => { - expect(true).toBe(true); - }); -}); diff --git a/calm-server/src/index.ts b/calm-server/src/index.ts index a6ca4c9c3..fe9e1b736 100644 --- a/calm-server/src/index.ts +++ b/calm-server/src/index.ts @@ -4,7 +4,7 @@ import { Command } from 'commander'; import { version } from '../package.json'; -import { startServer } from './server/cli-server'; +import { startServer } from './server/server'; import { SchemaDirectory, initLogger } from '@finos/calm-shared'; import { buildDocumentLoader, DocumentLoader, DocumentLoaderOptions } from '@finos/calm-shared/dist/document-loader/document-loader'; import path from 'path'; diff --git a/calm-server/src/server/routes/routes.spec.ts b/calm-server/src/server/routes/routes.spec.ts index ad5a890d8..dfb834643 100644 --- a/calm-server/src/server/routes/routes.spec.ts +++ b/calm-server/src/server/routes/routes.spec.ts @@ -1,5 +1,5 @@ import { Router } from 'express'; -import { CLIServerRoutes } from './routes'; +import { ServerRoutes } from './routes'; import { ValidationRouter } from './validation-route'; import { HealthRouter } from './health-route'; import { SchemaDirectory } from '@finos/calm-shared'; @@ -25,9 +25,9 @@ vi.mock('@finos/calm-shared', () => { SchemaDirectory: vi.fn() }; }); -describe('CLIServerRoutes', () => { +describe('ServerRoutes', () => { let schemaDirectory: SchemaDirectory; - let cliServerRoutes: CLIServerRoutes; + let serverRoutes: ServerRoutes; let mainRouter: Router; let validateRouter: Router; let healthRouter: Router; @@ -42,8 +42,8 @@ describe('CLIServerRoutes', () => { .mockImplementationOnce(() => mainRouter) .mockImplementationOnce(() => validateRouter) .mockImplementationOnce(() => healthRouter); - cliServerRoutes = new CLIServerRoutes(schemaDirectory); - void cliServerRoutes; + serverRoutes = new ServerRoutes(schemaDirectory); + void serverRoutes; }); it('should initialize router', () => { diff --git a/calm-server/src/server/routes/routes.ts b/calm-server/src/server/routes/routes.ts index 78e9af034..e63149514 100644 --- a/calm-server/src/server/routes/routes.ts +++ b/calm-server/src/server/routes/routes.ts @@ -6,7 +6,7 @@ import { SchemaDirectory } from '@finos/calm-shared'; const HEALTH_ROUTE_PATH = '/health'; const VALIDATE_ROUTE_PATH = '/calm/validate'; -export class CLIServerRoutes { +export class ServerRoutes { router: Router; constructor( diff --git a/calm-server/src/server/routes/validation-route.spec.ts b/calm-server/src/server/routes/validation-route.spec.ts index 93f8ae0fb..4fd794f2f 100644 --- a/calm-server/src/server/routes/validation-route.spec.ts +++ b/calm-server/src/server/routes/validation-route.spec.ts @@ -6,6 +6,7 @@ import { ValidationRouter } from './validation-route'; import path from 'path'; import { SchemaDirectory } from '@finos/calm-shared'; import { FileSystemDocumentLoader } from '@finos/calm-shared/dist/document-loader/file-system-document-loader'; +import { vi } from 'vitest'; const schemaDirectoryPath: string = __dirname + '/../../../../calm/release'; const apiGatewayPatternPath: string = @@ -49,6 +50,21 @@ describe('ValidationRouter', () => { }); }); + test('should return 400 when architecture JSON is invalid', async () => { + const requestBody = { + architecture: 'not valid json {' + }; + + const response = await request(app) + .post('/calm/validate') + .send(requestBody); + + expect(response.status).toBe(400); + expect(response.body).toEqual({ + error: 'Invalid JSON format for architecture' + }); + }); + test('should return 400 when the $schema specified in the instantiation is not found', async () => { const expectedFilePath = path.join( __dirname, @@ -67,6 +83,58 @@ describe('ValidationRouter', () => { }); }); + test('should return 500 when schema load throws an error', async () => { + app = express(); + app.use(express.json()); + + const router: express.Router = express.Router(); + const mockSchemaDirectory = { + loadSchemas: vi.fn().mockRejectedValueOnce(new Error('Load error')), + getSchema: vi.fn() + } as unknown as SchemaDirectory; + + new ValidationRouter(router, mockSchemaDirectory); + app.use('/calm/validate', router); + + const requestBody = { + architecture: JSON.stringify({ $schema: 'https://example.com/schema' }) + }; + + const response = await request(app) + .post('/calm/validate') + .send(requestBody); + + expect(response.status).toBe(500); + expect(response.body).toEqual({ + error: 'Failed to load schemas' + }); + }); + + test('should return 500 when getSchema throws an error', async () => { + app = express(); + app.use(express.json()); + + const router: express.Router = express.Router(); + const mockSchemaDirectory = { + loadSchemas: vi.fn().mockResolvedValueOnce(undefined), + getSchema: vi.fn().mockRejectedValueOnce(new Error('Schema retrieval error')) + } as unknown as SchemaDirectory; + + new ValidationRouter(router, mockSchemaDirectory); + app.use('/calm/validate', router); + + const requestBody = { + architecture: JSON.stringify({ $schema: 'https://example.com/schema' }) + }; + + const response = await request(app) + .post('/calm/validate') + .send(requestBody); + + expect(response.status).toBe(500); + expect(response.body.error).toContain('Failed to load schema'); + }); + test('should return 201 when the schema is valid', async () => { const expectedFilePath = path.join( __dirname, diff --git a/calm-server/src/server/cli-server.spec.ts b/calm-server/src/server/server.spec.ts similarity index 96% rename from calm-server/src/server/cli-server.spec.ts rename to calm-server/src/server/server.spec.ts index 025dc4c43..acf8e513b 100644 --- a/calm-server/src/server/cli-server.spec.ts +++ b/calm-server/src/server/server.spec.ts @@ -1,5 +1,5 @@ import { describe, it, expect, afterEach } from 'vitest'; -import { startServer } from './cli-server'; +import { startServer } from './server'; import { SchemaDirectory } from '@finos/calm-shared'; import fetch from 'node-fetch'; import { Server } from 'http'; diff --git a/calm-server/src/server/cli-server.ts b/calm-server/src/server/server.ts similarity index 82% rename from calm-server/src/server/cli-server.ts rename to calm-server/src/server/server.ts index ba72ec669..9779128c5 100644 --- a/calm-server/src/server/cli-server.ts +++ b/calm-server/src/server/server.ts @@ -1,5 +1,5 @@ import express, { Application } from 'express'; -import { CLIServerRoutes } from './routes/routes'; +import { ServerRoutes } from './routes/routes'; import { initLogger, SchemaDirectory } from '@finos/calm-shared'; import { Server } from 'http'; @@ -12,13 +12,13 @@ export function startServer( rateLimitMaxRequests: number = 100 ): Server { const app: Application = express(); - const cliServerRoutesInstance = new CLIServerRoutes( + const serverRoutesInstance = new ServerRoutes( schemaDirectory, verbose, rateLimitWindowMs, rateLimitMaxRequests ); - const allRoutes = cliServerRoutesInstance.router; + const allRoutes = serverRoutesInstance.router; app.use(express.json()); app.use('/', allRoutes); diff --git a/calm-server/vitest.config.ts b/calm-server/vitest.config.ts index 042d2fcef..2a94d2682 100644 --- a/calm-server/vitest.config.ts +++ b/calm-server/vitest.config.ts @@ -1,4 +1,22 @@ import { defineConfig } from 'vitest/config'; +import { CoverageV8Options } from "vitest/node"; + +const v8CoverageSettings: CoverageV8Options = { + enabled: true, + reporter: ['text', 'json', 'html'], + thresholds: { + branches: 85, + functions: 75, + lines: 75, + statements: 75 + }, + exclude: [ + 'test_fixtures/**', + '*.config.ts', + 'src/index.ts' // CLI boilerplate, tested via integration tests + ], + include: ['**/*.ts'] +} export default defineConfig({ test: { @@ -6,12 +24,7 @@ export default defineConfig({ environment: 'node', coverage: { provider: 'v8', - reporter: ['text', 'json', 'html'], - exclude: [ - 'node_modules/', - 'dist/', - '**/*.spec.ts', - ], + ...v8CoverageSettings }, }, }); From f016f1b763470873ef7477a4516ec7a122819baf Mon Sep 17 00:00:00 2001 From: Mark Scott Date: Thu, 26 Feb 2026 22:45:44 +0000 Subject: [PATCH 16/18] feat(calm-server): add CODEOWNERS, avoid deep fragile linking --- .github/CODEOWNERS | 2 ++ calm-server/src/index.ts | 3 +-- package-lock.json | 4 ++-- shared/src/index.ts | 1 + 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 87c20e89f..58a88597f 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -4,6 +4,8 @@ /calm-hub/ @jpgough-ms @rocketstack-matt @grahampacker-ms @Thels @markscott-ms +/calm-server/ @rocketstack-matt @markscott-ms + /cli/ @aidanm3341 @lbulanti-ms @willosborne @grahampacker-ms @jpgough-ms @rocketstack-matt @Thels @LeighFinegold @markscott-ms /shared/ @aidanm3341 @lbulanti-ms @willosborne @grahampacker-ms @jpgough-ms @rocketstack-matt @Thels @LeighFinegold @markscott-ms diff --git a/calm-server/src/index.ts b/calm-server/src/index.ts index fe9e1b736..3ae38830b 100644 --- a/calm-server/src/index.ts +++ b/calm-server/src/index.ts @@ -5,8 +5,7 @@ import { Command } from 'commander'; import { version } from '../package.json'; import { startServer } from './server/server'; -import { SchemaDirectory, initLogger } from '@finos/calm-shared'; -import { buildDocumentLoader, DocumentLoader, DocumentLoaderOptions } from '@finos/calm-shared/dist/document-loader/document-loader'; +import { SchemaDirectory, initLogger, buildDocumentLoader, DocumentLoader, DocumentLoaderOptions } from '@finos/calm-shared'; import path from 'path'; const BUNDLED_SCHEMA_PATH = path.join(__dirname, 'calm'); diff --git a/package-lock.json b/package-lock.json index 5dc3bf4c7..5b19bfa24 100644 --- a/package-lock.json +++ b/package-lock.json @@ -546,7 +546,7 @@ }, "cli": { "name": "@finos/calm-cli", - "version": "1.34.1", + "version": "1.34.2", "license": "Apache-2.0", "dependencies": { "@apidevtools/json-schema-ref-parser": "^14.0.0", @@ -41609,4 +41609,4 @@ } } } -} \ No newline at end of file +} diff --git a/shared/src/index.ts b/shared/src/index.ts index 5256d2ddc..f78c91969 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -33,6 +33,7 @@ export { CalmRelationshipGraph } from './docify/graphing/relationship-graph.js'; export { ValidationOutcome } from './commands/validate/validation.output'; export * from './test/file-comparison.js'; export { setWidgetLogger, type WidgetLogger } from '@finos/calm-widgets'; +export { buildDocumentLoader, DocumentLoader, DocumentLoaderOptions } from './document-loader/document-loader'; export * from './document-loader/loading-helpers.js'; export { hasArchitectureExtension, From 48bd0010205fb159abadeb47721543eb80d5875d Mon Sep 17 00:00:00 2001 From: Mark Scott Date: Thu, 26 Feb 2026 22:59:30 +0000 Subject: [PATCH 17/18] feat(calm-server): update README --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 2b7525f0f..44709b7f1 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ Ready to get started? Check out the [CALM tutorials](https://calm.finos.org/tuto | [CALM AI](./calm-ai) | [@rocketstack-matt](https://github.com/rocketstack-matt) | No build, prompt tools only, managed separately to CLI for easier maintenance and broader reuse. | | [CALM Hub](./calm-hub) | [@jpgough-ms](https://github.com/jpgough-ms), [@grahampacker-ms](https://github.com/grahampacker-ms) | [![Build Calm Hub](https://github.com/finos/architecture-as-code/actions/workflows/build-calm-hub.yml/badge.svg)](https://github.com/finos/architecture-as-code/actions/workflows/build-calm-hub.yml) | | [CALM Hub UI](./calm-hub-ui) | [@aidanm3341](https://github.com/aidanm3341), [@aamanrebello](https://github.com/aamanrebello), [@yoofitt96](https://github.com/YoofiTT96) | [![Build CALM Hub UI](https://github.com/finos/architecture-as-code/actions/workflows/build-calm-hub-ui.yml/badge.svg)](https://github.com/finos/architecture-as-code/actions/workflows/build-calm-hub-ui.yml) | +| [CALM-Server](./calm-server) | [@rocketstack-matt](https://github.com/rocketstack-matt), [@markscott-ms](https://github.com/markscott-ms) | [![Build CALM Server](https://github.com/finos/architecture-as-code/actions/workflows/build-calm-server.yml/badge.svg)](https://github.com/finos/architecture-as-code/actions/workflows/build-calm-server.yml) | | [Docs](./docs) | [@rocketstack-matt](https://github.com/rocketstack-matt) | [![Sync Docs to S3](https://github.com/finos/architecture-as-code/actions/workflows/s3-docs-sync.yml/badge.svg)](https://github.com/finos/architecture-as-code/actions/workflows/s3-docs-sync.yml) [![Build Docs](https://github.com/finos/architecture-as-code/actions/workflows/build-docs.yml/badge.svg)](https://github.com/finos/architecture-as-code/actions/workflows/build-docs.yml) | | [CALM VSCode Plugin](./calm-plugins/vscode) | [@LeighFinegold](https://github.com/LeighFinegold), [@rocketstack-matt](https://github.com/rocketstack-matt), [@markscott-ms](https://github.com/markscott-ms) | ![Build VS Code Extension](https://github.com/finos/architecture-as-code/workflows/Build%20VS%20Code%20Extension/badge.svg) | From 1ae9ffa6d420ab404fbef9078f5624a7cf4d05d2 Mon Sep 17 00:00:00 2001 From: Mark Scott Date: Sat, 28 Feb 2026 15:19:24 +0000 Subject: [PATCH 18/18] feat(calm-server): only import exported functions and types from shared --- calm-server/src/server/routes/validation-route.spec.ts | 3 +-- calm-server/src/server/routes/validation-route.ts | 2 +- calm-server/tsconfig.json | 3 ++- shared/src/index.ts | 2 ++ 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/calm-server/src/server/routes/validation-route.spec.ts b/calm-server/src/server/routes/validation-route.spec.ts index 4fd794f2f..005bae31b 100644 --- a/calm-server/src/server/routes/validation-route.spec.ts +++ b/calm-server/src/server/routes/validation-route.spec.ts @@ -4,8 +4,7 @@ import * as fs from 'fs'; import express, { Application } from 'express'; import { ValidationRouter } from './validation-route'; import path from 'path'; -import { SchemaDirectory } from '@finos/calm-shared'; -import { FileSystemDocumentLoader } from '@finos/calm-shared/dist/document-loader/file-system-document-loader'; +import { FileSystemDocumentLoader, SchemaDirectory } from '@finos/calm-shared'; import { vi } from 'vitest'; const schemaDirectoryPath: string = __dirname + '/../../../../calm/release'; diff --git a/calm-server/src/server/routes/validation-route.ts b/calm-server/src/server/routes/validation-route.ts index 8690c2d0d..3e0debb66 100644 --- a/calm-server/src/server/routes/validation-route.ts +++ b/calm-server/src/server/routes/validation-route.ts @@ -1,5 +1,5 @@ import { SchemaDirectory, validate, ValidationOutcome, initLogger } from '@finos/calm-shared'; -import type { Logger } from '@finos/calm-shared/dist/logger'; +import type { Logger } from '@finos/calm-shared'; import { Router, Request, Response } from 'express'; import rateLimit from 'express-rate-limit'; diff --git a/calm-server/tsconfig.json b/calm-server/tsconfig.json index fe9ce15d5..05da2b641 100644 --- a/calm-server/tsconfig.json +++ b/calm-server/tsconfig.json @@ -6,7 +6,8 @@ "moduleResolution": "bundler", "lib": [ "esnext" - ] + ], + "strict": true }, "include": ["src", "../vitest-globals.d.ts"] } diff --git a/shared/src/index.ts b/shared/src/index.ts index f78c91969..a7021f2d4 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -15,6 +15,7 @@ export { ValidationOutput } from './commands/validate/validation.output.js'; export { CALM_META_SCHEMA_DIRECTORY } from './consts.js'; export { SchemaDirectory } from './schema-directory.js'; export { initLogger } from './logger.js'; +export type { Logger } from './logger.js'; export { TemplateProcessor, TemplateProcessingMode } from './template/template-processor.js'; export * from './template/types.js'; export { @@ -34,6 +35,7 @@ export { ValidationOutcome } from './commands/validate/validation.output'; export * from './test/file-comparison.js'; export { setWidgetLogger, type WidgetLogger } from '@finos/calm-widgets'; export { buildDocumentLoader, DocumentLoader, DocumentLoaderOptions } from './document-loader/document-loader'; +export { FileSystemDocumentLoader } from './document-loader/file-system-document-loader'; export * from './document-loader/loading-helpers.js'; export { hasArchitectureExtension,