From 2f590323657cd183c498db638c25e0827abb56d0 Mon Sep 17 00:00:00 2001 From: clippy Date: Fri, 31 Jul 2026 14:10:49 -0700 Subject: [PATCH 1/2] feat(embed): add the TypeScript router + conformance suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds @monad-inc/embed-server — a zero-runtime-dependency /embed router that mounts into Express or bare Node http via createEmbedRouter (core logic + node adapter + Monad API adapter) — and the language-agnostic conformance suite that proves any backend satisfies the contract over HTTP: - conformance/ — Schemathesis response-conformance across all 9 routes plus scripted lifecycle scenarios (mint -> build -> status -> disable -> remove, and egress), driven against a stateful in-memory mock Monad. The harness boots the router under test as a subprocess (ROUTER=ts|go|python); only the ts server ships here — go/python servers land with their router layers. - A CI "Conformance (ts)" job builds the router and runs ROUTER=ts. From here, conformance is the required gate every later router PR must pass. Also ignores the Python harness .venv in prettier and formats two source files that weren't prettier-clean. Local: TS router 13 tests + conformance 14 checks green (ROUTER=ts); full typecheck / lint / format / build clean. Layer 3 (PRO-402), stacked on the browser client (PRO-401). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011ASKA2VrufrNLGGrcFjY7n --- .github/workflows/ci.yml | 32 +++ .prettierignore | 7 +- conformance/.gitignore | 4 + conformance/README.md | 57 +++++ conformance/conftest.py | 75 +++++++ conformance/mock_monad.py | 275 +++++++++++++++++++++++ conformance/run.sh | 28 +++ conformance/servers/ts_server.mjs | 20 ++ conformance/test_conformance.py | 42 ++++ conformance/test_scenarios.py | 120 ++++++++++ pnpm-lock.yaml | 15 ++ pnpm-workspace.yaml | 1 + routers/typescript/LICENSE | 201 +++++++++++++++++ routers/typescript/README.md | 163 ++++++++++++++ routers/typescript/package.json | 58 +++++ routers/typescript/src/core.ts | 286 +++++++++++++++++++++++ routers/typescript/src/index.ts | 39 ++++ routers/typescript/src/monad.ts | 300 +++++++++++++++++++++++++ routers/typescript/src/node.ts | 96 ++++++++ routers/typescript/test/router.test.ts | 204 +++++++++++++++++ routers/typescript/tsconfig.json | 17 ++ routers/typescript/tsup.config.ts | 9 + routers/typescript/vitest.config.ts | 8 + 23 files changed, 2056 insertions(+), 1 deletion(-) create mode 100644 conformance/.gitignore create mode 100644 conformance/README.md create mode 100644 conformance/conftest.py create mode 100644 conformance/mock_monad.py create mode 100755 conformance/run.sh create mode 100644 conformance/servers/ts_server.mjs create mode 100644 conformance/test_conformance.py create mode 100644 conformance/test_scenarios.py create mode 100644 routers/typescript/LICENSE create mode 100644 routers/typescript/README.md create mode 100644 routers/typescript/package.json create mode 100644 routers/typescript/src/core.ts create mode 100644 routers/typescript/src/index.ts create mode 100644 routers/typescript/src/monad.ts create mode 100644 routers/typescript/src/node.ts create mode 100644 routers/typescript/test/router.test.ts create mode 100644 routers/typescript/tsconfig.json create mode 100644 routers/typescript/tsup.config.ts create mode 100644 routers/typescript/vitest.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6182646..d2190c6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,3 +44,35 @@ jobs: - run: pnpm run typecheck - run: pnpm run test - run: pnpm run build + + conformance: + name: Conformance (ts) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - run: pnpm install --frozen-lockfile + + - name: Build the TS router + run: pnpm -C routers/typescript build + + - uses: astral-sh/setup-uv@v5 + + - name: Set up the conformance harness + working-directory: conformance + run: | + uv venv --python 3.10 .venv + uv pip install --python .venv/bin/python schemathesis pytest httpx + + - name: Run conformance against the TS router + working-directory: conformance + env: + ROUTER: ts + run: .venv/bin/python -m pytest -q diff --git a/.prettierignore b/.prettierignore index eae7d74..edd90d5 100644 --- a/.prettierignore +++ b/.prettierignore @@ -5,4 +5,9 @@ pnpm-lock.yaml CHANGELOG.md LICENSE NOTICE -.github/CODEOWNERS \ No newline at end of file +.github/CODEOWNERS + +# Python harness tooling (conformance venv + caches) — not ours to format +**/.venv +**/.pytest_cache +**/__pycache__ \ No newline at end of file diff --git a/conformance/.gitignore b/conformance/.gitignore new file mode 100644 index 0000000..7c04452 --- /dev/null +++ b/conformance/.gitignore @@ -0,0 +1,4 @@ +.venv/ +__pycache__/ +.pytest_cache/ +.hypothesis/ diff --git a/conformance/README.md b/conformance/README.md new file mode 100644 index 0000000..4e2a19f --- /dev/null +++ b/conformance/README.md @@ -0,0 +1,57 @@ +# `/embed` conformance suite + +Proves that every backend router behaves identically and matches the +[`/embed` contract](../packages/embed/openapi/embed.openapi.yaml). It is +**language-agnostic** — it boots a router under test and drives it over HTTP, so +the same suite runs against the TypeScript, Go, and Python routers (and any +future one). + +## How it works + +``` +mock_monad.py ── a stateful in-memory stand-in for the Monad API + ▲ + │ MONAD_API_BASE +router under test ── booted by conftest.py (ROUTER=python|go|ts), mounted at /embed + ▲ + │ HTTP +tests ── test_conformance.py (Schemathesis) + test_scenarios.py (lifecycle) +``` + +- **`test_conformance.py`** — Schemathesis reads the OpenAPI spec, generates + requests for every operation, and asserts each response matches the documented + **status code, response schema, and content type**. +- **`test_scenarios.py`** — the stateful lifecycle Schemathesis's stateless + fuzzing can't cover: mint → build ingress → status → disable → status → + remove → status, plus egress. + +The router is booted with a stubbed `getCustomerOrgID` (always the tenant +`org_conf`) and provisioning (`destinationOutputId=out_store`, `sourceInputId=in_source`), +pointed at the mock. Nothing touches real Monad. + +## Run + +```sh +# one-time: create the harness venv (installs Schemathesis + the Python router) +uv venv --python 3.10 .venv +uv pip install --python .venv/bin/python schemathesis pytest httpx fastapi uvicorn -e ../routers/python + +./run.sh # all routers: python go ts +./run.sh python go # a subset +ROUTER=ts .venv/bin/python -m pytest -q # a single router directly +``` + +Prerequisites per router: **python** — none (uses the harness venv); **go** — +a Go toolchain (`go run ./cmd/conformance`); **ts** — the built package +(`run.sh` runs `pnpm -C packages/embed build` automatically). + +## Scope note — auth and negative input + +The suite runs Schemathesis's **response-conformance** checks +(`status_code_conformance`, `response_schema_conformance`, +`content_type_conformance`). It deliberately does **not** run the +`ignored_auth` or `negative_data_rejection` checks: auth is the host's +responsibility — each router mounts _behind_ the host's auth middleware and +trusts `getCustomerOrgID`, which the harness stubs — so those checks would test the +stub, not the router. A `5xx` is a documented, conformant outcome here +(`500 internal_error`, `502 upstream_error`). diff --git a/conformance/conftest.py b/conformance/conftest.py new file mode 100644 index 0000000..4265314 --- /dev/null +++ b/conformance/conftest.py @@ -0,0 +1,75 @@ +"""Conformance harness fixtures. + +Boots the stateful mock Monad plus the router under test (selected by the +``ROUTER`` env var: ``python`` | ``go`` | ``ts``), pointed at the mock, then +yields for the tests. Language-agnostic: every test drives the router over HTTP. +""" + +from __future__ import annotations + +import os +import signal +import subprocess +import time +import urllib.request + +import pytest + +import mock_monad + +ROUTER = os.environ.get("ROUTER", "python") +MOCK_PORT = int(os.environ.get("MOCK_PORT", "8790")) +ROUTER_PORT = int(os.environ.get("ROUTER_PORT", "8791")) + +_HERE = os.path.dirname(os.path.abspath(__file__)) +_REPO = os.path.abspath(os.path.join(_HERE, "..")) + + +def _command() -> tuple[list[str], str, dict[str, str]]: + env = dict(os.environ) + env["MONAD_API_BASE"] = f"http://127.0.0.1:{MOCK_PORT}" + env["PORT"] = str(ROUTER_PORT) + if ROUTER == "python": + return [os.path.join(_HERE, ".venv", "bin", "python"), os.path.join(_HERE, "servers", "py_server.py")], _HERE, env + if ROUTER == "go": + return ["go", "run", "./cmd/conformance"], os.path.join(_REPO, "routers", "go"), env + if ROUTER == "ts": + return ["node", os.path.join(_HERE, "servers", "ts_server.mjs")], _HERE, env + raise ValueError(f"unknown ROUTER={ROUTER!r} (expected python|go|ts)") + + +def _wait_healthy(url: str, timeout: float = 45.0) -> bool: + deadline = time.time() + timeout + while time.time() < deadline: + try: + with urllib.request.urlopen(url, timeout=1) as resp: + if resp.status < 500: + return True + except Exception: # noqa: BLE001 — server not up yet + time.sleep(0.15) + return False + + +@pytest.fixture(scope="session", autouse=True) +def servers(): + server, _state = mock_monad.start(MOCK_PORT) + cmd, cwd, env = _command() + # New session so we can kill the whole group (e.g. `go run` + its child binary). + proc = subprocess.Popen(cmd, cwd=cwd, env=env, start_new_session=True) + try: + if not _wait_healthy(f"http://127.0.0.1:{ROUTER_PORT}/embed/config"): + raise RuntimeError(f"router '{ROUTER}' did not become healthy on port {ROUTER_PORT}") + yield + finally: + try: + os.killpg(os.getpgid(proc.pid), signal.SIGTERM) + except ProcessLookupError: + pass + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + try: + os.killpg(os.getpgid(proc.pid), signal.SIGKILL) + except ProcessLookupError: + pass + server.shutdown() diff --git a/conformance/mock_monad.py b/conformance/mock_monad.py new file mode 100644 index 0000000..2fec298 --- /dev/null +++ b/conformance/mock_monad.py @@ -0,0 +1,275 @@ +"""A stateful in-memory stand-in for the Monad API. + +The conformance harness boots a router under test pointed at this mock (via +``MONAD_API_BASE``) so the routers run their real logic without touching real +Monad. It is stateful enough for the lifecycle scenario: creating a pipeline +stores it, listing/detail return it, PATCH updates it, DELETE removes it. + +Response shapes here mirror the **real Monad API** (from its OpenAPI / +``pkg/routes``) so a router that mis-parses a real response is caught: + +- ``GET /v1/{kind}s`` (catalog) → a bare array of connector types. +- ``GET /v1/{org}/{kind}s`` → ``{ "s": [...] | null, "pagination": {...} }`` + (the list is **null**, not ``[]``, when empty). +- ``POST /v2/{org}/pipelines`` → **201** with the full pipeline object. +- ``GET /v2/{org}/pipelines`` → ``{ "pipelines": [...], "pagination": {...} }``. +- ``GET /v2/{org}/pipelines/{id}`` → the full pipeline object at top level + (``nodes``/``edges``/``enabled`` — no ``config`` wrapper). +- ``POST /v2/{org}/outputs`` → the created output object. +- ``POST /v3/sessions`` → ``{ "session_token", "expires_at" }``. +""" + +from __future__ import annotations + +import json +import re +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + + +class _State: + def __init__(self) -> None: + self.lock = threading.Lock() + self.pipelines: dict[str, dict] = {} + self._counter = 0 + + def new_id(self, prefix: str) -> str: + with self.lock: + self._counter += 1 + return f"{prefix}_{self._counter}" + + +def _component_ids(pipeline: dict) -> frozenset: + """The set of component ids a pipeline wires — used to detect a duplicate + connection (Monad rejects connecting the same components twice → 409).""" + return frozenset( + n.get("component_id") for n in pipeline.get("nodes", []) if n.get("component_id") + ) + + +def _input_id(pipeline: dict): + return next( + (n.get("component_id") for n in pipeline.get("nodes", []) if n.get("component_type") == "input"), + None, + ) + + +def _pipeline_view(p: dict) -> dict: + """A pipeline shaped like real Monad's create / detail / patch response: + the record at top level, with `nodes`/`edges`/`enabled` (no `config` wrapper).""" + return { + "id": p.get("id"), + "name": p.get("name", ""), + "description": p.get("description", ""), + "enabled": bool(p.get("enabled")), + "organization_id": "org_conf", + "managed_by": "", + "nodes": p.get("nodes", []), + "edges": p.get("edges", []), + "status": {"pipeline_id": p.get("id"), "status": "Running"}, + "createdAt": "2026-01-01T00:00:00Z", + "updatedAt": "2026-01-01T00:00:00Z", + } + + +def _pipeline_summary(p: dict) -> dict: + """A pipeline as it appears in the list endpoint (a lighter projection).""" + return { + "id": p.get("id"), + "name": p.get("name", ""), + "enabled": bool(p.get("enabled")), + "input_id": _input_id(p), + "organization_id": "org_conf", + } + + +def _output_view(oid: str, body: dict) -> dict: + return { + "id": oid, + "name": body.get("name", ""), + "description": body.get("description", ""), + "type": body.get("output_type", "dev-null"), + "organization_id": "org_conf", + "managed_by": "", + "config": {"settings": {}, "secrets": {}}, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z", + } + + +def _make_handler(state: _State): + class Handler(BaseHTTPRequestHandler): + def log_message(self, *_args) -> None: # silence per-request logging + pass + + def _send(self, code: int, obj=None) -> None: + body = b"" if obj is None else json.dumps(obj).encode() + self.send_response(code) + if obj is not None: + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + if body: + self.wfile.write(body) + + def _read(self) -> dict: + n = int(self.headers.get("Content-Length") or 0) + if n == 0: + return {} + try: + return json.loads(self.rfile.read(n) or b"{}") + except (ValueError, TypeError): + return {} + + def do_GET(self): # noqa: N802 + self._route("GET") + + def do_POST(self): # noqa: N802 + self._route("POST") + + def do_PATCH(self): # noqa: N802 + self._route("PATCH") + + def do_DELETE(self): # noqa: N802 + self._route("DELETE") + + def _route(self, method: str) -> None: + path = self.path.split("?", 1)[0] + body = self._read() if method in ("POST", "PATCH") else {} + + # Embed session mint: { session_token, expires_at }. + if method == "POST" and path == "/v3/sessions": + return self._send( + 200, {"session_token": "tok_conf", "expires_at": "2026-12-31T00:00:00Z"} + ) + + # Catalog of connector types — a bare array (no wrapper). + if method == "GET" and re.fullmatch(r"/v1/(inputs|outputs)", path): + return self._send( + 200, + [ + { + "type_id": "aws-cloudtrail", + "name": "AWS CloudTrail", + "description": "AWS CloudTrail logs", + "category": "cloud", + "in_beta": False, + }, + { + "type_id": "okta-systemlog", + "name": "Okta System Log", + "description": "Okta system log", + "category": "identity", + "in_beta": False, + }, + ], + ) + + # A tenant's configured connectors — wrapped alongside `pagination`, + # and returned as null (not []) when the tenant has none. Exercise + # both shapes: a populated `inputs`, an empty (null) `outputs`. + m = re.fullmatch(r"/v1/([^/]+)/(inputs|outputs)", path) + if method == "GET" and m: + kind = m.group(2) + rows = ( + None + if kind == "outputs" + else [ + { + "id": "cfg_1", + "type": "aws-cloudtrail", + "name": "Configured", + "organization_id": "org_conf", + "config": {"settings": {}, "secrets": {}}, + } + ] + ) + return self._send( + 200, + { + kind: rows, + "pagination": {"limit": 1000, "offset": 0, "total": 0 if rows is None else 1}, + }, + ) + + # Create an output (e.g. the dev/null sink) — the created record. + if method == "POST" and re.fullmatch(r"/v2/([^/]+)/outputs", path): + return self._send(200, _output_view(state.new_id("out"), body)) + + # pipelines collection (create / list) — check before status/detail + if re.fullmatch(r"/v2/([^/]+)/pipelines/?", path): + if method == "POST": + pid = state.new_id("pipe") + with state.lock: + new_ids = _component_ids(body) + if new_ids and any( + _component_ids(p) == new_ids for p in state.pipelines.values() + ): + return self._send( + 409, + {"error": "conflict", "message": "connector already connected"}, + ) + state.pipelines[pid] = {**body, "id": pid} + view = _pipeline_view(state.pipelines[pid]) + # Real Monad returns 201 with the full pipeline record. + return self._send(201, view) + if method == "GET": + with state.lock: + items = [_pipeline_summary(p) for p in state.pipelines.values()] + return self._send( + 200, + { + "pipelines": items, + "pagination": {"limit": 1000, "offset": 0, "total": len(items)}, + }, + ) + + if method == "GET" and re.fullmatch(r"/v2/([^/]+)/pipelines/([^/]+)/status", path): + pid = path.rsplit("/", 2)[-2] + return self._send( + 200, + { + "pipeline_id": pid, + "status": "Running", + "ingress": {"bytes": 0, "records": 0}, + "egress": {"bytes": 0, "records": 0}, + "nodes": [], + }, + ) + + m = re.fullmatch(r"/v2/([^/]+)/pipelines/([^/]+)", path) + if m: + pid = m.group(2) + if method == "GET": + with state.lock: + p = state.pipelines.get(pid) + if p is None: + return self._send(404, {"error": "pipeline not found"}) + # The full pipeline record at top level (no `config` wrapper). + return self._send(200, _pipeline_view(p)) + if method == "PATCH": + with state.lock: + if pid not in state.pipelines: + return self._send(404, {"error": "pipeline not found"}) + state.pipelines[pid] = {**state.pipelines[pid], **body, "id": pid} + view = _pipeline_view(state.pipelines[pid]) + return self._send(200, view) + if method == "DELETE": + with state.lock: + state.pipelines.pop(pid, None) + return self._send(204) + + if method == "DELETE" and re.fullmatch(r"/v1/([^/]+)/(inputs|outputs)/([^/]+)", path): + return self._send(204) + + return self._send(404, {"error": f"mock unhandled {method} {path}"}) + + return Handler + + +def start(port: int): + """Start the mock on 127.0.0.1:port in a background thread. Returns (server, state).""" + state = _State() + server = ThreadingHTTPServer(("127.0.0.1", port), _make_handler(state)) + threading.Thread(target=server.serve_forever, daemon=True).start() + return server, state diff --git a/conformance/run.sh b/conformance/run.sh new file mode 100755 index 0000000..322f54b --- /dev/null +++ b/conformance/run.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Run the conformance suite against one or more routers. +# ./run.sh # all: python go ts +# ./run.sh python go # a subset +set -uo pipefail +cd "$(dirname "$0")" + +PY=.venv/bin/python +ROUTERS="${*:-python go ts}" + +# The TS router runs from its built standalone package. +if [[ " $ROUTERS " == *" ts "* ]]; then + echo "=== building @monad-inc/embed-server (for ts router) ===" + (cd ../routers/typescript && pnpm build >/dev/null) +fi + +rc=0 +for r in $ROUTERS; do + echo "" + echo "======================== conformance: $r ========================" + if ROUTER="$r" "$PY" -m pytest -q; then + echo "--- $r: PASS ---" + else + echo "--- $r: FAIL ---" + rc=1 + fi +done +exit $rc diff --git a/conformance/servers/ts_server.mjs b/conformance/servers/ts_server.mjs new file mode 100644 index 0000000..2a25635 --- /dev/null +++ b/conformance/servers/ts_server.mjs @@ -0,0 +1,20 @@ +// Boot the TypeScript router for conformance testing, pointed at the mock +// Monad. Imports the built standalone package (run `pnpm -C routers/typescript +// build` first). +import http from 'node:http'; +import { createEmbedRouter } from '../../routers/typescript/dist/index.js'; + +const router = createEmbedRouter({ + apiKey: 'conf-key', + apiBase: process.env.MONAD_API_BASE, + frameOrigin: 'https://app.monad.com/embed', + getCustomerOrgID: () => 'org_conf', + getProvisionedComponents: () => ({ + destinationOutputId: 'out_store', + sourceInputId: 'in_source' + }), + catalogAllow: ['aws-cloudtrail', 'okta-systemlog'] +}); + +const port = Number(process.env.PORT || 8791); +http.createServer(router).listen(port, '127.0.0.1'); diff --git a/conformance/test_conformance.py b/conformance/test_conformance.py new file mode 100644 index 0000000..67712e2 --- /dev/null +++ b/conformance/test_conformance.py @@ -0,0 +1,42 @@ +"""Schemathesis property-based conformance: does every response match the spec? + +Schemathesis reads the OpenAPI contract, generates requests for every operation, +and asserts each response conforms — documented status code, response schema, +and content type. This is language-agnostic: it drives whichever router the +``servers`` fixture booted. +""" + +import os + +import schemathesis +from schemathesis.specs.openapi.checks import ( + content_type_conformance, + response_schema_conformance, + status_code_conformance, +) + +_SPEC = os.path.join( + os.path.dirname(__file__), "..", "packages", "embed", "openapi", "embed.openapi.yaml" +) +_BASE_URL = os.environ.get("ROUTER_BASE_URL", "http://127.0.0.1:8791") + +schema = schemathesis.openapi.from_path(_SPEC) + +# We validate RESPONSE conformance: every response the router returns for every +# operation must match the spec's documented status code, response schema, and +# content type. We deliberately do NOT run Schemathesis's security/negative +# checks (`ignored_auth`, `negative_data_rejection`): auth is the host's job — +# the router mounts *behind* the host's auth middleware, and the conformance +# harness stubs `getCustomerOrgID` to always succeed — so those checks would test +# the harness stub, not the router's contract behavior. A 5xx is also a +# documented, conformant outcome here (500/502), so `not_a_server_error` is out. +_CONFORMANCE_CHECKS = [ + status_code_conformance, + response_schema_conformance, + content_type_conformance, +] + + +@schema.parametrize() +def test_response_conforms_to_spec(case): + case.call_and_validate(base_url=_BASE_URL, checks=_CONFORMANCE_CHECKS) diff --git a/conformance/test_scenarios.py b/conformance/test_scenarios.py new file mode 100644 index 0000000..6a8fef0 --- /dev/null +++ b/conformance/test_scenarios.py @@ -0,0 +1,120 @@ +"""Stateful scenario conformance — the lifecycle Schemathesis's stateless +fuzzing won't cover: mint → build → status → disable → status → remove → status. + +Runs against whichever router the ``servers`` fixture booted, over HTTP, so it +is identical for every language. +""" + +import os + +import httpx +import pytest + +_BASE_URL = os.environ.get("ROUTER_BASE_URL", "http://127.0.0.1:8791") + + +@pytest.fixture() +def client(): + with httpx.Client(base_url=_BASE_URL, timeout=15) as c: + yield c + + +def test_config_is_public(client): + r = client.get("/embed/config") + assert r.status_code == 200 + body = r.json() + assert body["frameOrigin"] == "https://app.monad.com/embed" + assert "apiBase" in body + + +def test_ingress_lifecycle(client): + # 1) mint a session for the resolved tenant + r = client.post("/embed/session") + assert r.status_code == 200, r.text + assert r.json()["organizationId"] == "org_conf" + + # 2) the iframe returned an input id → build the ingress pipeline + r = client.post("/embed/pipelines/ingress", json={"inputId": "in_conf", "name": "CloudTrail"}) + assert r.status_code == 201, r.text + built = r.json() + pipeline_id = built["pipelineId"] + assert built["outputId"] == "out_store" # wired to the provisioned store + assert built["active"] is True + + # 3) status resolves the pipeline from the input id + r = client.get("/embed/pipelines", params={"connectorId": "in_conf", "kind": "input"}) + assert r.status_code == 200, r.text + status = r.json() + assert status["hasPipeline"] is True + assert status["pipelineId"] == pipeline_id + assert status["outputId"] == "out_store" + assert status["enabled"] is True + + # 4) disable — stops flow without deleting config + r = client.post("/embed/pipelines/state", json={"pipelineId": pipeline_id, "enabled": False}) + assert r.status_code == 204, r.text + + r = client.get("/embed/pipelines", params={"connectorId": "in_conf", "kind": "input"}) + assert r.json()["enabled"] is False + + # 5) remove — the provisioned store is kept, the pipeline + input are gone + r = client.post("/embed/pipelines/remove", json={"connectorId": "in_conf", "kind": "input"}) + assert r.status_code == 204, r.text + + r = client.get("/embed/pipelines", params={"connectorId": "in_conf", "kind": "input"}) + assert r.json()["hasPipeline"] is False + + +def test_egress_builds_from_provisioned_source(client): + # the iframe returned an output id → wire the tenant's source → it + r = client.post("/embed/pipelines/egress", json={"outputId": "out_conf", "name": "Splunk"}) + assert r.status_code == 201, r.text + assert r.json()["outputId"] == "out_conf" + + +def test_catalog_is_allow_listed(client): + r = client.get("/embed/catalog", params={"kind": "input"}) + assert r.status_code == 200 + type_ids = {t["typeId"] for t in r.json()} + assert type_ids == {"aws-cloudtrail", "okta-systemlog"} + + +# Every route that takes `kind` must reject an invalid value with the shared +# error model — not just /catalog. (Schemathesis only ever sends enum-valid +# `kind`s, so this negative case has to be asserted explicitly.) +@pytest.mark.parametrize( + "call", + [ + lambda c: c.get("/embed/catalog", params={"kind": "nope"}), + lambda c: c.get("/embed/connectors", params={"kind": "nope"}), + lambda c: c.get("/embed/pipelines", params={"connectorId": "in_x", "kind": "nope"}), + lambda c: c.post("/embed/pipelines/remove", json={"connectorId": "in_x", "kind": "nope"}), + ], + ids=["catalog", "connectors", "pipelines", "remove"], +) +def test_invalid_kind_is_rejected(client, call): + r = call(client) + assert r.status_code == 400, r.text + assert r.json()["code"] == "invalid_request" + + +def test_state_on_unknown_pipeline_is_404(client): + # Toggling a pipeline that doesn't exist for this tenant is a 404 not_found — + # the router must translate Monad's 404, not fold it into a generic 502. + r = client.post( + "/embed/pipelines/state", + json={"pipelineId": "pipe_does_not_exist", "enabled": False}, + ) + assert r.status_code == 404, r.text + assert r.json()["code"] == "not_found" + + +def test_duplicate_ingress_conflicts(client): + # Connecting the same source twice collides with existing state → 409 conflict + # (a known Monad constraint the router must surface as the contract's `conflict`). + body = {"inputId": "in_dup_conflict", "name": "Dup Conflict Test"} + first = client.post("/embed/pipelines/ingress", json=body) + assert first.status_code == 201, first.text + second = client.post("/embed/pipelines/ingress", json=body) + assert second.status_code == 409, second.text + assert second.json()["code"] == "conflict" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e16ba30..fc793b4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -45,6 +45,21 @@ importers: specifier: ^2.1.5 version: 2.1.9(@types/node@22.19.21)(jsdom@25.0.1) + routers/typescript: + devDependencies: + '@types/node': + specifier: ^22.9.0 + version: 22.19.21 + tsup: + specifier: ^8.3.5 + version: 8.5.1(postcss@8.5.15)(typescript@5.9.3) + typescript: + specifier: ^5.6.3 + version: 5.9.3 + vitest: + specifier: ^2.1.5 + version: 2.1.9(@types/node@22.19.21)(jsdom@25.0.1) + packages: '@asamuzakjp/css-color@3.2.0': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 18ec407..654bb7d 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,2 +1,3 @@ packages: - 'packages/*' + - 'routers/typescript' diff --git a/routers/typescript/LICENSE b/routers/typescript/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/routers/typescript/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/routers/typescript/README.md b/routers/typescript/README.md new file mode 100644 index 0000000..aa713da --- /dev/null +++ b/routers/typescript/README.md @@ -0,0 +1,163 @@ +# Monad Embed — TypeScript router + +A standalone, mountable `/embed` backend router for a Monad embed integration. +Implements the [`/embed` route contract](../../packages/embed/openapi/embed.openapi.yaml). +**Zero runtime dependencies** and no dependency on any other Monad package — +lift it and go. Mounts into **Express, Fastify, Koa, and bare Node `http`** via a +Node adapter, and into **Next.js, SvelteKit, and edge runtimes** via a +framework-agnostic core. + +```ts +import express from 'express'; +import { createEmbedRouter } from '@monad-inc/embed-server'; + +const app = express(); +app.use( + '/embed', + createEmbedRouter({ + apiKey: process.env.MONAD_API_KEY!, + // your auth → the tenant's Monad team id (sync or async) + getCustomerOrgID: (req) => sessionOrg(req.raw), + // server-side lookup of a tenant's pre-provisioned components + getProvisionedComponents: (org) => ({ destinationOutputId: stores[org] }) + }) +); +``` + +Mounted with `app.use('/embed', …)`, the router serves `/embed/session`, +`/embed/pipelines/ingress`, etc. The browser holds only a session token; this +router holds the API key and is the seam to the Monad API. + +The router has two entry points, and which you use depends on your server — +**not** your frontend framework (React/Svelte are frontend; this is backend): + +- **`createEmbedRouter(cfg)`** — a Node `(req, res)` handler (it strips the + `/embed` prefix itself). Use it with **Express, Fastify, Koa, bare Node + `http`, Vite** — anything that speaks Node's req/res. +- **`createEmbedHandler(cfg)`** — the framework-agnostic core, a plain + `(EmbedRequest) => EmbedResponse` with no framework and no deps. Use it with + **Web-standard** servers — **Next.js, SvelteKit**, Hono, Bun, Deno, Cloudflare + Workers — which hand you a Fetch `Request` and take a `Response`. + +Most full-stack apps mount this in their meta-framework's own server layer +(Next.js route handlers, SvelteKit `+server.ts`), not a separate Express server. + +## Node frameworks — `createEmbedRouter` + +**Express** — [expressjs](https://expressjs.com) + +```ts +app.use('/embed', createEmbedRouter(cfg)); +``` + +**Fastify** — [fastify](https://fastify.dev) via [`@fastify/middie`](https://github.com/fastify/middie) + +```ts +import middie from '@fastify/middie'; + +await fastify.register(middie); +fastify.use('/embed', createEmbedRouter(cfg)); +``` + +**Koa** — [koajs](https://koajs.com) + +```ts +const embed = createEmbedRouter(cfg); + +app.use((ctx, next) => { + if (!ctx.path.startsWith('/embed')) return next(); + ctx.respond = false; // hand the raw res to the embed router + embed(ctx.req, ctx.res); +}); +``` + +**Bare Node `http`** — a dedicated server (routes served under `/embed`) + +```ts +import http from 'node:http'; + +http.createServer(createEmbedRouter(cfg)).listen(8080); +``` + +## Web-standard frameworks — `createEmbedHandler` + +Next.js, SvelteKit, and edge runtimes call handlers with a Fetch `Request` and +expect a `Response`. One small adapter bridges that to the core — write it once: + +```ts +import { createEmbedHandler, type EmbedRequest } from '@monad-inc/embed-server'; + +const handle = createEmbedHandler(cfg); + +// Web `Request` → `EmbedRequest` → `Response`. `subpath` is the part after +// /embed (e.g. "session", "pipelines/ingress"); `raw` is whatever your +// getCustomerOrgID reads auth from (the Request, or a framework event). +async function serve(request: Request, subpath: string, raw: unknown = request) { + const query: Record = {}; + new URL(request.url).searchParams.forEach((v, k) => (query[k] = v)); + + const res = await handle({ + method: request.method, + path: '/' + subpath, + query, + headers: Object.fromEntries(request.headers), + body: request.body ? await request.json().catch(() => undefined) : undefined, + raw + } satisfies EmbedRequest); + + return res.body === undefined + ? new Response(null, { status: res.status }) + : Response.json(res.body, { status: res.status }); +} +``` + +**Next.js** (App Router) — `app/embed/[...path]/route.ts` + +```ts +// `params` is a Promise in Next 15+; awaiting it also works on older versions. +export async function GET(request: Request, ctx: { params: Promise<{ path: string[] }> }) { + return serve(request, (await ctx.params).path.join('/')); +} +export const POST = GET; +``` + +**SvelteKit** — `src/routes/embed/[...path]/+server.ts` + +```ts +import type { RequestHandler } from './$types'; + +// Pass the whole `event` as `raw` so getCustomerOrgID can read event.locals / cookies. +const handler: RequestHandler = (event) => serve(event.request, event.params.path, event); + +export const GET = handler; +export const POST = handler; +``` + +The same adapter works on Hono, `Bun.serve`, `Deno.serve`, and Cloudflare +Workers — they all hand you a `Request` and take a `Response`. + +## Config + +| Field | Purpose | +| -------------------------- | --------------------------------------------------------------------------------------------------- | +| `apiKey` | Long-lived Monad API key (server-side only). | +| `apiBase` | Monad API base. Optional — defaults to `https://app.monad.com/api` (production). | +| `frameOrigin` | Iframe origin returned by `GET /embed/config`. Optional — defaults to production. | +| `getCustomerOrgID` | `EmbedRequest → org id`, sync or async. Return `""` / throw to reject (→ 401). | +| `getProvisionedComponents` | `org → { destinationOutputId?, sourceInputId? }`. Omit → ingress uses dev/null, egress unavailable. | +| `catalogAllow` | Restrict the catalog to these connector type ids. Omit → all. | + +## Relationship to `@monad-inc/embed` + +This router is self-contained — it inlines its own Monad client. If instead you +are writing your _own_ backend routes (not mounting a prebuilt router), the +ergonomic Monad client lives in `@monad-inc/embed/kit`. + +## Develop + +```sh +pnpm install +pnpm build # tsup → dist (ESM + CJS + d.ts) +pnpm typecheck +pnpm test # vitest +``` diff --git a/routers/typescript/package.json b/routers/typescript/package.json new file mode 100644 index 0000000..5979fc8 --- /dev/null +++ b/routers/typescript/package.json @@ -0,0 +1,58 @@ +{ + "name": "@monad-inc/embed-server", + "version": "0.1.0", + "description": "Standalone, mountable /embed backend router for a Monad embed integration. Zero runtime dependencies; mounts into Express or bare Node http.", + "license": "Apache-2.0", + "author": "Monad Inc.", + "homepage": "https://github.com/monad-inc/embed/tree/main/routers/typescript", + "repository": { + "type": "git", + "url": "git+https://github.com/monad-inc/embed.git", + "directory": "routers/typescript" + }, + "keywords": [ + "monad", + "embed", + "router", + "express", + "white-label" + ], + "type": "module", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "sideEffects": false, + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "engines": { + "node": ">=18" + }, + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "tsup", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest" + }, + "devDependencies": { + "@types/node": "^22.9.0", + "tsup": "^8.3.5", + "typescript": "^5.6.3", + "vitest": "^2.1.5" + } +} diff --git a/routers/typescript/src/core.ts b/routers/typescript/src/core.ts new file mode 100644 index 0000000..9170968 --- /dev/null +++ b/routers/typescript/src/core.ts @@ -0,0 +1,286 @@ +/** + * @monad-inc/embed-server — the backend `/embed` router, framework-agnostic core. + * + * A standalone, self-contained implementation of the `/embed` route contract + * (see the repo's `packages/embed/openapi/embed.openapi.yaml`). It depends on + * nothing but its own inlined Monad client (`./monad`), so this package stands + * on its own — lift it and go. + * + * `createEmbedHandler` returns a plain `(EmbedRequest) => EmbedResponse` with + * ZERO runtime dependencies. A framework adapter (`./node`) turns it into a + * mountable router. + */ +import { MonadApi, UpstreamError, type ComponentKind } from './monad'; + +/** What the host pre-provisions per tenant — resolved server-side, never sent by the browser. */ +export interface Provision { + /** Ingress target: the tenant's destination output. Omit to send ingress to a dev/null sink. */ + destinationOutputId?: string; + /** Egress source: the tenant's pre-provisioned input. Required to build an egress pipeline. */ + sourceInputId?: string; +} + +/** A request normalized to the shape the core dispatches on. */ +export interface EmbedRequest { + method: string; + /** Path within the `/embed` mount, e.g. `/session`, `/pipelines/ingress`. */ + path: string; + query: Record; + headers: Record; + body?: unknown; + /** The native framework request, passed to `getCustomerOrgID` so it can read the host's auth. */ + raw?: unknown; +} + +/** A response the adapter serializes as JSON (or an empty 204). */ +export interface EmbedResponse { + status: number; + body?: unknown; +} + +/** Configuration shared by every language's router. */ +export interface EmbedServerConfig { + /** Long-lived Monad API key. Server-side only. */ + apiKey: string; + /** Monad API base. Defaults to `https://app.monad.com/api` (production); set only for non-prod. */ + apiBase?: string; + /** + * Iframe origin returned by `GET /embed/config`. Defaults to + * `https://app.monad.com/embed` (production); set only for non-prod. + */ + frameOrigin?: string; + /** + * Map the authenticated request to the caller's Monad team id. The one seam + * only the host can fill. Throw an {@link EmbedError} (or any error → 401) to + * reject an unauthenticated/unauthorized caller. + */ + getCustomerOrgID: (req: EmbedRequest) => string | Promise; + /** Per-tenant pre-provisioned resources. Omit → ingress uses dev/null and egress is unavailable. */ + getProvisionedComponents?: (org: string) => Provision | Promise; + /** Restrict the catalog to these connector type ids. Omit → expose everything. */ + catalogAllow?: string[]; +} + +/** An error carrying the HTTP status + stable code from the contract's error model. */ +export class EmbedError extends Error { + constructor( + public readonly status: number, + public readonly code: string, + message: string + ) { + super(message); + this.name = 'EmbedError'; + } +} + +const json = (status: number, body: unknown): EmbedResponse => ({ status, body }); +const noContent = (): EmbedResponse => ({ status: 204 }); + +function requireString(body: unknown, field: string): string { + const v = (body as Record | undefined)?.[field]; + if (typeof v !== 'string' || v.length === 0) { + throw new EmbedError(400, 'invalid_request', `Field '${field}' is required.`); + } + return v; +} + +function requireBoolean(body: unknown, field: string): boolean { + const v = (body as Record | undefined)?.[field]; + if (typeof v !== 'boolean') { + throw new EmbedError(400, 'invalid_request', `Field '${field}' must be a boolean.`); + } + return v; +} + +function requireKind(value: string | undefined, where: string): ComponentKind { + if (value !== 'input' && value !== 'output') { + throw new EmbedError(400, 'invalid_request', `${where} must be 'input' or 'output'.`); + } + return value; +} + +/** Wrap a Monad call so any failure surfaces as the contract's `502 upstream_error`. */ +async function upstream(p: Promise): Promise { + try { + return await p; + } catch (e) { + // Translate Monad's own status codes into the contract's error model. + if (e instanceof UpstreamError) { + if (e.status === 404) { + throw new EmbedError( + 404, + 'not_found', + 'The referenced connector or pipeline does not exist.' + ); + } + if (e.status === 409) { + throw new EmbedError(409, 'conflict', 'The request conflicts with existing state.'); + } + } + // Anything else: log the detail server-side, return a generic 502. + console.error('[embed] upstream Monad API call failed:', e); + throw new EmbedError(502, 'upstream_error', 'The upstream Monad API request failed.'); + } +} + +/** Build the framework-agnostic `/embed` handler. Hold one per process. */ +export function createEmbedHandler( + config: EmbedServerConfig +): (req: EmbedRequest) => Promise { + // Production defaults — override apiBase/frameOrigin only for non-prod. + const apiBase = config.apiBase ?? 'https://app.monad.com/api'; + const frameOrigin = config.frameOrigin ?? 'https://app.monad.com/embed'; + const monad = new MonadApi(config.apiKey, apiBase); + + async function resolve(req: EmbedRequest): Promise { + try { + const org = await config.getCustomerOrgID(req); + if (!org) { + throw new EmbedError(401, 'unauthenticated', 'Could not resolve a tenant for the request.'); + } + return org; + } catch (e) { + if (e instanceof EmbedError) throw e; + throw new EmbedError( + 401, + 'unauthenticated', + e instanceof Error ? e.message : 'Tenant resolution failed.' + ); + } + } + + async function provision(org: string): Promise { + return config.getProvisionedComponents ? await config.getProvisionedComponents(org) : {}; + } + + async function dispatch(req: EmbedRequest): Promise { + const method = req.method.toUpperCase(); + const path = req.path || '/'; + + if (method === 'GET' && path === '/config') { + return json(200, { frameOrigin, apiBase }); + } + + if (method === 'POST' && path === '/session') { + const org = await resolve(req); + return json(200, await upstream(monad.mintSession(org))); + } + + if (method === 'GET' && path === '/catalog') { + await resolve(req); + const kind = requireKind(req.query.kind, "Query 'kind'"); + return json(200, await upstream(monad.listCatalog(kind, config.catalogAllow))); + } + + if (method === 'GET' && path === '/connectors') { + const org = await resolve(req); + const kind = requireKind(req.query.kind, "Query 'kind'"); + return json(200, await upstream(monad.listConnectors(org, kind))); + } + + if (method === 'POST' && path === '/pipelines/ingress') { + const org = await resolve(req); + const inputId = requireString(req.body, 'inputId'); + const name = requireString(req.body, 'name'); + const prov = await provision(org); + const built = await upstream( + monad.connectSource(org, { inputId, name, toOutputId: prov.destinationOutputId }) + ); + return json(201, built); + } + + if (method === 'POST' && path === '/pipelines/egress') { + const org = await resolve(req); + const outputId = requireString(req.body, 'outputId'); + const name = requireString(req.body, 'name'); + const prov = await provision(org); + if (!prov.sourceInputId) { + throw new EmbedError( + 500, + 'internal_error', + 'No source input is provisioned for this tenant; egress cannot be built.' + ); + } + const built = await upstream( + monad.connectDestination(org, { outputId, name, fromInputId: prov.sourceInputId }) + ); + return json(201, built); + } + + if (method === 'GET' && path === '/pipelines') { + const org = await resolve(req); + const connectorId = req.query.connectorId; + if (!connectorId) { + throw new EmbedError(400, 'invalid_request', "Query 'connectorId' is required."); + } + const kind = requireKind(req.query.kind, "Query 'kind'"); + if (kind === 'input') { + return json(200, await upstream(monad.pipelineStatus(org, connectorId))); + } + const r = await upstream(monad.findPipelineByOutput(org, connectorId)); + return json(200, { + hasPipeline: r !== null, + enabled: r?.enabled ?? false, + pipelineId: r?.pipelineId, + inputId: r?.inputId + }); + } + + if (method === 'POST' && path === '/pipelines/state') { + const org = await resolve(req); + const pipelineId = requireString(req.body, 'pipelineId'); + const enabled = requireBoolean(req.body, 'enabled'); + await upstream(monad.setEnabled(org, pipelineId, enabled)); + return noContent(); + } + + if (method === 'POST' && path === '/pipelines/remove') { + const org = await resolve(req); + const connectorId = requireString(req.body, 'connectorId'); + const kind = requireKind( + (req.body as Record | undefined)?.kind as string | undefined, + "Field 'kind'" + ); + if (kind === 'input') { + const status = await upstream(monad.pipelineStatus(org, connectorId)); + const prov = await provision(org); + const keepStore = Boolean( + prov.destinationOutputId && + status.outputId && + prov.destinationOutputId === status.outputId + ); + await upstream( + monad.remove( + org, + { pipelineId: status.pipelineId, inputId: connectorId, outputId: status.outputId }, + { output: !keepStore } + ) + ); + } else { + const r = await upstream(monad.findPipelineByOutput(org, connectorId)); + // Keep the shared source input; remove only the pipeline + output. + await upstream( + monad.remove(org, { pipelineId: r?.pipelineId, outputId: connectorId }, { input: false }) + ); + } + return noContent(); + } + + return json(404, { code: 'not_found', message: `No route for ${method} ${path}.` }); + } + + return async (req: EmbedRequest): Promise => { + try { + return await dispatch(req); + } catch (e) { + if (e instanceof EmbedError) { + return json(e.status, { code: e.code, message: e.message }); + } + console.error('[embed] unexpected error handling request:', e); + return json(500, { + code: 'internal_error', + message: 'An unexpected error occurred.' + }); + } + }; +} diff --git a/routers/typescript/src/index.ts b/routers/typescript/src/index.ts new file mode 100644 index 0000000..0483199 --- /dev/null +++ b/routers/typescript/src/index.ts @@ -0,0 +1,39 @@ +/** + * @monad-inc/embed-server — a standalone, mountable `/embed` backend router. + * + * Implements the `/embed` route contract for a Monad embed integration. The + * host mounts this behind its own auth; the browser holds only a session token, + * this router holds the API key and is the seam to the Monad API. Depends on + * nothing but Node — lift it and go. + * + * import { createEmbedRouter } from '@monad-inc/embed-server'; + * + * app.use('/embed', createEmbedRouter({ + * apiKey: process.env.MONAD_API_KEY!, + * apiBase: 'https://app.monad.com/api', + * frameOrigin: 'https://app.monad.com/embed', + * getCustomerOrgID: (req) => sessionOrg(req.raw), // your auth → Monad team + * getProvisionedComponents: (org) => ({ destinationOutputId: stores[org] }), + * })); + */ +export { + createEmbedHandler, + EmbedError, + type EmbedServerConfig, + type EmbedRequest, + type EmbedResponse, + type Provision +} from './core'; + +export { createEmbedRouter, type EmbedRouterOptions } from './node'; + +export { + MonadApi, + type ComponentKind, + type Session, + type CatalogType, + type ConfiguredConnector, + type BuiltPipeline, + type PipelineStatus, + type CleanupPolicy +} from './monad'; diff --git a/routers/typescript/src/monad.ts b/routers/typescript/src/monad.ts new file mode 100644 index 0000000..e1bb381 --- /dev/null +++ b/routers/typescript/src/monad.ts @@ -0,0 +1,300 @@ +/** + * A self-contained Monad API client — the TypeScript equivalent of the Go and + * Python routers' inlined clients. This package stands on its own: it does NOT + * depend on `@monad-inc/embed`. It sequences Monad's /v1 + /v2 + /v3 calls and + * returns values already shaped to the `/embed` contract (camelCase). + * + * A failed Monad call throws; the router maps that to `502 upstream_error`. + */ + +export type ComponentKind = 'input' | 'output'; + +export interface Session { + sessionToken: string; + organizationId: string; + expiresAt: string; +} + +export interface CatalogType { + typeId: string; + name: string; +} + +export interface ConfiguredConnector { + id: string; + typeId: string; + name: string; +} + +export interface BuiltPipeline { + pipelineId: string; + outputId: string; + status: string; + active: boolean; +} + +export interface PipelineStatus { + hasPipeline: boolean; + enabled: boolean; + pipelineId?: string; + inputId?: string; + outputId?: string; +} + +/** Which resources a delete removes. A flag left undefined defaults to true. */ +export interface CleanupPolicy { + pipeline?: boolean; + input?: boolean; + output?: boolean; +} + +const POLL_ATTEMPTS = 15; +const POLL_INTERVAL_MS = 2000; + +/** URL-encode a single path segment so browser-supplied ids can't inject query + * params or traverse the path (`/`, `?`, `#` are neutralised). */ +const seg = (s: string): string => encodeURIComponent(s); + +/** + * Thrown when a Monad API call fails. Carries the status + internal detail for + * server-side logging; the router surfaces only the generic super() message to + * the browser, so upstream response bodies never cross the trust boundary. + */ +export class UpstreamError extends Error { + constructor( + readonly status: number, + readonly detail: string + ) { + super('The upstream Monad API request failed.'); + this.name = 'UpstreamError'; + } +} + +/* eslint-disable @typescript-eslint/no-explicit-any -- Monad responses are untyped JSON */ + +/** The Monad API client. Bind your key + base once; call plain methods. */ +export class MonadApi { + private readonly apiBase: string; + private readonly apiKey: string; + + constructor(apiKey: string, apiBase: string) { + this.apiKey = apiKey; + this.apiBase = apiBase; + } + + private async req(path: string, init?: RequestInit): Promise { + const r = await fetch(`${this.apiBase}${path}`, { + ...init, + headers: { + 'Content-Type': 'application/json', + Authorization: `ApiKey ${this.apiKey}`, + ...(init?.headers ?? {}) + } + }); + if (!r.ok) { + // Keep the upstream body server-side only — never surface it to the browser. + throw new UpstreamError(r.status, `${r.status} ${path}: ${(await r.text()).slice(0, 300)}`); + } + const text = await r.text(); + return text ? JSON.parse(text) : undefined; + } + + async mintSession(org: string): Promise { + const raw = await this.req('/v3/sessions', { + method: 'POST', + body: JSON.stringify({ ttl_seconds: 1800, organization_id: org }) + }); + return { sessionToken: raw.session_token, organizationId: org, expiresAt: raw.expires_at }; + } + + async listCatalog(kind: ComponentKind, allow?: string[]): Promise { + const types = (await this.req(`/v1/${kind}s`)) as { type_id: string; name: string }[]; + const list = types.map((t) => ({ typeId: t.type_id, name: t.name })); + if (!allow || allow.length === 0) return list; + const set = new Set(allow); + return list.filter((t) => set.has(t.typeId)); + } + + async listConnectors(org: string, kind: ComponentKind): Promise { + const page = (await this.req(`/v1/${seg(org)}/${kind}s?limit=1000&offset=0`)) as Record< + string, + { id: string; type: string; name: string }[] + >; + const rows = page?.[`${kind}s`] ?? []; + return rows.map((r) => ({ id: r.id, typeId: r.type, name: r.name })); + } + + private async wire( + org: string, + inputId: string, + outputId: string, + name: string + ): Promise { + const pipeline = (await this.req(`/v2/${seg(org)}/pipelines/`, { + method: 'POST', + body: JSON.stringify({ + name, + description: 'Created when the connector was configured via embed', + enabled: true, + nodes: [ + { slug: 'in', component_id: inputId, component_type: 'input', enabled: true }, + { slug: 'out', component_id: outputId, component_type: 'output', enabled: true } + ], + edges: [ + { + from_node_instance_id: 'in', + to_node_instance_id: 'out', + description: 'all records', + conditions: { operator: 'always' } + } + ] + }) + })) as { id: string }; + + let status = 'Pending'; + for (let attempt = 0; attempt < POLL_ATTEMPTS; attempt++) { + const s = (await this.req(`/v2/${seg(org)}/pipelines/${seg(pipeline.id)}/status`)) as { + status?: string; + }; + status = s?.status ?? status; + if (status === 'Running' || status === 'Erroring') break; + await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); + } + return { pipelineId: pipeline.id, outputId, status, active: status === 'Running' }; + } + + private async buildDevNull(org: string, inputId: string, name: string): Promise { + const output = (await this.req(`/v2/${seg(org)}/outputs`, { + method: 'POST', + body: JSON.stringify({ + output_type: 'dev-null', + name: `${name} → /dev/null`, + description: 'Auto-created sink for embed pipeline', + promise_id: '', + config: { settings: {}, secrets: {} } + }) + })) as { id: string }; + return this.wire(org, inputId, output.id, name); + } + + /** Ingress: wire a configured input → the store, or a dev/null sink if none. */ + connectSource( + org: string, + opts: { inputId: string; name: string; toOutputId?: string } + ): Promise { + if (opts.toOutputId) return this.wire(org, opts.inputId, opts.toOutputId, opts.name); + return this.buildDevNull(org, opts.inputId, opts.name); + } + + /** Egress: wire the pre-provisioned source → a configured output. */ + connectDestination( + org: string, + opts: { outputId: string; name: string; fromInputId: string } + ): Promise { + return this.wire(org, opts.fromInputId, opts.outputId, opts.name); + } + + private async pipelines(org: string): Promise { + const listed = await this.req(`/v2/${seg(org)}/pipelines/`); + return Array.isArray(listed) ? listed : (listed?.pipelines ?? listed?.data ?? []); + } + + private async detail(org: string, id: string): Promise { + const d = await this.req(`/v2/${seg(org)}/pipelines/${seg(id)}`); + return d?.config ?? d ?? {}; + } + + async pipelineStatus(org: string, inputId: string): Promise { + for (const summary of await this.pipelines(org)) { + if (!summary?.id) continue; + let p: any; + try { + p = await this.detail(org, summary.id); + } catch { + continue; + } + const nodes: any[] = p.nodes ?? []; + const inNode = nodes.find((n) => n.component_type === 'input' && n.component_id === inputId); + if (!inNode) continue; + const outNode = nodes.find((n) => n.component_type === 'output'); + return { + hasPipeline: true, + enabled: Boolean(p.enabled), + pipelineId: summary.id, + outputId: outNode?.component_id + }; + } + return { hasPipeline: false, enabled: false }; + } + + async findPipelineByOutput(org: string, outputId: string): Promise { + for (const summary of await this.pipelines(org)) { + if (!summary?.id) continue; + let p: any; + try { + p = await this.detail(org, summary.id); + } catch { + continue; + } + const nodes: any[] = p.nodes ?? []; + const outNode = nodes.find( + (n) => n.component_type === 'output' && n.component_id === outputId + ); + if (!outNode) continue; + const inNode = nodes.find((n) => n.component_type === 'input'); + return { + hasPipeline: true, + enabled: Boolean(p.enabled), + pipelineId: summary.id, + inputId: inNode?.component_id + }; + } + return null; + } + + async setEnabled(org: string, pipelineId: string, enabled: boolean): Promise { + const p = await this.detail(org, pipelineId); + await this.req(`/v2/${seg(org)}/pipelines/${seg(pipelineId)}`, { + method: 'PATCH', + body: JSON.stringify({ + name: p.name, + description: p.description ?? '', + enabled, + nodes: (p.nodes ?? []).map((n: any) => ({ + id: n.id, + slug: n.slug, + component_id: n.component_id, + component_type: n.component_type, + enabled: n.enabled ?? true + })), + edges: (p.edges ?? []).map((e: any) => ({ + name: e.name, + description: e.description ?? '', + from_node_instance_id: e.from_node_instance_id, + to_node_instance_id: e.to_node_instance_id, + disabled: e.disabled ?? false, + conditions: e.conditions + })) + }) + }); + } + + /** Delete resources per a cleanup policy. Order: pipeline (references the rest) first. */ + async remove( + org: string, + ids: { pipelineId?: string; inputId?: string; outputId?: string }, + cleanup: CleanupPolicy = {} + ): Promise { + const policy = { pipeline: true, input: true, output: true, ...cleanup }; + if (ids.pipelineId && policy.pipeline) { + await this.req(`/v2/${seg(org)}/pipelines/${seg(ids.pipelineId)}`, { method: 'DELETE' }); + } + if (ids.inputId && policy.input) { + await this.req(`/v1/${seg(org)}/inputs/${seg(ids.inputId)}`, { method: 'DELETE' }); + } + if (ids.outputId && policy.output) { + await this.req(`/v1/${seg(org)}/outputs/${seg(ids.outputId)}`, { method: 'DELETE' }); + } + } +} +/* eslint-enable @typescript-eslint/no-explicit-any */ diff --git a/routers/typescript/src/node.ts b/routers/typescript/src/node.ts new file mode 100644 index 0000000..f2a686a --- /dev/null +++ b/routers/typescript/src/node.ts @@ -0,0 +1,96 @@ +/** + * Node adapter — turns the framework-agnostic core into a mountable router. + * + * `import type` from `node:http` is erased at build time, so this keeps the + * package's zero-runtime-dependency guarantee. The returned `(req, res)` + * function works with bare Node `http` and with Express (Express's req/res + * extend Node's): + * + * app.use('/embed', createEmbedRouter(config)); // Express + * http.createServer(createEmbedRouter(config)); // bare Node (routes under /embed) + */ +import type { IncomingMessage, ServerResponse } from 'node:http'; +import { createEmbedHandler, type EmbedRequest, type EmbedServerConfig } from './core'; + +export interface EmbedRouterOptions { + /** + * Path prefix to strip from the incoming URL before matching. Defaults to + * `/embed`. When mounted with `app.use('/embed', …)` Express has already + * stripped it; when used as a raw Node handler the full `/embed/...` path + * arrives and this removes the prefix. + */ + mountPath?: string; +} + +function headerRecord(headers: IncomingMessage['headers']): Record { + const out: Record = {}; + for (const [k, v] of Object.entries(headers)) { + out[k] = Array.isArray(v) ? v[0] : v; + } + return out; +} + +async function readJsonBody(req: IncomingMessage): Promise { + const parsed = (req as { body?: unknown }).body; + if (parsed !== undefined) return parsed; + + const method = (req.method ?? 'GET').toUpperCase(); + if (method === 'GET' || method === 'HEAD') return undefined; + + const chunks: Buffer[] = []; + for await (const chunk of req) { + chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : (chunk as Buffer)); + } + if (chunks.length === 0) return undefined; + const text = Buffer.concat(chunks).toString('utf8').trim(); + if (!text) return undefined; + try { + return JSON.parse(text); + } catch { + return undefined; + } +} + +/** Build a `(req, res)` router that serves the `/embed` routes. */ +export function createEmbedRouter(config: EmbedServerConfig, options: EmbedRouterOptions = {}) { + const handle = createEmbedHandler(config); + const mountPath = options.mountPath ?? '/embed'; + + return function embedRouter(req: IncomingMessage, res: ServerResponse): void { + void (async () => { + const url = new URL(req.url ?? '/', 'http://embed.local'); + let path = url.pathname; + if (path === mountPath) path = '/'; + else if (path.startsWith(mountPath + '/')) path = path.slice(mountPath.length); + + const query: Record = {}; + for (const [k, v] of url.searchParams) query[k] = v; + + const embedReq: EmbedRequest = { + method: req.method ?? 'GET', + path, + query, + headers: headerRecord(req.headers), + body: await readJsonBody(req), + raw: req + }; + + const result = await handle(embedReq); + res.statusCode = result.status; + if (result.body === undefined) { + res.end(); + return; + } + res.setHeader('content-type', 'application/json; charset=utf-8'); + res.end(JSON.stringify(result.body)); + })().catch(() => { + if (!res.headersSent) { + res.statusCode = 500; + res.setHeader('content-type', 'application/json; charset=utf-8'); + res.end(JSON.stringify({ code: 'internal_error', message: 'Request handling failed.' })); + } else { + res.end(); + } + }); + }; +} diff --git a/routers/typescript/test/router.test.ts b/routers/typescript/test/router.test.ts new file mode 100644 index 0000000..a0470b7 --- /dev/null +++ b/routers/typescript/test/router.test.ts @@ -0,0 +1,204 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createEmbedHandler, type EmbedRequest, type EmbedServerConfig } from '../src'; + +const API_BASE = 'https://api.test/api'; + +/** Route mocked fetch by "METHOD /path" → a JSON body (function receives the parsed request body). */ +function mockApi(routes: Record unknown)>) { + const calls: { method: string; path: string; body?: unknown }[] = []; + const fetchMock = vi.fn(async (url: string, init?: RequestInit) => { + const method = (init?.method ?? 'GET').toUpperCase(); + const path = url.replace(API_BASE, ''); + const body = init?.body ? JSON.parse(init.body as string) : undefined; + calls.push({ method, path, body }); + const key = + Object.keys(routes).find((k) => k === `${method} ${path}`) ?? + Object.keys(routes).find((k) => { + const [m, p] = k.split(' '); + return m === method && p !== undefined && path.startsWith(p); + }); + if (!key) throw new Error(`unmocked ${method} ${path}`); + const val = routes[key]; + const resolved = typeof val === 'function' ? (val as (b: unknown) => unknown)(body) : val; + return { + ok: true, + status: 200, + text: async () => (resolved === undefined ? '' : JSON.stringify(resolved)) + } as Response; + }); + vi.stubGlobal('fetch', fetchMock); + return calls; +} + +const baseConfig = (over: Partial = {}): EmbedServerConfig => ({ + apiKey: 'k', + apiBase: API_BASE, + frameOrigin: 'https://app.monad.com/embed', + getCustomerOrgID: () => 'org_1', + ...over +}); + +const req = (method: string, path: string, extra: Partial = {}): EmbedRequest => ({ + method, + path, + query: {}, + headers: {}, + ...extra +}); + +afterEach(() => vi.unstubAllGlobals()); + +describe('createEmbedHandler (standalone)', () => { + it('GET /config returns frame + api config without auth', async () => { + const handle = createEmbedHandler( + baseConfig({ + getCustomerOrgID: () => { + throw new Error('should not be called'); + } + }) + ); + const res = await handle(req('GET', '/config')); + expect(res).toEqual({ + status: 200, + body: { frameOrigin: 'https://app.monad.com/embed', apiBase: API_BASE } + }); + }); + + it('defaults apiBase + frameOrigin to production when omitted', async () => { + const res = await createEmbedHandler({ apiKey: 'k', getCustomerOrgID: () => 'org_1' })( + req('GET', '/config') + ); + expect(res.body).toEqual({ + frameOrigin: 'https://app.monad.com/embed', + apiBase: 'https://app.monad.com/api' + }); + }); + + it('POST /session mints a token for the resolved tenant', async () => { + mockApi({ 'POST /v3/sessions': { session_token: 'tok', expires_at: '2026-01-01T00:00:00Z' } }); + const res = await createEmbedHandler(baseConfig())(req('POST', '/session')); + expect(res.status).toBe(200); + expect(res.body).toEqual({ + sessionToken: 'tok', + organizationId: 'org_1', + expiresAt: '2026-01-01T00:00:00Z' + }); + }); + + it('GET /catalog returns the contract camelCase (typeId), filtered to the allow-list', async () => { + mockApi({ + 'GET /v1/inputs': [ + { type_id: 'aws-cloudtrail', name: 'AWS' }, + { type_id: 'secret', name: 'Hidden' } + ] + }); + const handle = createEmbedHandler(baseConfig({ catalogAllow: ['aws-cloudtrail'] })); + const res = await handle(req('GET', '/catalog', { query: { kind: 'input' } })); + expect(res.status).toBe(200); + expect(res.body).toEqual([{ typeId: 'aws-cloudtrail', name: 'AWS' }]); + }); + + it('GET /connectors normalizes the API `type` field to camelCase `typeId`', async () => { + mockApi({ + 'GET /v1/org_1/inputs': { + inputs: [{ id: 'in_1', type: 'aws-cloudtrail', name: 'Audit Logs' }] + } + }); + const res = await createEmbedHandler(baseConfig())( + req('GET', '/connectors', { query: { kind: 'input' } }) + ); + expect(res.body).toEqual([{ id: 'in_1', typeId: 'aws-cloudtrail', name: 'Audit Logs' }]); + }); + + it('POST /pipelines/ingress wires to the provisioned store', async () => { + const calls = mockApi({ + 'POST /v2/org_1/pipelines/': { id: 'pipe_1' }, + 'GET /v2/org_1/pipelines/pipe_1/status': { status: 'Running' } + }); + const handle = createEmbedHandler( + baseConfig({ getProvisionedComponents: () => ({ destinationOutputId: 'out_store' }) }) + ); + const res = await handle( + req('POST', '/pipelines/ingress', { body: { inputId: 'in_1', name: 'CT' } }) + ); + expect(res.status).toBe(201); + expect((res.body as { outputId: string }).outputId).toBe('out_store'); + const post = calls.find((c) => c.method === 'POST' && c.path === '/v2/org_1/pipelines/'); + expect((post!.body as { nodes: unknown[] }).nodes).toContainEqual( + expect.objectContaining({ component_id: 'out_store', component_type: 'output' }) + ); + }); + + it('POST /pipelines/ingress falls back to a dev/null sink without a store', async () => { + mockApi({ + 'POST /v2/org_1/outputs': { id: 'out_devnull' }, + 'POST /v2/org_1/pipelines/': { id: 'pipe_2' }, + 'GET /v2/org_1/pipelines/pipe_2/status': { status: 'Running' } + }); + const res = await createEmbedHandler(baseConfig())( + req('POST', '/pipelines/ingress', { body: { inputId: 'in_1', name: 'CT' } }) + ); + expect(res.status).toBe(201); + expect((res.body as { outputId: string }).outputId).toBe('out_devnull'); + }); + + it('POST /pipelines/egress 500s when no source is provisioned', async () => { + const res = await createEmbedHandler(baseConfig())( + req('POST', '/pipelines/egress', { body: { outputId: 'out_1', name: 'Splunk' } }) + ); + expect(res.status).toBe(500); + expect((res.body as { code: string }).code).toBe('internal_error'); + }); + + it('POST /pipelines/state disables and returns 204', async () => { + mockApi({ + 'GET /v2/org_1/pipelines/pipe_1': { + config: { name: 'p', nodes: [], edges: [], enabled: true } + }, + 'PATCH /v2/org_1/pipelines/pipe_1': undefined + }); + const res = await createEmbedHandler(baseConfig())( + req('POST', '/pipelines/state', { body: { pipelineId: 'pipe_1', enabled: false } }) + ); + expect(res).toEqual({ status: 204 }); + }); + + it('rejects an unauthenticated caller with 401', async () => { + const handle = createEmbedHandler( + baseConfig({ + getCustomerOrgID: () => { + throw new Error('no session'); + } + }) + ); + const res = await handle(req('POST', '/session')); + expect(res.status).toBe(401); + expect((res.body as { code: string }).code).toBe('unauthenticated'); + }); + + it('400s on an invalid kind and on a missing body field', async () => { + const handle = createEmbedHandler(baseConfig()); + const bad = await handle(req('GET', '/catalog', { query: { kind: 'nope' } })); + expect(bad.status).toBe(400); + const missing = await handle(req('POST', '/pipelines/ingress', { body: { name: 'x' } })); + expect(missing.status).toBe(400); + }); + + it('maps a Monad API failure to 502 upstream_error', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => { + throw new Error('connection refused'); + }) + ); + const res = await createEmbedHandler(baseConfig())(req('POST', '/session')); + expect(res.status).toBe(502); + expect((res.body as { code: string }).code).toBe('upstream_error'); + }); + + it('404s an unknown route', async () => { + const res = await createEmbedHandler(baseConfig())(req('GET', '/nope')); + expect(res.status).toBe(404); + expect((res.body as { code: string }).code).toBe('not_found'); + }); +}); diff --git a/routers/typescript/tsconfig.json b/routers/typescript/tsconfig.json new file mode 100644 index 0000000..86a8c7a --- /dev/null +++ b/routers/typescript/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022", "DOM"], + "strict": true, + "noUncheckedIndexedAccess": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": true, + "outDir": "dist", + "types": ["node"] + }, + "include": ["src/**/*", "test/**/*"], + "exclude": ["dist", "node_modules"] +} diff --git a/routers/typescript/tsup.config.ts b/routers/typescript/tsup.config.ts new file mode 100644 index 0000000..d2fc6b4 --- /dev/null +++ b/routers/typescript/tsup.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: { index: 'src/index.ts' }, + format: ['esm', 'cjs'], + dts: true, + sourcemap: true, + clean: true +}); diff --git a/routers/typescript/vitest.config.ts b/routers/typescript/vitest.config.ts new file mode 100644 index 0000000..08d237f --- /dev/null +++ b/routers/typescript/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['test/**/*.test.ts'], + environment: 'node' + } +}); From 03b78c9fee2ddd0b6d3eba69eac33bf3bfd91cac Mon Sep 17 00:00:00 2001 From: clippy Date: Fri, 31 Jul 2026 15:42:33 -0700 Subject: [PATCH 2/2] test(conformance): live mode against real Monad + Monad-schema mock fidelity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a live conformance mode (MONAD_LIVE=1) that boots the router against the real Monad API instead of the mock, driven by env-configurable credentials/org/ provisioned ids (all defaulting to the mock fixtures, so the hermetic run is unchanged). Schemathesis is restricted to read-only GET operations in live mode. Adds test_mock_fidelity.py + monad_schemas.py, which validate every mock response against the Monad response shapes the routers consume — pinning the mock to the documented upstream contract (needs jsonschema). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011ASKA2VrufrNLGGrcFjY7n --- .github/workflows/ci.yml | 2 +- conformance/README.md | 47 ++++++++++- conformance/conftest.py | 15 +++- conformance/monad_schemas.py | 128 ++++++++++++++++++++++++++++++ conformance/servers/ts_server.mjs | 34 +++++--- conformance/test_conformance.py | 8 ++ conformance/test_mock_fidelity.py | 102 ++++++++++++++++++++++++ conformance/test_scenarios.py | 118 ++++++++++++++++++++++----- 8 files changed, 414 insertions(+), 40 deletions(-) create mode 100644 conformance/monad_schemas.py create mode 100644 conformance/test_mock_fidelity.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d2190c6..360c92d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -69,7 +69,7 @@ jobs: working-directory: conformance run: | uv venv --python 3.10 .venv - uv pip install --python .venv/bin/python schemathesis pytest httpx + uv pip install --python .venv/bin/python schemathesis pytest httpx jsonschema - name: Run conformance against the TS router working-directory: conformance diff --git a/conformance/README.md b/conformance/README.md index 4e2a19f..00fd5d2 100644 --- a/conformance/README.md +++ b/conformance/README.md @@ -16,19 +16,60 @@ router under test ── booted by conftest.py (ROUTER=python|go|ts), mounted a ▲ │ HTTP tests ── test_conformance.py (Schemathesis) + test_scenarios.py (lifecycle) + + test_mock_fidelity.py (mock ⟷ Monad shapes) ``` -- **`test_conformance.py`** — Schemathesis reads the OpenAPI spec, generates - requests for every operation, and asserts each response matches the documented - **status code, response schema, and content type**. +- **`test_conformance.py`** — Schemathesis reads the `/embed` OpenAPI spec, + generates requests for every operation, and asserts each response matches the + documented **status code, response schema, and content type**. - **`test_scenarios.py`** — the stateful lifecycle Schemathesis's stateless fuzzing can't cover: mint → build ingress → status → disable → status → remove → status, plus egress. +- **`test_mock_fidelity.py`** — pins the mock to the **Monad** response shapes + the routers consume (`monad_schemas.py`, vendored from Monad's OpenAPI), so the + mock can't drift from the documented upstream contract on a field a router + parses. This is the check that would have caught a missing `pagination` + sibling for free. (Needs `jsonschema`.) The router is booted with a stubbed `getCustomerOrgID` (always the tenant `org_conf`) and provisioning (`destinationOutputId=out_store`, `sourceInputId=in_source`), pointed at the mock. Nothing touches real Monad. +## Two contracts, two directions + +The suite guards both contracts the router sits between: + +- **downstream** — `test_conformance.py` proves the router's `/embed` responses + match _our_ published contract (`packages/embed/openapi/embed.openapi.yaml`). +- **upstream** — `test_mock_fidelity.py` proves the _mock_ matches _Monad's_ + documented shapes. But a spec is only as good as its accuracy: Monad's spec + types the connectors list as `array` yet real Monad returns `null` when empty + (the bug that hit the Python router). So spec-fidelity alone is not enough — + **live mode** (below) closes the gap by running the routers against real Monad. + +## Live mode — run the routers against real Monad + +The default run is hermetic (mock). Set `MONAD_LIVE=1` and point the same env at +real Monad staging to run the **identical** read-path scenarios against the real +API — the only thing that catches where reality diverges from the spec: + +```sh +MONAD_LIVE=1 \ +MONAD_API_BASE=https://app.monad.security/api \ +MONAD_API_KEY= \ +MONAD_ORG_ID= \ +MONAD_SOURCE_ID= \ +MONAD_FRAME_ORIGIN=https://app.monad.security/embed \ +ROUTER=python .venv/bin/python -m pytest -q +``` + +In live mode the mock is not booted; Schemathesis is restricted to **read-only +GET** operations (no fuzzed mutations against real Monad); and the mutating +lifecycle scenarios are skipped unless you opt in with `MONAD_LIVE_MUTATE=1` +plus throwaway `CONF_INPUT_ID` / `CONF_OUTPUT_ID` components (they create and +then delete real pipelines, keeping the shared source). Every knob defaults to +the mock fixture value, so an unset env is exactly the hermetic run. + ## Run ```sh diff --git a/conformance/conftest.py b/conformance/conftest.py index 4265314..08b8900 100644 --- a/conformance/conftest.py +++ b/conformance/conftest.py @@ -21,13 +21,20 @@ MOCK_PORT = int(os.environ.get("MOCK_PORT", "8790")) ROUTER_PORT = int(os.environ.get("ROUTER_PORT", "8791")) +# Live mode boots the router against the real Monad API (MONAD_API_BASE + +# MONAD_API_KEY + MONAD_ORG_ID … supplied by the caller) instead of the mock. +LIVE = os.environ.get("MONAD_LIVE") == "1" + _HERE = os.path.dirname(os.path.abspath(__file__)) _REPO = os.path.abspath(os.path.join(_HERE, "..")) def _command() -> tuple[list[str], str, dict[str, str]]: env = dict(os.environ) - env["MONAD_API_BASE"] = f"http://127.0.0.1:{MOCK_PORT}" + # In mock mode we own MONAD_API_BASE (point the router at the local mock); + # in live mode the caller's real MONAD_API_BASE is passed through untouched. + if not LIVE: + env["MONAD_API_BASE"] = f"http://127.0.0.1:{MOCK_PORT}" env["PORT"] = str(ROUTER_PORT) if ROUTER == "python": return [os.path.join(_HERE, ".venv", "bin", "python"), os.path.join(_HERE, "servers", "py_server.py")], _HERE, env @@ -52,7 +59,8 @@ def _wait_healthy(url: str, timeout: float = 45.0) -> bool: @pytest.fixture(scope="session", autouse=True) def servers(): - server, _state = mock_monad.start(MOCK_PORT) + # Live mode talks to real Monad — no mock to boot. + server = None if LIVE else mock_monad.start(MOCK_PORT)[0] cmd, cwd, env = _command() # New session so we can kill the whole group (e.g. `go run` + its child binary). proc = subprocess.Popen(cmd, cwd=cwd, env=env, start_new_session=True) @@ -72,4 +80,5 @@ def servers(): os.killpg(os.getpgid(proc.pid), signal.SIGKILL) except ProcessLookupError: pass - server.shutdown() + if server is not None: + server.shutdown() diff --git a/conformance/monad_schemas.py b/conformance/monad_schemas.py new file mode 100644 index 0000000..5494bf1 --- /dev/null +++ b/conformance/monad_schemas.py @@ -0,0 +1,128 @@ +"""JSON Schemas for the **Monad API** responses the routers consume. + +These are the *upstream* contract the routers depend on — distinct from our own +``/embed`` contract (``packages/embed/openapi/embed.openapi.yaml``, which +Schemathesis checks). They are hand-authored from Monad's OpenAPI +(``docs/swagger.json``) + ``pkg/routes/embed`` — deliberately vendored rather +than importing Monad's internal spec into this public repo, and pared to the +fields the routers actually read. + +``test_mock_fidelity.py`` validates every response the stateful mock emits +against these, so the mock can never drift from the documented Monad shapes on a +field a router parses. This is the check that would have caught the missing +``pagination`` sibling for free. Each schema allows extra properties — real +Monad returns many more fields than the routers touch. + +Notes where the mock encodes reality *beyond* the spec: +- the connectors list field is ``["array", "null"]``: real Monad returns + ``null`` (not ``[]``) for a tenant with none, though its spec types it + ``array``. Live conformance is what proves this, but pinning it here stops the + mock from regressing to a non-null-emitting shape. +""" + +# POST /v3/sessions — the embed session mint (swagger leaves the body untyped; +# shape is from pkg/routes/embed/sessions.go). +SESSION = { + "type": "object", + "required": ["session_token", "expires_at"], + "properties": { + "session_token": {"type": "string"}, + "expires_at": {"type": "string"}, + }, +} + +# GET /v1/{kind}s — the connector-type catalog: a bare array. +CATALOG = { + "type": "array", + "items": { + "type": "object", + "required": ["type_id", "name"], + "properties": { + "type_id": {"type": "string"}, + "name": {"type": "string"}, + }, + }, +} + + +def connectors_list(kind: str) -> dict: + """GET /v1/{org}/{kind}s — the tenant's configured connectors, wrapped + alongside ``pagination``. The list is nullable (see module note).""" + return { + "type": "object", + "required": [f"{kind}s", "pagination"], + "properties": { + f"{kind}s": { + "type": ["array", "null"], + "items": { + "type": "object", + "required": ["id", "type", "name"], + "properties": { + "id": {"type": "string"}, + "type": {"type": "string"}, + "name": {"type": "string"}, + }, + }, + }, + "pagination": {"type": "object"}, + }, + } + + +# A pipeline node as the routers read it (match on component_type/component_id). +_NODE = { + "type": "object", + "required": ["component_type", "component_id"], + "properties": { + "component_type": {"type": "string"}, + "component_id": {"type": "string"}, + "slug": {"type": "string"}, + "enabled": {"type": "boolean"}, + }, +} + +# GET/POST/PATCH /v2/{org}/pipelines/{id} — the full pipeline record. The routers +# read id/name/enabled + the nodes to resolve input/output wiring. +PIPELINE = { + "type": "object", + "required": ["id", "enabled", "nodes"], + "properties": { + "id": {"type": "string"}, + "name": {"type": "string"}, + "description": {"type": "string"}, + "enabled": {"type": "boolean"}, + "nodes": {"type": "array", "items": _NODE}, + "edges": {"type": "array"}, + }, +} + +# GET /v2/{org}/pipelines/ — the list. Router accepts a bare array or a wrapper. +PIPELINE_LIST = { + "type": "object", + "required": ["pipelines", "pagination"], + "properties": { + "pipelines": { + "type": "array", + "items": { + "type": "object", + "required": ["id"], + "properties": {"id": {"type": "string"}}, + }, + }, + "pagination": {"type": "object"}, + }, +} + +# GET /v2/{org}/pipelines/{id}/status — the router reads `status`. +PIPELINE_STATUS = { + "type": "object", + "required": ["status"], + "properties": {"status": {"type": "string"}}, +} + +# POST /v2/{org}/outputs — the created output; the router reads `id`. +OUTPUT = { + "type": "object", + "required": ["id"], + "properties": {"id": {"type": "string"}}, +} diff --git a/conformance/servers/ts_server.mjs b/conformance/servers/ts_server.mjs index 2a25635..1ff2ae0 100644 --- a/conformance/servers/ts_server.mjs +++ b/conformance/servers/ts_server.mjs @@ -1,19 +1,31 @@ -// Boot the TypeScript router for conformance testing, pointed at the mock -// Monad. Imports the built standalone package (run `pnpm -C routers/typescript -// build` first). +// Boot the TypeScript router for conformance testing. In the default (mock) +// mode the harness points it at the in-memory mock via MONAD_API_BASE; in live +// mode the same knobs are set to real Monad staging credentials. Every value +// falls back to the mock fixture default, so `node ts_server.mjs` with only +// MONAD_API_BASE set behaves exactly as before. +// +// Build the standalone package first: `pnpm -C routers/typescript build`. import http from 'node:http'; import { createEmbedRouter } from '../../routers/typescript/dist/index.js'; +const env = (name, fallback) => process.env[name] ?? fallback; +// A provisioned id may be intentionally empty (live tenant with no store) → treat +// "" as "not provisioned" so the router falls back to a dev/null sink. +const store = env('MONAD_STORE_ID', 'out_store') || undefined; +const source = env('MONAD_SOURCE_ID', 'in_source') || undefined; +// Empty allow-list → expose the whole catalog (undefined), never the empty set. +const allow = env('MONAD_CATALOG_ALLOW', 'aws-cloudtrail,okta-systemlog') + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + const router = createEmbedRouter({ - apiKey: 'conf-key', + apiKey: env('MONAD_API_KEY', 'conf-key'), apiBase: process.env.MONAD_API_BASE, - frameOrigin: 'https://app.monad.com/embed', - getCustomerOrgID: () => 'org_conf', - getProvisionedComponents: () => ({ - destinationOutputId: 'out_store', - sourceInputId: 'in_source' - }), - catalogAllow: ['aws-cloudtrail', 'okta-systemlog'] + frameOrigin: env('MONAD_FRAME_ORIGIN', 'https://app.monad.com/embed'), + getCustomerOrgID: () => env('MONAD_ORG_ID', 'org_conf'), + getProvisionedComponents: () => ({ destinationOutputId: store, sourceInputId: source }), + catalogAllow: allow.length ? allow : undefined }); const port = Number(process.env.PORT || 8791); diff --git a/conformance/test_conformance.py b/conformance/test_conformance.py index 67712e2..7eda466 100644 --- a/conformance/test_conformance.py +++ b/conformance/test_conformance.py @@ -8,6 +8,7 @@ import os +import pytest import schemathesis from schemathesis.specs.openapi.checks import ( content_type_conformance, @@ -20,6 +21,11 @@ ) _BASE_URL = os.environ.get("ROUTER_BASE_URL", "http://127.0.0.1:8791") +# Against real Monad (live mode) the fuzzer must not create resources with +# generated payloads — restrict it to read-only GET operations, which are also +# exactly the response-shape surface that has bitten us (catalog/connectors). +LIVE = os.environ.get("MONAD_LIVE") == "1" + schema = schemathesis.openapi.from_path(_SPEC) # We validate RESPONSE conformance: every response the router returns for every @@ -39,4 +45,6 @@ @schema.parametrize() def test_response_conforms_to_spec(case): + if LIVE and case.method.upper() != "GET": + pytest.skip("live mode fuzzes read-only GET operations only (no real mutations)") case.call_and_validate(base_url=_BASE_URL, checks=_CONFORMANCE_CHECKS) diff --git a/conformance/test_mock_fidelity.py b/conformance/test_mock_fidelity.py new file mode 100644 index 0000000..7fd4aeb --- /dev/null +++ b/conformance/test_mock_fidelity.py @@ -0,0 +1,102 @@ +"""Pin the stateful mock to the documented Monad response shapes. + +Boots its own mock instance and drives it exactly as the routers do, asserting +every response validates against the vendored Monad schemas (``monad_schemas``). +This is the hermetic complement to live conformance: live testing catches where +the *docs* are wrong (e.g. null-when-empty), while this catches the *mock* +drifting from the docs on a field a router parses — the class of gap that let +the missing ``pagination`` sibling ship. Fast, offline, no credentials. +""" + +import httpx +import pytest +from jsonschema import Draft202012Validator + +import mock_monad +import monad_schemas as S + +_ORG = "org_conf" + + +@pytest.fixture(scope="module") +def base_url(): + # A dedicated mock instance on its own port — independent of the conftest + # `servers` fixture (which also boots a router), so this stays hermetic in + # both mock and live router modes. + port = 8795 + server, _state = mock_monad.start(port) + try: + yield f"http://127.0.0.1:{port}" + finally: + server.shutdown() + + +@pytest.fixture() +def http(base_url): + with httpx.Client(base_url=base_url, timeout=10) as c: + yield c + + +def _valid(schema: dict, instance) -> None: + errors = sorted(Draft202012Validator(schema).iter_errors(instance), key=lambda e: e.path) + assert not errors, "; ".join(f"{list(e.path)}: {e.message}" for e in errors) + + +def test_session_shape(http): + r = http.post("/v3/sessions", json={"organization_id": _ORG, "ttl_seconds": 1800}) + assert r.status_code == 200 + _valid(S.SESSION, r.json()) + + +@pytest.mark.parametrize("kind", ["input", "output"]) +def test_catalog_shape(http, kind): + r = http.get(f"/v1/{kind}s") + assert r.status_code == 200 + _valid(S.CATALOG, r.json()) + + +@pytest.mark.parametrize("kind", ["input", "output"]) +def test_connectors_list_shape(http, kind): + # Covers both the populated (inputs) and the null-when-empty (outputs) case, + # each of which must still carry the `pagination` sibling. + r = http.get(f"/v1/{_ORG}/{kind}s?limit=1000&offset=0") + assert r.status_code == 200 + _valid(S.connectors_list(kind), r.json()) + + +def test_output_create_shape(http): + r = http.post(f"/v2/{_ORG}/outputs", json={"output_type": "dev-null", "name": "sink"}) + assert r.status_code == 200 + _valid(S.OUTPUT, r.json()) + + +def test_pipeline_lifecycle_shapes(http): + body = { + "name": "fidelity", + "enabled": True, + "nodes": [ + {"slug": "in", "component_id": "in_fidelity", "component_type": "input", "enabled": True}, + {"slug": "out", "component_id": "out_fidelity", "component_type": "output", "enabled": True}, + ], + "edges": [], + } + created = http.post(f"/v2/{_ORG}/pipelines/", json=body) + assert created.status_code == 201, created.text + _valid(S.PIPELINE, created.json()) + pid = created.json()["id"] + + listed = http.get(f"/v2/{_ORG}/pipelines/") + assert listed.status_code == 200 + _valid(S.PIPELINE_LIST, listed.json()) + + detail = http.get(f"/v2/{_ORG}/pipelines/{pid}") + assert detail.status_code == 200 + _valid(S.PIPELINE, detail.json()) + + status = http.get(f"/v2/{_ORG}/pipelines/{pid}/status") + assert status.status_code == 200 + _valid(S.PIPELINE_STATUS, status.json()) + + patched = http.patch(f"/v2/{_ORG}/pipelines/{pid}", json={**body, "enabled": False}) + assert patched.status_code == 200 + _valid(S.PIPELINE, patched.json()) diff --git a/conformance/test_scenarios.py b/conformance/test_scenarios.py index 6a8fef0..6e135b1 100644 --- a/conformance/test_scenarios.py +++ b/conformance/test_scenarios.py @@ -3,6 +3,18 @@ Runs against whichever router the ``servers`` fixture booted, over HTTP, so it is identical for every language. + +The fixture values are read from the **same env vars the conformance servers +read**, so the identical scenarios run two ways: + +- **mock mode** (default) — the env vars are unset, so everything falls back to + the in-memory mock's fixtures (``org_conf`` / ``out_store`` / ``in_conf`` …). +- **live mode** (``MONAD_LIVE=1``) — the env points the router at real Monad + staging, and the expected values come from the real tenant. The read-path + scenarios (config/session/catalog/connectors) run as-is — that is exactly the + response-shape surface that has bitten us. The mutating lifecycle scenarios + additionally require ``MONAD_LIVE_MUTATE=1`` + real ``CONF_INPUT_ID`` / + ``CONF_OUTPUT_ID`` throwaway components, and clean up after themselves. """ import os @@ -12,10 +24,32 @@ _BASE_URL = os.environ.get("ROUTER_BASE_URL", "http://127.0.0.1:8791") +LIVE = os.environ.get("MONAD_LIVE") == "1" +# Mutating scenarios against real Monad are opt-in (they create + delete real +# pipelines) and need throwaway components to wire. +MUTATE = os.environ.get("MONAD_LIVE_MUTATE") == "1" + +# Expected values — mirror the conformance servers' env defaults. +ORG = os.environ.get("MONAD_ORG_ID", "org_conf") +STORE = os.environ.get("MONAD_STORE_ID", "out_store") # ingress target; "" → dev/null sink +FRAME = os.environ.get("MONAD_FRAME_ORIGIN", "https://app.monad.com/embed") +# A real configured input/output to wire in the mutating lifecycle scenarios. +INPUT_ID = os.environ.get("CONF_INPUT_ID", "in_conf") +OUTPUT_ID = os.environ.get("CONF_OUTPUT_ID", "out_conf") +# The allow-list the catalog should be constrained to (empty → whole catalog). +_ALLOW = os.environ.get("MONAD_CATALOG_ALLOW", "aws-cloudtrail,okta-systemlog") +EXPECT_CATALOG = {s.strip() for s in _ALLOW.split(",") if s.strip()} + +# Skip a mutating scenario in live mode unless it was explicitly opted into. +skip_mutation = pytest.mark.skipif( + LIVE and not MUTATE, + reason="mutating live scenario — set MONAD_LIVE_MUTATE=1 with throwaway CONF_INPUT_ID/CONF_OUTPUT_ID", +) + @pytest.fixture() def client(): - with httpx.Client(base_url=_BASE_URL, timeout=15) as c: + with httpx.Client(base_url=_BASE_URL, timeout=30) as c: yield c @@ -23,65 +57,99 @@ def test_config_is_public(client): r = client.get("/embed/config") assert r.status_code == 200 body = r.json() - assert body["frameOrigin"] == "https://app.monad.com/embed" + assert body["frameOrigin"] == FRAME assert "apiBase" in body +def test_session_mints_for_tenant(client): + r = client.post("/embed/session") + assert r.status_code == 200, r.text + body = r.json() + assert body["organizationId"] == ORG + assert body.get("sessionToken") # a real (or mock) token was minted + + +def test_connectors_list_parses(client): + # The response-shape surface that has bitten us live (null-when-empty + + # a `pagination` sibling). It must parse into the contract array for both + # kinds regardless of how many the tenant has. + for kind in ("input", "output"): + r = client.get("/embed/connectors", params={"kind": kind}) + assert r.status_code == 200, r.text + rows = r.json() + assert isinstance(rows, list) + for row in rows: + assert {"id", "typeId", "name"} <= row.keys() + + +def test_catalog_is_allow_listed(client): + r = client.get("/embed/catalog", params={"kind": "input"}) + assert r.status_code == 200 + type_ids = {t["typeId"] for t in r.json()} + assert type_ids, "catalog must not be empty" + if EXPECT_CATALOG: + # The allow-list must constrain the catalog to (a subset of) itself. + assert type_ids <= EXPECT_CATALOG + + +@skip_mutation def test_ingress_lifecycle(client): # 1) mint a session for the resolved tenant r = client.post("/embed/session") assert r.status_code == 200, r.text - assert r.json()["organizationId"] == "org_conf" + assert r.json()["organizationId"] == ORG # 2) the iframe returned an input id → build the ingress pipeline - r = client.post("/embed/pipelines/ingress", json={"inputId": "in_conf", "name": "CloudTrail"}) + r = client.post("/embed/pipelines/ingress", json={"inputId": INPUT_ID, "name": "CloudTrail"}) assert r.status_code == 201, r.text built = r.json() pipeline_id = built["pipelineId"] - assert built["outputId"] == "out_store" # wired to the provisioned store + if STORE: + assert built["outputId"] == STORE # wired to the provisioned store + else: + assert built["outputId"] # a dev/null sink was created assert built["active"] is True # 3) status resolves the pipeline from the input id - r = client.get("/embed/pipelines", params={"connectorId": "in_conf", "kind": "input"}) + r = client.get("/embed/pipelines", params={"connectorId": INPUT_ID, "kind": "input"}) assert r.status_code == 200, r.text status = r.json() assert status["hasPipeline"] is True assert status["pipelineId"] == pipeline_id - assert status["outputId"] == "out_store" + if STORE: + assert status["outputId"] == STORE assert status["enabled"] is True # 4) disable — stops flow without deleting config r = client.post("/embed/pipelines/state", json={"pipelineId": pipeline_id, "enabled": False}) assert r.status_code == 204, r.text - r = client.get("/embed/pipelines", params={"connectorId": "in_conf", "kind": "input"}) + r = client.get("/embed/pipelines", params={"connectorId": INPUT_ID, "kind": "input"}) assert r.json()["enabled"] is False # 5) remove — the provisioned store is kept, the pipeline + input are gone - r = client.post("/embed/pipelines/remove", json={"connectorId": "in_conf", "kind": "input"}) + r = client.post("/embed/pipelines/remove", json={"connectorId": INPUT_ID, "kind": "input"}) assert r.status_code == 204, r.text - r = client.get("/embed/pipelines", params={"connectorId": "in_conf", "kind": "input"}) + r = client.get("/embed/pipelines", params={"connectorId": INPUT_ID, "kind": "input"}) assert r.json()["hasPipeline"] is False +@skip_mutation def test_egress_builds_from_provisioned_source(client): # the iframe returned an output id → wire the tenant's source → it - r = client.post("/embed/pipelines/egress", json={"outputId": "out_conf", "name": "Splunk"}) + r = client.post("/embed/pipelines/egress", json={"outputId": OUTPUT_ID, "name": "Splunk"}) assert r.status_code == 201, r.text - assert r.json()["outputId"] == "out_conf" - - -def test_catalog_is_allow_listed(client): - r = client.get("/embed/catalog", params={"kind": "input"}) - assert r.status_code == 200 - type_ids = {t["typeId"] for t in r.json()} - assert type_ids == {"aws-cloudtrail", "okta-systemlog"} + assert r.json()["outputId"] == OUTPUT_ID + if LIVE: + # clean up the pipeline (+ output) we just created; keep the shared source. + client.post("/embed/pipelines/remove", json={"connectorId": OUTPUT_ID, "kind": "output"}) # Every route that takes `kind` must reject an invalid value with the shared # error model — not just /catalog. (Schemathesis only ever sends enum-valid -# `kind`s, so this negative case has to be asserted explicitly.) +# `kind`s, so this negative case has to be asserted explicitly.) Router-local +# validation, so it runs identically against mock and live. @pytest.mark.parametrize( "call", [ @@ -100,15 +168,21 @@ def test_invalid_kind_is_rejected(client, call): def test_state_on_unknown_pipeline_is_404(client): # Toggling a pipeline that doesn't exist for this tenant is a 404 not_found — - # the router must translate Monad's 404, not fold it into a generic 502. + # the router must translate Monad's 404, not fold it into a generic 502. The + # router reads the pipeline before writing, so this creates nothing → safe live. r = client.post( "/embed/pipelines/state", - json={"pipelineId": "pipe_does_not_exist", "enabled": False}, + json={"pipelineId": "00000000-0000-4000-8000-000000000000", "enabled": False}, ) assert r.status_code == 404, r.text assert r.json()["code"] == "not_found" +@pytest.mark.skipif( + LIVE, + reason="Monad does not document a 409 on duplicate pipeline creation (201/400/500 only); " + "the mock emulates the constraint, real behavior is asserted only hermetically", +) def test_duplicate_ingress_conflicts(client): # Connecting the same source twice collides with existing state → 409 conflict # (a known Monad constraint the router must surface as the contract's `conflict`).