diff --git a/.github/workflows/workflow-canvas.yml b/.github/workflows/workflow-canvas.yml index 9c018403..d2b482b0 100644 --- a/.github/workflows/workflow-canvas.yml +++ b/.github/workflows/workflow-canvas.yml @@ -12,7 +12,8 @@ # until 26 becomes LTS on 2026-10-28. # # The e2e job runs the Playwright tests in Chromium on Node 24 only (the -# browser, not Node, is what they exercise) and also regenerates the +# browser, not Node, is what they exercise), starts the stub API beside the +# app (its config is in playwright.shared.ts) and also regenerates the # screenshots, which must match the committed files. Both the HTML report and # the screenshots are uploaded whether the tests pass or fail. # @@ -75,6 +76,9 @@ jobs: - name: API contract (also part of Test, run alone for a clear label) run: npm run api:lint + - name: Stub API (also part of Test, run alone for a clear label) + run: npm run test:stub + - name: Build run: npm run build diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ab3d8d8..3213b8db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ not per individual commit. - `workflow-canvas/`, a browser UI for designing and running workflows (Vite, React, Cloudscape), with CI. - Playwright end-to-end tests and screenshots for `workflow-canvas/`, run in CI. - HTTP API contract for the workflow canvas (`workflow-canvas/api/openapi.yaml`), checked by `npm run api:lint`. +- Stub API for the workflow canvas (`npm run stub`, `npm run dev:stub`); `WC_API_TARGET` points the page at a real server. ### Changed diff --git a/workflow-canvas/README.md b/workflow-canvas/README.md index 7e11ee02..84181fec 100644 --- a/workflow-canvas/README.md +++ b/workflow-canvas/README.md @@ -4,22 +4,20 @@ A browser UI for designing and running Konductor workflows. This folder is an independent npm project (Vite, React, TypeScript, Cloudscape) and is not part of the agent package or the CLI. -Currently an empty page with Designer and Execution tabs. Features land in -follow-up changes. +The page is a shell with Designer and Execution tabs. It does not call the +API; the contract, the stub that serves it and the tests around both are in +this folder. ## Layout - `src/`: the UI. - `api/`: the HTTP API contract (`openapi.yaml`) and its test. +- `stub-api/`: a TypeScript stub server for the contract, for local + development and tests. - `e2e/`: Playwright tests (`tests/`), the screenshot capture (`capture/`), shared page objects and helpers (`support/`) and the committed PNGs (`screenshots/`). -Directories added in later PRs: - -- `stub-api/`: a TypeScript stub server for local development and end-to-end - tests, started by the same Playwright `webServer` config. - A single `package.json` serves all of the above. ## Commands @@ -28,14 +26,17 @@ Run from this folder with Node 24.15 or later (`.nvmrc` selects 24): ```bash npm ci -npm run dev # Vite dev server on http://127.0.0.1:5173 -npm run lint # ESLint and Prettier check +npm run dev # Vite dev server on http://127.0.0.1:5173, /api goes to WC_API_TARGET or the stub +npm run dev:stub # The stub and the Vite dev server together; Ctrl+C stops both +npm run build # production build into dist/ +npm run lint # ESLint and Prettier check npm run typecheck -npm test # Vitest, including the API contract test -npm run api:lint # Only the API contract test -npm run build -npm run test:e2e # Playwright, headless Chromium -npm run screenshots # regenerate e2e/screenshots/*.png +npm test # Vitest: UI, API contract and stub API +npm run api:lint # Only the API contract test +npm run stub # The stub API on http://127.0.0.1:5174 +npm run test:stub # Only the stub API tests +npm run test:e2e # Playwright, headless Chromium, against the stub or WC_API_TARGET +npm run screenshots # Regenerate e2e/screenshots/*.png ``` ## API contract @@ -83,6 +84,118 @@ know, and requests with a field the contract does not name are refused. CI runs it on each pull request, and `npm test` includes it. +## Stub API + +`stub-api/` serves the six calls of `api/openapi.yaml` from memory, so the UI +and the real backend can be built against the same contract. It is a local +development and test stub with no real auth: the launch code and the session +token only exercise the session flow. It listens on `127.0.0.1` only and keeps +nothing on disk, so a restart brings back the sample data. + +```bash +npm run stub +# Workflow canvas stub API (local development only, no real auth) +# Listening on http://127.0.0.1:5174 +# The stub serves no page. With `npm run dev` running, open: +# http://127.0.0.1:5173/# +``` + +The launch code is new each time and works once, within 60 seconds of the +moment the stub starts listening. `POST /api/session` trades it for the token +that the other calls send as `Authorization: Bearer `. `STUB_PORT` +sets the port (a whole number from 1 to 65535; anything else stops the command +with a one-line error). `STUB_LAUNCH_CODE` sets the code, for tests only: it can be +weaker than the contract asks. + +The contract has the code work once. That would make a reload of the page a +dead end when you develop: the reload drops the token and the code from the page, +and the printed address no longer works. So the stub of `npm run dev:stub` (it +gets `--with-dev-server`) and the one that Playwright starts take the code +again, with no time limit, and give the same token. Open the printed address +again after a reload. `npm run stub` on its own follows the contract: once, +within 60 seconds. + +The data covers the first release: a workflow of an intent and stages, and the +`kiro-cli` harness. It is a `hello` workflow with a `draft` stage +(`k-developer`) and a `review` stage (`k-quality-assurance`). A saved change +lasts until the stub stops. A run is faked and calls no agent: its stages run +for 3 seconds each, one after the other, and all of them pass. + +### Run the page against the stub + +Run `npm run dev:stub`. It starts the stub and `npm run dev` together, and +Ctrl+C stops both. Or start the stub in one terminal and `npm run dev` in +another. Then open the address that the stub prints. The dev server forwards +`/api` to the stub (`vite.config.ts`), so the page and the API share one +origin. The stub sends no CORS headers and accepts only its own `Origin`, so +the proxy swaps the page's `Origin` (`http://127.0.0.1:5173` or +`http://localhost:5173`) for the stub's. Any other `Origin`, or none, goes +through unchanged and the stub refuses a POST or PUT with a 403, so a page on +another origin cannot use the dev server to call the API. The page does not +call the API, so try it with `curl`, and send the `Origin` of the page: + +```bash +curl -s -X POST http://127.0.0.1:5173/api/session \ + -H 'Origin: http://127.0.0.1:5173' \ + -H 'Content-Type: application/json' -d '{"code":""}' +``` + +The launcher sends SIGTERM to the other command when one exits, and SIGKILL +to a command that is still running 5 seconds later. If the launcher itself is +killed with SIGKILL, the stub and Vite may keep running; stop them yourself. + +## Choose the API backend + +The page always calls the relative `/api`. Only the proxy target changes, and +one variable sets it: `WC_API_TARGET`, an origin such as +`http://127.0.0.1:8080`. When it is unset, the target is the stub. A bad value +(not an http or https origin, or one with a path) stops the command that needs +it (`dev`, `preview`, `test:e2e`, `screenshots`) with a one-line error that +hides any user, password or query in the value. `build` and `npm test` never +read it, so a mistyped value in your shell cannot fail them (the tests of the +Playwright config set it themselves). `stub-api/target.ts` is the only place +that reads it. + +- Stubbed dev: `npm run dev:stub` +- Real dev: `WC_API_TARGET=http://127.0.0.1:8080 npm run dev` +- Stubbed e2e: `npm run test:e2e` +- Real e2e: `WC_API_TARGET=http://127.0.0.1:8080 npm run test:e2e` + +`dev:stub` ignores `WC_API_TARGET`, since it starts the stub. For real e2e, +Playwright does not start the stub, so start the real server first with the +launch code `e2e-launch-code`, and run the tests within 60 seconds of it +listening. The launch code works once, so restart the server before each run; +a second run against the same server fails with a 401. + +The proxy sends the `Origin` of the target in place of the page's own, and +forwards any other `Origin` unchanged. A real server must pass the same Origin +check as the stub (refuse a POST or PUT from any other `Origin`), and the same +`POST /api/session` step from `api/openapi.yaml`, where the launch code is +traded for the token that the other calls send. The e2e and contract tests then show whether the real server +answers as the stub does. + +### Code + +The code is in two parts, so the rules can be tested without a server: + +- `core/`: the workflow check, the file store and the run simulator. It does + no IO and takes its clock as a parameter. A test fails if a core file + imports anything but its own modules, `yaml` and `createHash`, or reads the + clock, randomness or the environment. +- `http/`: a thin adapter on `node:http` that checks headers and bodies and + hands the rest to the core. + +`npm run test:stub` runs the tests. Those in `http/` start the stub on a free +port, call it over HTTP and check each reply against the schema of the +contract. They move a fake clock by hand, so none of them waits. A test +compares the error codes, diagnostic codes, limits and patterns of the stub +with the contract, so a change to one without the other fails. + +Playwright starts the stub beside the app (`stub-api/web-servers.ts` holds its +launch code and the server list), with `--with-dev-server` so that a test can +open the page again with the same code. So an end-to-end test can call it. With +`WC_API_TARGET` set it does not, and the same tests call that server instead. + ## End-to-end tests The tests in `e2e/tests/` drive the production build in headless Chromium. diff --git a/workflow-canvas/e2e/tests/stub-api.spec.ts b/workflow-canvas/e2e/tests/stub-api.spec.ts new file mode 100644 index 00000000..a87fc3e7 --- /dev/null +++ b/workflow-canvas/e2e/tests/stub-api.spec.ts @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: Apache-2.0 +import { expect, test } from '@playwright/test'; +import { API_TARGET, API_URL, STUB_LAUNCH_CODE } from '../../playwright.shared'; + +// Shows that a test can reach the API: the stub that the Playwright config +// starts, or the server named by `WC_API_TARGET`. No page calls it. +test('the API gives a session for the launch code of the config', async ({ + request, +}) => { + const reply = await request.post(`${API_URL}/api/session`, { + data: { code: STUB_LAUNCH_CODE }, + headers: { Origin: API_URL }, + }); + expect(reply.status()).toBe(200); + expect(await reply.json()).toMatchObject({ + root: 'hello', + harness: 'kiro-cli', + api_version: 1, + }); +}); + +// A reload drops the token and the launch code from the page, so the person +// opens the printed address again. The stub of `dev:stub` and of these tests +// takes the code again. A server that follows the contract to the letter does +// not, so this test is for the stub only. +if (API_TARGET.isStub) { + test('the stub takes the launch code again and gives the same session', async ({ + request, + }) => { + const trade = async () => + request.post(`${API_URL}/api/session`, { + data: { code: STUB_LAUNCH_CODE }, + headers: { Origin: API_URL }, + }); + + const first = await trade(); + const again = await trade(); + + expect(again.status()).toBe(200); + expect(await again.json()).toEqual(await first.json()); + }); +} diff --git a/workflow-canvas/e2e/tsconfig.json b/workflow-canvas/e2e/tsconfig.json index a616e7a5..df3489e1 100644 --- a/workflow-canvas/e2e/tsconfig.json +++ b/workflow-canvas/e2e/tsconfig.json @@ -1,7 +1,8 @@ { "extends": "../tsconfig.json", "compilerOptions": { - "types": ["node"] + "types": ["node"], + "allowImportingTsExtensions": true }, "include": ["."] } diff --git a/workflow-canvas/package-lock.json b/workflow-canvas/package-lock.json index 2cf32078..3d0f9031 100644 --- a/workflow-canvas/package-lock.json +++ b/workflow-canvas/package-lock.json @@ -34,7 +34,8 @@ "typescript": "6.0.3", "typescript-eslint": "8.72.0", "vite": "8.3.5", - "vitest": "5.0.5" + "vitest": "5.0.5", + "yaml": "2.9.1" }, "engines": { "node": "^24.15.0 || >=26" @@ -3831,6 +3832,22 @@ "dev": true, "license": "MIT" }, + "node_modules/yaml": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", + "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/workflow-canvas/package.json b/workflow-canvas/package.json index 602553ea..01278c65 100644 --- a/workflow-canvas/package.json +++ b/workflow-canvas/package.json @@ -9,12 +9,15 @@ }, "scripts": { "dev": "vite", + "dev:stub": "node stub-api/dev.ts", + "stub": "node stub-api/main.ts", "build": "vite build", "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p e2e/tsconfig.json", "lint": "eslint . && prettier --check .", "format": "prettier --write .", "test": "vitest run", "api:lint": "vitest run api/openapi.test.ts", + "test:stub": "vitest run stub-api", "test:e2e": "playwright test", "screenshots": "playwright test --config playwright.screenshots.config.ts" }, @@ -44,6 +47,7 @@ "typescript": "6.0.3", "typescript-eslint": "8.72.0", "vite": "8.3.5", - "vitest": "5.0.5" + "vitest": "5.0.5", + "yaml": "2.9.1" } } diff --git a/workflow-canvas/playwright.shared.ts b/workflow-canvas/playwright.shared.ts index f41aeacb..c588b269 100644 --- a/workflow-canvas/playwright.shared.ts +++ b/workflow-canvas/playwright.shared.ts @@ -4,10 +4,22 @@ import { type PlaywrightTestConfig, type Project, } from '@playwright/test'; +import { apiTargetOrExit } from './stub-api/target'; +import { + APP_URL, + STUB_LAUNCH_CODE, + webServersFor, +} from './stub-api/web-servers'; + +export { STUB_LAUNCH_CODE }; -const HOST = '127.0.0.1'; -const APP_PORT = 4173; -const APP_URL = `http://${HOST}:${APP_PORT}`; +/** + * Where the API listens during a run: the stub, or the server named by + * `WC_API_TARGET`. This is the only place that reads the variable for + * Playwright; the server definitions are in `stub-api/web-servers.ts`. + */ +export const API_TARGET = apiTargetOrExit(); +export const API_URL = API_TARGET.origin; /** * One viewport and render mode for every project. With the pinned font and the @@ -34,17 +46,5 @@ export const sharedConfig: PlaywrightTestConfig = { // A flaky test must fail the run, not pass on a retry. retries: 0, use: { baseURL: APP_URL, trace: 'retain-on-failure' }, - // Servers the tests depend on, started before and stopped after the run. - // The app is served from a production build, as users get it. A backing - // service the app calls gets its own entry here, so one command starts - // everything. - webServer: [ - { - name: 'app', - command: `npm run build && npx vite preview --host ${HOST} --port ${APP_PORT} --strictPort`, - url: APP_URL, - reuseExistingServer: false, - timeout: 120_000, - }, - ], + webServer: webServersFor(API_TARGET), }; diff --git a/workflow-canvas/stub-api/address.test.ts b/workflow-canvas/stub-api/address.test.ts new file mode 100644 index 00000000..cffb88ee --- /dev/null +++ b/workflow-canvas/stub-api/address.test.ts @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: Apache-2.0 +import { describe, expect, it } from 'vitest'; +import { DEFAULT_PORT, originOf, portOf, stubPort } from './address.ts'; + +describe('stubPort', () => { + it('is the default port when STUB_PORT is unset or empty', () => { + expect(stubPort({})).toBe(DEFAULT_PORT); + expect(stubPort({ STUB_PORT: '' })).toBe(DEFAULT_PORT); + }); + + it.each([ + ['1', 1], + ['80', 80], + ['6000', 6000], + ['65535', 65535], + ])('reads STUB_PORT=%s as %i', (value, port) => { + expect(stubPort({ STUB_PORT: value })).toBe(port); + }); +}); + +describe('originOf', () => { + it('names the port, except the default port of http', () => { + expect(originOf(5174)).toBe('http://127.0.0.1:5174'); + expect(originOf(80)).toBe('http://127.0.0.1'); + }); +}); + +describe('portOf', () => { + it.each([ + ['http://127.0.0.1:5174', 5174], + ['http://127.0.0.1', 80], + ['http://127.0.0.1:80', 80], + ['https://api.example.test', 443], + ['https://api.example.test:8443', 8443], + ])('reads %s as port %i', (origin, port) => { + expect(portOf(origin)).toBe(port); + }); +}); diff --git a/workflow-canvas/stub-api/address.ts b/workflow-canvas/stub-api/address.ts new file mode 100644 index 00000000..830453a1 --- /dev/null +++ b/workflow-canvas/stub-api/address.ts @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: Apache-2.0 +import { ConfigError } from './config-error.ts'; + +/** The stub listens here and nowhere else. */ +export const LOOPBACK = '127.0.0.1'; +/** The port of the server URL in the contract. */ +export const DEFAULT_PORT = 5174; +/** The port of the Vite dev server, which proxies `/api` to the stub. */ +export const DEV_PAGE_PORT = 5173; + +/** A `STUB_PORT` value that is not a port number. */ +export class StubPortError extends ConfigError { + constructor(value: string) { + super( + `STUB_PORT=${JSON.stringify(value)} is not usable: ` + + 'use a whole number from 1 to 65535, or unset it for the default.', + ); + this.name = 'StubPortError'; + } +} + +/** + * The port of the stub: `STUB_PORT` if it is set, else the default. An empty + * value counts as unset. Throws `StubPortError` for anything but 1 to 65535. + */ +export function stubPort(env: NodeJS.ProcessEnv = process.env): number { + const value = env.STUB_PORT; + if (!value) return DEFAULT_PORT; + const port = /^\d+$/.test(value) ? Number(value) : NaN; + if (!(port >= 1 && port <= 65535)) throw new StubPortError(value); + return port; +} + +/** + * The origin of a server on the loopback address, as a browser writes it: + * the default port of the scheme (80) is left out. + */ +export function originOf(port: number): string { + return new URL(`http://${LOOPBACK}:${port}`).origin; +} + +/** The port an http(s) origin names, with the scheme's default if it has none. */ +export function portOf(origin: string): number { + const url = new URL(origin); + return Number(url.port) || (url.protocol === 'https:' ? 443 : 80); +} diff --git a/workflow-canvas/stub-api/config-error.ts b/workflow-canvas/stub-api/config-error.ts new file mode 100644 index 00000000..dfe4f919 --- /dev/null +++ b/workflow-canvas/stub-api/config-error.ts @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: Apache-2.0 + +/** A bad setting that a command reports in one line, without a stack trace. */ +export class ConfigError extends Error { + constructor(message: string) { + super(message); + this.name = 'ConfigError'; + } +} + +/** + * Runs `read`. A `ConfigError` prints one `error:` line and exits with 1; + * anything else is a bug and is rethrown. + */ +export function exitOnConfigError(read: () => T): T { + try { + return read(); + } catch (error) { + if (!(error instanceof ConfigError)) throw error; + console.error(`error: ${error.message}`); + return process.exit(1); + } +} diff --git a/workflow-canvas/stub-api/core/clock.ts b/workflow-canvas/stub-api/core/clock.ts new file mode 100644 index 00000000..f2ae35c3 --- /dev/null +++ b/workflow-canvas/stub-api/core/clock.ts @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: Apache-2.0 + +/** The only source of time for the core, so tests can move it by hand. */ +export interface Clock { + /** Milliseconds since the Unix epoch. */ + now(): number; +} diff --git a/workflow-canvas/stub-api/core/contract-drift.test.ts b/workflow-canvas/stub-api/core/contract-drift.test.ts new file mode 100644 index 00000000..2c5bc6c7 --- /dev/null +++ b/workflow-canvas/stub-api/core/contract-drift.test.ts @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: Apache-2.0 +/** Checks that the codes, limits and patterns of the stub are those of api/openapi.yaml. */ +import { describe, expect, it } from 'vitest'; +import { STATUS_BY_CODE } from '../http/respond.ts'; +import { + contractDescription, + errorExamples, + schemaOf, +} from '../test-support/contract.ts'; +import { + HASH_PATTERN, + MAX_GOAL_CHARS, + MAX_INTENT_CHARS, + MAX_STAGES, + MAX_TEXT_BYTES, + NAME_PATTERN, + RESERVED_STAGE_NAMES, + RUN_ID_PATTERN, +} from './contract.ts'; +import { ERROR_CODES, type ErrorCode } from './errors.ts'; +import { DIAGNOSTIC_CODES } from './model.ts'; + +type Json = Record; +const property = (schema: Json, name: string) => + (schema.properties as Record)[name] as Json; + +describe('the stub and api/openapi.yaml', () => { + it('have the same error codes', () => { + expect([...ERROR_CODES].sort()).toEqual( + [...(schemaOf('ErrorCode').enum as string[])].sort(), + ); + }); + + it('have the same diagnostic codes', () => { + expect([...DIAGNOSTIC_CODES].sort()).toEqual( + [...(schemaOf('DiagnosticCode').enum as string[])].sort(), + ); + }); + + it('give every error code the status that the contract shows with it', () => { + const shown = errorExamples(); + for (const { status, code } of shown) { + expect(STATUS_BY_CODE[code as ErrorCode], code).toBe(status); + } + // A code with no example is named with its status in the description. + const description = contractDescription(); + for (const code of ERROR_CODES) { + const status = STATUS_BY_CODE[code]; + const inExample = shown.some((e) => e.code === code); + const inText = new RegExp(`${status}\\s+\`${code}\``).test(description); + expect(inExample || inText, `${status} ${code}`).toBe(true); + } + }); + + it('have the same patterns', () => { + expect(schemaOf('Name').pattern).toBe(NAME_PATTERN.source); + expect(schemaOf('Hash').pattern).toBe(HASH_PATTERN.source); + expect(schemaOf('RunId').pattern).toBe(RUN_ID_PATTERN.source); + }); + + it('have the same reserved stage names', () => { + const rule = (schemaOf('StageName').allOf as Json[])[1] as { + not: { enum: string[] }; + }; + expect([...RESERVED_STAGE_NAMES].sort()).toEqual([...rule.not.enum].sort()); + }); + + it('have the same limits', () => { + expect(property(schemaOf('Intent'), 'outcome').maxLength).toBe( + MAX_INTENT_CHARS, + ); + expect(property(schemaOf('WorkflowView'), 'stages').maxItems).toBe( + MAX_STAGES, + ); + const params = property(schemaOf('RunRequest'), 'params'); + expect(property(params, 'goal').maxLength).toBe(MAX_GOAL_CHARS); + for (const request of ['SaveRequest', 'CheckRequest']) { + const text = property(schemaOf(request), 'text').description as string; + expect(text, request).toContain(`${MAX_TEXT_BYTES} bytes`); + } + }); +}); diff --git a/workflow-canvas/stub-api/core/contract.ts b/workflow-canvas/stub-api/core/contract.ts new file mode 100644 index 00000000..9c2fea84 --- /dev/null +++ b/workflow-canvas/stub-api/core/contract.ts @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: Apache-2.0 + +/** Values that api/openapi.yaml fixes for this version of the API. */ +export const API_VERSION = 1; +export const HARNESS = 'kiro-cli'; +export const MAX_TEXT_BYTES = 262_144; +export const MAX_GOAL_CHARS = 2048; +export const MAX_INTENT_CHARS = 300; +export const MAX_STAGES = 40; + +export const NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/; +export const HASH_PATTERN = /^[0-9a-f]{64}$/; +export const RUN_ID_PATTERN = /^[0-9]{8}-[0-9]{4}-[a-z0-9]{5}$/; +export const RESERVED_STAGE_NAMES: ReadonlySet = new Set([ + 'start', + 'end', + 'run', + 'halt', +]); + +/** The length of a text in code points, which is how the contract counts. */ +export function codePointLength(text: string): number { + return [...text].length; +} + +/** A workflow name, stage name or agent id is lower-case words with hyphens. */ +export function isName(value: string): boolean { + return NAME_PATTERN.test(value); +} + +/** A stage name must be a name and must not be one of the reserved words. */ +export function isUsableStageName(value: string): boolean { + return isName(value) && !RESERVED_STAGE_NAMES.has(value); +} diff --git a/workflow-canvas/stub-api/core/diagnostics.ts b/workflow-canvas/stub-api/core/diagnostics.ts new file mode 100644 index 00000000..46e23270 --- /dev/null +++ b/workflow-canvas/stub-api/core/diagnostics.ts @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: Apache-2.0 +import type { Diagnostic, DiagnosticCode } from './model.ts'; +import type { SourceMap } from './source-map.ts'; + +/** A step in a key path: a key, or the place of a list entry. */ +export type Segment = string | number; +/** Start and end offsets in the text; the end is the first place after. */ +export type Span = readonly [start: number, end: number]; + +interface Found { + start: number; + diagnostic: Diagnostic; +} + +/** + * Collects the diagnostics of one check. A stage is addressed by its name + * when it has a usable one, and by its place in the list otherwise. + */ +export class Reporter { + private readonly file: string; + private readonly source: SourceMap; + private readonly found: Found[] = []; + private stageNames: readonly (string | undefined)[] = []; + + constructor(file: string, source: SourceMap) { + this.file = file; + this.source = source; + } + + nameStages(names: readonly (string | undefined)[]): void { + this.stageNames = names; + } + + report( + code: DiagnosticCode, + path: readonly Segment[], + span: Span, + message: string, + suggestion?: string, + ): void { + const start = this.source.position(span[0]); + const end = this.source.position(span[1]); + this.found.push({ + start: span[0], + diagnostic: { + code, + severity: 'error', + file: this.file, + path: this.formatPath(path), + line: start.line, + column: start.column, + end_line: end.line, + end_column: end.column, + message, + ...(suggestion === undefined ? {} : { suggestion }), + }, + }); + } + + hasErrors(): boolean { + return this.found.length > 0; + } + + /** Every finding, in the order of the text. */ + diagnostics(): Diagnostic[] { + return [...this.found] + .sort((a, b) => a.start - b.start) + .map((found) => found.diagnostic); + } + + private formatPath(path: readonly Segment[]): string { + return path.reduce((joined, segment, index) => { + if (typeof segment === 'string') { + return joined === '' ? segment : `${joined}.${segment}`; + } + const stageName = + path[index - 1] === 'stages' ? this.stageNames[segment] : undefined; + return stageName === undefined + ? `${joined}[${segment}]` + : `${joined}.${stageName}`; + }, ''); + } +} diff --git a/workflow-canvas/stub-api/core/errors.ts b/workflow-canvas/stub-api/core/errors.ts new file mode 100644 index 00000000..c430d8f0 --- /dev/null +++ b/workflow-canvas/stub-api/core/errors.ts @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: Apache-2.0 + +/** The error codes of the API contract. */ +export const ERROR_CODES = [ + 'bad_request', + 'bad_name', + 'bad_hash', + 'unauthorized', + 'invalid_code', + 'forbidden_host', + 'forbidden_origin', + 'read_only_workflow', + 'not_found', + 'method_not_allowed', + 'conflict', + 'already_running', + 'harness_unavailable', + 'too_large', + 'invalid_workflow', + 'bad_params', + 'io_error', +] as const; + +export type ErrorCode = (typeof ERROR_CODES)[number]; + +/** A refusal that the HTTP adapter turns into an error response. */ +export class ApiError extends Error { + readonly code: ErrorCode; + /** Fields that come with the code, such as `current_hash`. */ + readonly extra: Readonly>; + + constructor( + code: ErrorCode, + message: string, + extra: Readonly> = {}, + ) { + super(message); + this.name = 'ApiError'; + this.code = code; + this.extra = extra; + } +} + +/** The 400 for a body, header or field that is not what the contract allows. */ +export function badRequest(): ApiError { + return new ApiError('bad_request', 'The request body is not valid.'); +} + +/** The 400 for a workflow name that does not match the name pattern. */ +export function badName(): ApiError { + return new ApiError('bad_name', 'The workflow name is not valid.'); +} diff --git a/workflow-canvas/stub-api/core/hash.ts b/workflow-canvas/stub-api/core/hash.ts new file mode 100644 index 00000000..9392c4f3 --- /dev/null +++ b/workflow-canvas/stub-api/core/hash.ts @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: Apache-2.0 +import { createHash } from 'node:crypto'; + +/** The SHA-256 of a text's UTF-8 bytes, as 64 lower-case hex digits. */ +export function sha256Hex(text: string): string { + return createHash('sha256').update(text, 'utf8').digest('hex'); +} diff --git a/workflow-canvas/stub-api/core/model.ts b/workflow-canvas/stub-api/core/model.ts new file mode 100644 index 00000000..577a0c8d --- /dev/null +++ b/workflow-canvas/stub-api/core/model.ts @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: Apache-2.0 + +/** The wire shapes of api/openapi.yaml that the core produces. */ +export const DIAGNOSTIC_CODES = [ + 'YAML_SYNTAX', + 'YAML_UNSUPPORTED', + 'NOT_A_MAPPING', + 'UNKNOWN_KEY', + 'DUPLICATE_KEY', + 'MISSING_KEY', + 'WRONG_TYPE', + 'INVALID_NAME', + 'RESERVED_NAME', + 'DUPLICATE_STAGE', + 'NAME_MISMATCH', + 'INVALID_VALUE', +] as const; + +export type DiagnosticCode = (typeof DIAGNOSTIC_CODES)[number]; + +export interface Diagnostic { + code: DiagnosticCode; + severity: 'error' | 'warning' | 'info'; + file: string; + path: string; + line: number; + column: number; + end_line: number; + end_column: number; + message: string; + suggestion?: string; +} + +export interface Stage { + name: string; + agent: string; +} + +export interface WorkflowView { + name: string; + intent: { outcome: string }; + stages: Stage[]; +} + +export interface CheckResult { + errors: number; + warnings: number; + infos: number; + view: WorkflowView | null; + diagnostics: Diagnostic[]; + summary: { valid: boolean; counts: { stages: number } }; +} + +export interface WorkflowDocument { + name: string; + text: string | null; + hash: string | null; + read_only: boolean; + view: WorkflowView | null; + diagnostics: Diagnostic[]; +} + +export interface SaveResult { + hash: string; + view: WorkflowView; + diagnostics: Diagnostic[]; +} + +export type StageStatus = 'pending' | 'running' | 'passed' | 'blocked'; +export type RunStatus = 'running' | 'passed' | 'halted'; + +export interface Run { + run_id: string; + workflow: string; + harness: string; + status: RunStatus; + started_at: string; + finished_at: string | null; + exit_code: number | null; + order: string[]; + stages: Record; +} diff --git a/workflow-canvas/stub-api/core/purity.test.ts b/workflow-canvas/stub-api/core/purity.test.ts new file mode 100644 index 00000000..a21bd821 --- /dev/null +++ b/workflow-canvas/stub-api/core/purity.test.ts @@ -0,0 +1,187 @@ +// SPDX-License-Identifier: Apache-2.0 +/** + * Checks that the core does no IO and reads no clock, randomness or + * environment. It may import its own modules, `yaml` and `createHash`. + */ +import { + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +const CORE_DIR = fileURLToPath(new URL('.', import.meta.url)); +const ALLOWED_PACKAGES = new Set(['yaml']); +// A named import is allowed from a module that is otherwise not: the names. +const ALLOWED_NAMES: Readonly> = { + 'node:crypto': ['createHash'], +}; +const IMPURE = [ + /\bDate\b/, + /\bMath\.random\b/, + /\bperformance\b/, + /\b(?:randomBytes|randomUUID|randomInt|randomFillSync|getRandomValues)\b/, + /\bcrypto\./, + /\bprocess\b/, + /\b(?:setTimeout|setInterval|setImmediate)\b/, + /\bfetch\s*\(/, + /\brequire\s*\(/, +]; + +/** The module names that a source file imports, statically or dynamically. */ +function importedModules(source: string): string[] { + const pattern = /(?:\bfrom\s*|\bimport\s*\(\s*|\bimport\s+)['"]([^'"]+)['"]/g; + return [...source.matchAll(pattern)].map((match) => match[1] ?? ''); +} + +/** The names of the `import { a, b as c } from 'module'` statements. */ +function namedImports(source: string, module: string): string[] { + const pattern = new RegExp( + `\\bimport\\s+(?:type\\s+)?\\{([^}]*)\\}\\s*from\\s*['"]${module}['"]`, + 'g', + ); + return [...source.matchAll(pattern)].flatMap((match) => + (match[1] ?? '') + .split(',') + .map( + (name) => + name + .trim() + .replace(/^type\s+/, '') + .split(/\s+as\s+/)[0] ?? '', + ) + .filter((name) => name !== ''), + ); +} + +function isOwnModule(specifier: string, fromFile: string): boolean { + if (!specifier.startsWith('.')) return false; + const target = fileURLToPath( + new URL(specifier, `file://${CORE_DIR}${fromFile}`), + ); + return target.startsWith(CORE_DIR); +} + +function isAllowedImport(specifier: string, fromFile: string, source: string) { + const names = ALLOWED_NAMES[specifier]; + if (names) { + const count = importedModules(source).filter((m) => m === specifier).length; + const named = namedImports(source, specifier); + return named.length >= count && named.every((n) => names.includes(n)); + } + return ALLOWED_PACKAGES.has(specifier) || isOwnModule(specifier, fromFile); +} + +/** Every way that a core source breaks the rules of the core. */ +function problemsIn(file: string, source: string): string[] { + const imports = importedModules(source) + .filter((module) => !isAllowedImport(module, file, source)) + .map((module) => `${file} imports ${module}`); + const calls = IMPURE.filter((pattern) => pattern.test(source)).map( + (pattern) => `${file} matches ${pattern}`, + ); + return [...imports, ...calls]; +} + +/** The `.ts` sources under a directory, at any depth, tests left out. */ +function sourcesUnder(dir: string): { file: string; source: string }[] { + return readdirSync(dir, { recursive: true, encoding: 'utf8' }) + .filter((file) => file.endsWith('.ts') && !file.endsWith('.test.ts')) + .map((file) => ({ file, source: readFileSync(join(dir, file), 'utf8') })); +} + +const coreSources = () => sourcesUnder(CORE_DIR); + +describe('the core', () => { + it('has sources to check', () => { + expect(coreSources().length).toBeGreaterThan(5); + }); + + it('imports only its own modules, yaml and createHash, and reads no clock, randomness, environment or network', () => { + const problems = coreSources().flatMap(({ file, source }) => + problemsIn(file, source), + ); + expect(problems).toEqual([]); + }); +}); + +describe('the scan, on code that breaks the rules', () => { + const HASH = "import { createHash } from 'node:crypto';\n"; + + it('accepts the named import of createHash and own modules', () => { + const source = `${HASH}import { a } from './a.ts';\nimport { b } from './sub/b.ts';\n`; + expect(problemsIn('x.ts', source)).toEqual([]); + }); + + it.each([ + ['randomBytes', "import { createHash, randomBytes } from 'node:crypto';"], + ['a renamed randomUUID', "import { randomUUID as id } from 'node:crypto';"], + ['a namespace import of node:crypto', "import * as c from 'node:crypto';"], + ['a default import of node:crypto', "import crypto from 'node:crypto';"], + [ + 'a dynamic import of node:crypto', + "const c = await import('node:crypto');", + ], + ['node:fs', "import { readFileSync } from 'node:fs';"], + ['a module above core', "import { x } from '../http/server.ts';"], + ['a package that is not allowed', "import x from 'express';"], + ])('rejects %s', (_, line) => { + expect(problemsIn('x.ts', line)).not.toEqual([]); + }); + + it.each([ + ['Date.now()', 'const t = Date.now();'], + ['new Date()', 'const t = new Date();'], + ['new Date(ms)', 'const t = new Date(ms);'], + ['Date()', 'const t = Date();'], + ['Math.random()', 'const r = Math.random();'], + ['performance.now()', 'const t = performance.now();'], + ['randomUUID()', 'const id = randomUUID();'], + ['randomBytes()', 'const b = randomBytes(8);'], + ['getRandomValues()', 'crypto.getRandomValues(new Uint8Array(4));'], + ['globalThis.crypto', 'const id = globalThis.crypto.randomUUID();'], + ['process.env', 'const v = process.env.HOME;'], + ['setTimeout()', 'setTimeout(() => {}, 1);'], + ['fetch()', "await fetch('http://x');"], + ])('rejects %s', (_, line) => { + expect(problemsIn('x.ts', `${HASH}${line}\n`)).not.toEqual([]); + }); + + it('walks sub-directories', () => { + const dir = mkdtempSync(join(tmpdir(), 'purity-')); + try { + mkdirSync(join(dir, 'deep')); + writeFileSync( + join(dir, 'deep', 'bad.ts'), + 'export const t = Date.now();', + ); + writeFileSync(join(dir, 'deep', 'bad.test.ts'), ''); + expect(sourcesUnder(dir).map((s) => s.file)).toEqual(['deep/bad.ts']); + } finally { + rmSync(dir, { recursive: true }); + } + }); + + it('finds static, side-effect, re-export and dynamic imports', () => { + const source = [ + "import { a } from 'node:fs';", + 'import type { B } from "./b.ts";', + "import 'node:http';", + "export { c } from 'node:net';", + "const d = await import('node:child_process');", + ].join('\n'); + expect(importedModules(source)).toEqual([ + 'node:fs', + './b.ts', + 'node:http', + 'node:net', + 'node:child_process', + ]); + }); +}); diff --git a/workflow-canvas/stub-api/core/runs.test.ts b/workflow-canvas/stub-api/core/runs.test.ts new file mode 100644 index 00000000..d2a96e5c --- /dev/null +++ b/workflow-canvas/stub-api/core/runs.test.ts @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: Apache-2.0 +/** Checks the simulated run against a clock that the test moves. */ +import { beforeEach, describe, expect, it } from 'vitest'; +import { countingSuffix, FakeClock } from '../test-support/fake-clock.ts'; +import { RUN_ID_PATTERN } from './contract.ts'; +import { RunSimulator } from './runs.ts'; + +const STAGE_MS = 1000; +let clock: FakeClock; +let runs: RunSimulator; + +beforeEach(() => { + clock = new FakeClock(); + runs = new RunSimulator({ + clock, + suffix: countingSuffix(), + stageMs: STAGE_MS, + }); +}); + +function statusesOf(runId: string): string[] { + const run = runs.get(runId); + return Object.values(run?.stages ?? {}).map((stage) => stage.status); +} + +describe('a simulated run', () => { + it('has an id of the contract, made from the start time', () => { + const runId = runs.start('hello', ['draft', 'review']); + expect(runId).toBe('20261024-0412-00001'); + expect(runId).toMatch(RUN_ID_PATTERN); + }); + + it('starts with the first stage running and the rest pending', () => { + const runId = runs.start('hello', ['draft', 'review']); + expect(runs.get(runId)).toEqual({ + run_id: runId, + workflow: 'hello', + harness: 'kiro-cli', + status: 'running', + started_at: '2026-10-24T04:12:05Z', + finished_at: null, + exit_code: null, + order: ['draft', 'review'], + stages: { draft: { status: 'running' }, review: { status: 'pending' } }, + }); + }); + + it('runs the stages one after the other and then passes', () => { + const runId = runs.start('hello', ['draft', 'review']); + const seen = [statusesOf(runId)]; + for (let step = 0; step < 2; step++) { + clock.advance(STAGE_MS); + seen.push(statusesOf(runId)); + } + expect(seen).toEqual([ + ['running', 'pending'], + ['passed', 'running'], + ['passed', 'passed'], + ]); + expect(runs.get(runId)).toMatchObject({ + status: 'passed', + finished_at: '2026-10-24T04:12:07Z', + exit_code: 0, + }); + }); + + it('stays in a stage until its time is up', () => { + const runId = runs.start('hello', ['draft', 'review']); + clock.advance(STAGE_MS - 1); + expect(statusesOf(runId)).toEqual(['running', 'pending']); + }); + + it('stays readable after it has finished', () => { + const runId = runs.start('hello', ['draft']); + clock.advance(60 * STAGE_MS); + expect(runs.get(runId)?.status).toBe('passed'); + }); + + it('is the only active run until it finishes', () => { + const runId = runs.start('hello', ['draft', 'review']); + expect(runs.activeRunId()).toBe(runId); + clock.advance(2 * STAGE_MS - 1); + expect(runs.activeRunId()).toBe(runId); + clock.advance(1); + expect(runs.activeRunId()).toBeUndefined(); + }); + + it('does not know a run that was never started', () => { + expect(runs.get('20261024-0412-zzzzz')).toBeUndefined(); + }); + + it('gives two runs that start at once two ids', () => { + const first = runs.start('hello', ['draft']); + const second = runs.start('hello', ['draft']); + expect(second).not.toBe(first); + }); +}); diff --git a/workflow-canvas/stub-api/core/runs.ts b/workflow-canvas/stub-api/core/runs.ts new file mode 100644 index 00000000..47dfb4d7 --- /dev/null +++ b/workflow-canvas/stub-api/core/runs.ts @@ -0,0 +1,103 @@ +// SPDX-License-Identifier: Apache-2.0 +import type { Clock } from './clock.ts'; +import { HARNESS } from './contract.ts'; +import type { Run, StageStatus } from './model.ts'; +import { formatTimestamp, runIdPrefix } from './timestamp.ts'; + +/** How long each simulated stage takes. */ +export const DEFAULT_STAGE_MS = 3000; + +interface StartedRun { + runId: string; + workflow: string; + startedAt: number; + order: string[]; +} + +export interface RunSimulatorOptions { + clock: Clock; + /** Five characters from `a-z0-9`, for the end of a run id. */ + suffix: () => string; + stageMs?: number; +} + +/** + * Fakes runs without running anything: each read works out the state from the + * clock, with stages of `stageMs` that all pass. + */ +export class RunSimulator { + private readonly clock: Clock; + private readonly suffix: () => string; + private readonly stageMs: number; + private readonly runs = new Map(); + private latest: StartedRun | undefined; + + constructor(options: RunSimulatorOptions) { + this.clock = options.clock; + this.suffix = options.suffix; + this.stageMs = options.stageMs ?? DEFAULT_STAGE_MS; + } + + /** The id of the run that has not finished yet, if there is one. */ + activeRunId(): string | undefined { + const run = this.latest; + return run && this.clock.now() < this.finishTime(run) + ? run.runId + : undefined; + } + + start(workflow: string, stageNames: readonly string[]): string { + const startedAt = this.clock.now(); + const run = { + runId: this.newRunId(startedAt), + workflow, + startedAt, + order: [...stageNames], + }; + this.runs.set(run.runId, run); + this.latest = run; + return run.runId; + } + + get(runId: string): Run | undefined { + const run = this.runs.get(runId); + return run && this.describe(run); + } + + private describe(run: StartedRun): Run { + const done = this.clock.now() >= this.finishTime(run); + const stages: Run['stages'] = {}; + run.order.forEach((name, index) => { + stages[name] = { status: this.stageStatus(run, index) }; + }); + return { + run_id: run.runId, + workflow: run.workflow, + harness: HARNESS, + status: done ? 'passed' : 'running', + started_at: formatTimestamp(run.startedAt), + finished_at: done ? formatTimestamp(this.finishTime(run)) : null, + exit_code: done ? 0 : null, + order: [...run.order], + stages, + }; + } + + private stageStatus(run: StartedRun, index: number): StageStatus { + const elapsed = Math.max(0, this.clock.now() - run.startedAt); + const current = Math.floor(elapsed / this.stageMs); + if (index < current) return 'passed'; + return index === current ? 'running' : 'pending'; + } + + private finishTime(run: StartedRun): number { + return run.startedAt + run.order.length * this.stageMs; + } + + private newRunId(startedAt: number): string { + const prefix = runIdPrefix(startedAt); + let runId = `${prefix}-${this.suffix()}`; + while (this.runs.has(runId)) runId = `${prefix}-${this.suffix()}`; + return runId; + } +} diff --git a/workflow-canvas/stub-api/core/sample.ts b/workflow-canvas/stub-api/core/sample.ts new file mode 100644 index 00000000..afc2b8d7 --- /dev/null +++ b/workflow-canvas/stub-api/core/sample.ts @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: Apache-2.0 + +/** The workflow the stub serves, and so the root of its session. */ +export const SAMPLE_ROOT = 'hello'; + +/** The one fixed workflow file: a draft stage, then a review stage. */ +export const SAMPLE_FILES: Readonly> = { + [SAMPLE_ROOT]: `name: hello +intent: Add a greeting to the README +stages: + - name: draft + agent: k-developer + - name: review + agent: k-quality-assurance +`, +}; diff --git a/workflow-canvas/stub-api/core/source-map.test.ts b/workflow-canvas/stub-api/core/source-map.test.ts new file mode 100644 index 00000000..7ef522be --- /dev/null +++ b/workflow-canvas/stub-api/core/source-map.test.ts @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: Apache-2.0 +/** Checks line and column lookups, and that a long line costs no more per lookup. */ +import { describe, expect, it } from 'vitest'; +import { SourceMap } from './source-map.ts'; +import { checkWorkflowText } from './validator.ts'; + +const FLOW_TIME_LIMIT_MS = 2000; + +describe('SourceMap', () => { + it('counts lines from 1 at LF, CRLF and a lone CR', () => { + const map = new SourceMap('a\nb\r\nc\rd'); + expect([0, 2, 5, 7].map((at) => map.position(at))).toEqual([ + { line: 1, column: 1 }, + { line: 2, column: 1 }, + { line: 3, column: 1 }, + { line: 4, column: 1 }, + ]); + }); + + it('counts code points, so a pair of UTF-16 units is one column', () => { + const map = new SourceMap('a😀b\n😀😀c'); + expect(map.position(3)).toEqual({ line: 1, column: 3 }); + expect(map.position(4)).toEqual({ line: 1, column: 4 }); + expect(map.position(9)).toEqual({ line: 2, column: 3 }); + }); + + it('counts a tab as one column', () => { + expect(new SourceMap('\t\tx').position(2)).toEqual({ line: 1, column: 3 }); + }); + + it('puts an offset at the end of the text on the last line', () => { + expect(new SourceMap('ab\n').position(3)).toEqual({ line: 2, column: 1 }); + }); +}); + +describe('the check of a very long line', () => { + // One flow mapping of unknown keys on one line. The contract allows 262144 + // bytes, and the quadratic version of the lookup needed over a minute here. + function flowMapping(count: number, prefix: string): string { + const entries = Array.from( + { length: count }, + (_, i) => `${prefix}k${i}: 1`, + ); + return `{${entries.join(', ')}}\n`; + } + + it.each([ + ['250 KB of ASCII', flowMapping(24_000, ''), 245_000], + ['text with surrogate pairs', flowMapping(8000, '😀'), 100_000], + ])('finishes well within the time limit for %s', (_, text, minBytes) => { + const bytes = Buffer.byteLength(text); + expect(bytes).toBeGreaterThan(minBytes); + expect(bytes).toBeLessThanOrEqual(262_144); + const started = performance.now(); + const result = checkWorkflowText(text, 'hello'); + const elapsed = performance.now() - started; + expect(result.errors).toBeGreaterThan(7000); + expect(elapsed).toBeLessThan(FLOW_TIME_LIMIT_MS); + }); +}); diff --git a/workflow-canvas/stub-api/core/source-map.ts b/workflow-canvas/stub-api/core/source-map.ts new file mode 100644 index 00000000..109cd895 --- /dev/null +++ b/workflow-canvas/stub-api/core/source-map.ts @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: Apache-2.0 + +export interface Position { + line: number; + column: number; +} + +/** + * Turns offsets into the line and column of the contract: a line ends at LF, + * CRLF or a lone CR, and a column counts code points. A lookup is a search. + */ +export class SourceMap { + private readonly lineStarts: number[] = [0]; + // pairsBefore[i] is how many surrogate pairs end before offset i. + private readonly pairsBefore: Uint32Array; + + constructor(text: string) { + this.pairsBefore = new Uint32Array(text.length + 1); + for (let i = 0; i < text.length; i++) { + const pair = isLowHalf(text, i) && isHighHalf(text, i - 1); + this.pairsBefore[i + 1] = (this.pairsBefore[i] ?? 0) + (pair ? 1 : 0); + } + for (let i = 0; i < text.length; i++) { + const char = text.charCodeAt(i); + if (char === 13 && text.charCodeAt(i + 1) === 10) i++; + else if (char !== 10 && char !== 13) continue; + this.lineStarts.push(i + 1); + } + } + + position(offset: number): Position { + const at = Math.min(offset, this.pairsBefore.length - 1); + const line = this.lineIndexOf(at); + const start = this.lineStarts[line] ?? 0; + const pairs = this.pairsAt(at) - this.pairsAt(start); + return { line: line + 1, column: at - start - pairs + 1 }; + } + + private pairsAt(offset: number): number { + return this.pairsBefore[offset] ?? 0; + } + + // The last line that starts at or before the offset. + private lineIndexOf(offset: number): number { + let low = 0; + let high = this.lineStarts.length - 1; + while (low < high) { + const middle = Math.ceil((low + high) / 2); + if ((this.lineStarts[middle] ?? 0) <= offset) low = middle; + else high = middle - 1; + } + return low; + } +} + +const isHighHalf = (text: string, index: number) => + text.charCodeAt(index) >= 0xd800 && text.charCodeAt(index) <= 0xdbff; + +const isLowHalf = (text: string, index: number) => + text.charCodeAt(index) >= 0xdc00 && text.charCodeAt(index) <= 0xdfff; diff --git a/workflow-canvas/stub-api/core/store.ts b/workflow-canvas/stub-api/core/store.ts new file mode 100644 index 00000000..4d91321a --- /dev/null +++ b/workflow-canvas/stub-api/core/store.ts @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: Apache-2.0 +import { ApiError } from './errors.ts'; +import { sha256Hex } from './hash.ts'; + +export interface StoredFile { + text: string; + hash: string; +} + +/** + * Workflow files held in memory, by name. A save compares the hash and writes + * in one synchronous step. + */ +export class WorkflowStore { + private readonly files = new Map(); + + constructor(files: Readonly> = {}) { + for (const [name, text] of Object.entries(files)) + this.files.set(name, text); + } + + read(name: string): StoredFile | undefined { + const text = this.files.get(name); + return text === undefined ? undefined : { text, hash: sha256Hex(text) }; + } + + /** + * Writes the text if the file still has the hash the caller loaded, where + * `null` means the file must not exist. Returns the hash of the new text. + */ + save(name: string, text: string, baseHash: string | null): string { + const current = this.read(name); + const unchanged = baseHash === null ? !current : current?.hash === baseHash; + if (!unchanged) throw conflictWith(current, baseHash); + this.files.set(name, text); + return sha256Hex(text); + } +} + +function conflictWith( + current: StoredFile | undefined, + baseHash: string | null, +): ApiError { + if (!current) return new ApiError('conflict', 'The file no longer exists.'); + const message = + baseHash === null + ? 'The file already exists.' + : 'The file changed on disk.'; + return new ApiError('conflict', message, { current_hash: current.hash }); +} diff --git a/workflow-canvas/stub-api/core/suggest.ts b/workflow-canvas/stub-api/core/suggest.ts new file mode 100644 index 00000000..92cd50fc --- /dev/null +++ b/workflow-canvas/stub-api/core/suggest.ts @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: Apache-2.0 + +const MAX_DISTANCE = 2; + +/** The edit distance between two texts, counting code points. */ +export function editDistance(a: string, b: string): number { + const left = [...a]; + const right = [...b]; + let previous = right.map((_, index) => index + 1); + previous.unshift(0); + left.forEach((char, i) => { + const row = [i + 1]; + right.forEach((other, j) => { + const substitution = (previous[j] ?? 0) + (char === other ? 0 : 1); + const deletion = (previous[j + 1] ?? 0) + 1; + const insertion = (row[j] ?? 0) + 1; + row.push(Math.min(substitution, deletion, insertion)); + }); + previous = row; + }); + return previous[right.length] ?? 0; +} + +/** + * The candidate nearest to the key, the first one if two tie, or `undefined` + * when none is within an edit distance of 2. + */ +export function suggestKey( + key: string, + candidates: readonly string[], +): string | undefined { + let best: string | undefined; + let bestDistance = MAX_DISTANCE + 1; + for (const candidate of candidates) { + const distance = editDistance(key, candidate); + if (distance < bestDistance) { + best = candidate; + bestDistance = distance; + } + } + return best; +} diff --git a/workflow-canvas/stub-api/core/timestamp.test.ts b/workflow-canvas/stub-api/core/timestamp.test.ts new file mode 100644 index 00000000..a4e635bd --- /dev/null +++ b/workflow-canvas/stub-api/core/timestamp.test.ts @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: Apache-2.0 +/** Checks the date arithmetic of the core against the platform's `Date`. */ +import { describe, expect, it } from 'vitest'; +import { formatTimestamp, runIdPrefix } from './timestamp.ts'; + +const DAY_MS = 86_400_000; +// Leap days, the end of a century that is not a leap year, and year ends. +const EDGES = [ + '1970-01-01T00:00:00Z', + '2000-02-29T23:59:59Z', + '2024-02-29T12:00:00Z', + '2100-02-28T23:59:59Z', + '2100-03-01T00:00:00Z', + '2026-12-31T23:59:59Z', + '2027-01-01T00:00:00Z', +]; + +describe('formatTimestamp', () => { + it.each(EDGES)('formats %s', (iso) => { + expect(formatTimestamp(Date.parse(iso))).toBe(iso); + }); + + it('agrees with Date over 200 years, a step of an odd length at a time', () => { + const step = 37 * DAY_MS + 13 * 3_600_000 + 7_001; + for (let ms = 0; ms < 200 * 366 * DAY_MS; ms += step) { + const expected = new Date(ms).toISOString().replace(/\.\d{3}Z$/, 'Z'); + expect(formatTimestamp(ms)).toBe(expected); + } + }); + + it('drops the milliseconds', () => { + expect(formatTimestamp(Date.parse('2026-10-24T19:14:28.999Z'))).toBe( + '2026-10-24T19:14:28Z', + ); + }); +}); + +describe('runIdPrefix', () => { + it('is the date and the minute', () => { + expect(runIdPrefix(Date.parse('2026-10-24T04:12:59Z'))).toBe( + '20261024-0412', + ); + }); +}); diff --git a/workflow-canvas/stub-api/core/timestamp.ts b/workflow-canvas/stub-api/core/timestamp.ts new file mode 100644 index 00000000..d55f1b80 --- /dev/null +++ b/workflow-canvas/stub-api/core/timestamp.ts @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: Apache-2.0 + +const SECONDS_PER_DAY = 86_400; + +/** The UTC calendar fields of a time. */ +export interface UtcParts { + year: number; + month: number; + day: number; + hour: number; + minute: number; + second: number; +} + +const pad = (value: number, width: number) => + String(value).padStart(width, '0'); + +/** The UTC fields of milliseconds since the Unix epoch, to the whole second. */ +export function utcParts(ms: number): UtcParts { + const seconds = Math.floor(ms / 1000); + const days = Math.floor(seconds / SECONDS_PER_DAY); + const secondOfDay = seconds - days * SECONDS_PER_DAY; + return { + ...civilDate(days), + hour: Math.floor(secondOfDay / 3600), + minute: Math.floor((secondOfDay % 3600) / 60), + second: secondOfDay % 60, + }; +} + +// The proleptic Gregorian date of a day count from 1970-01-01. +function civilDate(daysSinceEpoch: number) { + const shifted = daysSinceEpoch + 719_468; + const era = Math.floor(shifted / 146_097); + const dayOfEra = shifted - era * 146_097; + const yearOfEra = Math.floor( + (dayOfEra - + Math.floor(dayOfEra / 1460) + + Math.floor(dayOfEra / 36_524) - + Math.floor(dayOfEra / 146_096)) / + 365, + ); + const dayOfYear = + dayOfEra - + (365 * yearOfEra + Math.floor(yearOfEra / 4) - Math.floor(yearOfEra / 100)); + const monthFromMarch = Math.floor((5 * dayOfYear + 2) / 153); + const month = monthFromMarch < 10 ? monthFromMarch + 3 : monthFromMarch - 9; + return { + year: yearOfEra + era * 400 + (month <= 2 ? 1 : 0), + month, + day: dayOfYear - Math.floor((153 * monthFromMarch + 2) / 5) + 1, + }; +} + +/** A time as `YYYY-MM-DDTHH:MM:SSZ`, in UTC. */ +export function formatTimestamp(ms: number): string { + const t = utcParts(ms); + const date = `${pad(t.year, 4)}-${pad(t.month, 2)}-${pad(t.day, 2)}`; + return `${date}T${pad(t.hour, 2)}:${pad(t.minute, 2)}:${pad(t.second, 2)}Z`; +} + +/** The `YYYYMMDD-HHMM` start of a run id. */ +export function runIdPrefix(ms: number): string { + const t = utcParts(ms); + const date = `${pad(t.year, 4)}${pad(t.month, 2)}${pad(t.day, 2)}`; + return `${date}-${pad(t.hour, 2)}${pad(t.minute, 2)}`; +} diff --git a/workflow-canvas/stub-api/core/validator.test.ts b/workflow-canvas/stub-api/core/validator.test.ts new file mode 100644 index 00000000..ace15ab8 --- /dev/null +++ b/workflow-canvas/stub-api/core/validator.test.ts @@ -0,0 +1,319 @@ +// SPDX-License-Identifier: Apache-2.0 +/** Checks the rules of the workflow check, one problem per case. */ +import { describe, expect, it } from 'vitest'; +import { SAMPLE_FILES, SAMPLE_ROOT } from './sample.ts'; +import { suggestKey } from './suggest.ts'; +import { checkWorkflowText } from './validator.ts'; + +const VALID = SAMPLE_FILES[SAMPLE_ROOT] ?? ''; +const HEAD = 'name: hello\nintent: Say hello\n'; +const STAGES = 'stages:\n - name: write\n agent: k-developer\n'; + +type Where = [code: string, path: string, line: number, column: number]; +type Span = [...Where, endLine: number, endColumn: number]; + +function codesOf(text: string): Where[] { + return checkWorkflowText(text, 'hello').diagnostics.map((d) => [ + d.code, + d.path, + d.line, + d.column, + ]); +} + +function spansOf(text: string): Span[] { + return checkWorkflowText(text, 'hello').diagnostics.map((d) => [ + d.code, + d.path, + d.line, + d.column, + d.end_line, + d.end_column, + ]); +} + +describe('a valid workflow', () => { + it('has no diagnostics and a view with the intent as a mapping', () => { + expect(checkWorkflowText(VALID, 'hello')).toEqual({ + errors: 0, + warnings: 0, + infos: 0, + diagnostics: [], + summary: { valid: true, counts: { stages: 2 } }, + view: { + name: 'hello', + intent: { outcome: 'Add a greeting to the README' }, + stages: [ + { name: 'draft', agent: 'k-developer' }, + { name: 'review', agent: 'k-quality-assurance' }, + ], + }, + }); + }); + + it('may leave out name and may give the intent as a mapping', () => { + const text = `version: '1.1'\nintent:\n outcome: Say hello\n${STAGES}`; + expect(checkWorkflowText(text, 'hello').view).toEqual({ + name: 'hello', + intent: { outcome: 'Say hello' }, + stages: [{ name: 'write', agent: 'k-developer' }], + }); + }); + + it('reads a byte order mark and CRLF line ends', () => { + expect( + codesOf(`\uFEFF${(HEAD + STAGES).replaceAll('\n', '\r\n')}`), + ).toEqual([]); + }); + + it('treats on, yes and no as plain strings', () => { + const text = `${HEAD}stages:\n - name: yes\n agent: no\n`; + expect(codesOf(text)).toEqual([]); + }); +}); + +describe('the whole text', () => { + it.each([ + ['empty', ''], + ['blank lines', '\n\n \n'], + ['only a comment', '# nothing here\n'], + ])('is not a mapping when %s, at line 1 column 1', (_, text) => { + expect(spansOf(text)).toEqual([['NOT_A_MAPPING', '', 1, 1, 1, 1]]); + }); + + it('is not a mapping when it is a scalar or a list', () => { + expect(spansOf('hello\n')).toEqual([['NOT_A_MAPPING', '', 1, 1, 1, 6]]); + expect(codesOf('- a\n- b\n')).toEqual([['NOT_A_MAPPING', '', 1, 1]]); + }); + + it('reports a syntax error where the parser found it, with no width', () => { + const [found, ...rest] = spansOf('name: hello\nintent: [oops\n'); + expect(rest).toEqual([]); + expect(found?.slice(0, 2)).toEqual(['YAML_SYNTAX', '']); + expect(found?.[4]).toBe(found?.[2]); + expect(found?.[5]).toBe(found?.[3]); + }); + + it.each([ + ['an anchor', `${HEAD}x: &a 1\n${STAGES}`, 'x', 3], + ['a * reference', `${HEAD}x: *a\n${STAGES}`, 'x', 3], + ['a tag', `name: hello\nintent: !!str Say hello\n${STAGES}`, 'intent', 2], + ['a merge key', `${HEAD}<<: {a: 1}\n${STAGES}`, '', 3], + ['a key that is a list', `${HEAD}? [a]\n: b\n${STAGES}`, '', 3], + ])('does not allow %s', (_, text, path, line) => { + const [found, ...rest] = codesOf(text); + expect(rest).toEqual([]); + expect(found?.slice(0, 3)).toEqual(['YAML_UNSUPPORTED', path, line]); + }); + + it('reports a second document at its --- line, to the end of its content', () => { + expect(spansOf(`${HEAD + STAGES}---\na: 1\n`)).toEqual([ + ['YAML_UNSUPPORTED', '', 6, 1, 8, 1], + ]); + }); + + it('reports a second document after an empty first one on line 2', () => { + expect(spansOf('---\n---\nintent: x\n')).toEqual([ + ['YAML_UNSUPPORTED', '', 2, 1, 4, 1], + ]); + }); +}); + +describe('keys', () => { + it('reports an unknown key, with a suggestion only when one is near', () => { + const near = checkWorkflowText(`name: hello\nintnt: x\n${STAGES}`, 'hello'); + expect(near.diagnostics.map((d) => [d.code, d.suggestion])).toEqual([ + ['MISSING_KEY', undefined], + ['UNKNOWN_KEY', 'intent'], + ]); + const far = checkWorkflowText(`${HEAD}owner: me\n${STAGES}`, 'hello'); + expect(far.diagnostics[0]).toMatchObject({ code: 'UNKNOWN_KEY' }); + expect(far.diagnostics[0]).not.toHaveProperty('suggestion'); + }); + + it('breaks a tie between suggestions with the first candidate', () => { + expect(suggestKey('xame', ['name', 'came'])).toBe('name'); + expect(suggestKey('xame', ['came', 'name'])).toBe('came'); + expect(suggestKey('abcdef', ['name'])).toBeUndefined(); + }); + + it('suggests a stage key', () => { + const text = `${HEAD}stages:\n - nme: write\n agent: k-developer\n`; + const [unknown] = checkWorkflowText(text, 'hello').diagnostics; + expect(unknown).toMatchObject({ + code: 'UNKNOWN_KEY', + path: 'stages[0].nme', + suggestion: 'name', + }); + }); + + it('reports the later of two equal keys as DUPLICATE_KEY', () => { + const text = `${HEAD}intent: Another\n${STAGES}`; + expect(spansOf(text)).toEqual([['DUPLICATE_KEY', 'intent', 3, 1, 3, 7]]); + }); + + it('reports an unknown key once as unknown and then as duplicates', () => { + const text = `${HEAD}owner: me\nowner: you\nowner: it\n${STAGES}`; + expect(codesOf(text)).toEqual([ + ['UNKNOWN_KEY', 'owner', 3, 1], + ['DUPLICATE_KEY', 'owner', 4, 1], + ['DUPLICATE_KEY', 'owner', 5, 1], + ]); + }); + + it('compares keys by their text, before they are read as numbers', () => { + const text = `${HEAD}1: a\n'1': b\n1.0: c\n0x1: d\ntrue: e\nTrue: f\n${STAGES}`; + expect(codesOf(text)).toEqual([ + ['UNKNOWN_KEY', '1', 3, 1], + ['DUPLICATE_KEY', '1', 4, 1], + ['UNKNOWN_KEY', '1.0', 5, 1], + ['UNKNOWN_KEY', '0x1', 6, 1], + ['UNKNOWN_KEY', 'true', 7, 1], + ['UNKNOWN_KEY', 'True', 8, 1], + ]); + }); + + it('reports a missing key at the mapping that lacks it', () => { + expect(spansOf('name: hello\n')).toEqual([ + ['MISSING_KEY', '', 1, 1, 2, 1], + ['MISSING_KEY', '', 1, 1, 2, 1], + ]); + expect(codesOf(`name: hello\nintent: {}\n${STAGES}`)).toEqual([ + ['MISSING_KEY', 'intent', 2, 9], + ]); + }); + + it('addresses a stage by its name, or by its place if it has none', () => { + const noAgent = `${HEAD}stages:\n - name: write\n`; + expect(codesOf(noAgent)).toEqual([['MISSING_KEY', 'stages.write', 4, 5]]); + const noName = `${HEAD}stages:\n - agent: k-developer\n`; + expect(codesOf(noName)).toEqual([['MISSING_KEY', 'stages[0]', 4, 5]]); + }); +}); + +describe('values', () => { + it.each([ + ['version: 1.1\n' + HEAD + STAGES, 'version', 1], + ['name: 123\nintent: x\n' + STAGES, 'name', 1], + ['name: true\nintent: x\n' + STAGES, 'name', 1], + [HEAD.replace('Say hello', '[a]') + STAGES, 'intent', 2], + [`${HEAD}stages: write\n`, 'stages', 3], + [ + `${HEAD}stages:\n - name: write\n agent: 5\n`, + 'stages.write.agent', + 5, + ], + [ + `${HEAD}stages:\n - name:\n agent: k-developer\n`, + 'stages[0].name', + 4, + ], + ])('is WRONG_TYPE for a value of another kind (%#)', (text, path, line) => { + const found = codesOf(text).find((d) => d[0] === 'WRONG_TYPE'); + expect(found?.slice(1, 3)).toEqual([path, line]); + }); + + it('is NOT_A_MAPPING for a stage that is not a mapping', () => { + expect(codesOf(`${HEAD}stages:\n - write\n`)).toEqual([ + ['NOT_A_MAPPING', 'stages[0]', 4, 5], + ]); + }); + + it('is INVALID_NAME for a string that is not a name', () => { + const text = `${HEAD}stages:\n - name: Write\n agent: k_developer\n`; + expect(codesOf(text)).toEqual([ + ['INVALID_NAME', 'stages[0].name', 4, 11], + ['INVALID_NAME', 'stages[0].agent', 5, 12], + ]); + }); + + it('is RESERVED_NAME for start, end, run and halt', () => { + const text = `${HEAD}stages:\n - name: end\n agent: k-developer\n`; + expect(codesOf(text)).toEqual([['RESERVED_NAME', 'stages[0].name', 4, 11]]); + }); + + it('is DUPLICATE_STAGE for the later of two equal stage names', () => { + const text = `${STAGES} - name: write\n agent: k-developer\n`; + expect(spansOf(HEAD + text)).toEqual([ + ['DUPLICATE_STAGE', 'stages[1].name', 6, 11, 6, 16], + ]); + }); + + it('is NAME_MISMATCH when name is not the name of the file', () => { + expect(codesOf(`name: other\nintent: x\n${STAGES}`)).toEqual([ + ['NAME_MISMATCH', 'name', 1, 7], + ]); + }); + + it.each([ + ["version: '2'\n" + HEAD + STAGES, 'version'], + ["name: hello\nintent: ''\n" + STAGES, 'intent'], + ['name: hello\nintent: |\n one\n two\n' + STAGES, 'intent'], + [`name: hello\nintent: ${'x'.repeat(301)}\n${STAGES}`, 'intent'], + [`name: hello\nintent:\n outcome: ''\n${STAGES}`, 'intent.outcome'], + [`${HEAD}stages: []\n`, 'stages'], + [ + HEAD + + 'stages:\n' + + Array.from( + { length: 41 }, + (_, i) => ` - name: s${i}\n agent: a\n`, + ).join(''), + 'stages', + ], + ])('is INVALID_VALUE for a value out of range (%#)', (text, path) => { + expect(codesOf(text).map((d) => d.slice(0, 2))).toEqual([ + ['INVALID_VALUE', path], + ]); + }); + + it('accepts an intent of 300 characters, counting code points', () => { + const text = `name: hello\nintent: "${'😀'.repeat(300)}"\n${STAGES}`; + expect(codesOf(text)).toEqual([]); + }); +}); + +describe('positions', () => { + it('count code points, not UTF-16 units', () => { + const text = `name: "😀😀"\nintent: x\n${STAGES}`; + expect(spansOf(text)).toEqual([['INVALID_NAME', 'name', 1, 7, 1, 11]]); + }); + + it('count a tab as one column', () => { + const text = `name:\tHello\nintent: x\n${STAGES}`; + expect(codesOf(text)).toEqual([['INVALID_NAME', 'name', 1, 7]]); + }); + + it('end a line at a lone CR or at CRLF as well as at LF', () => { + const lone = `name: hello\rintent: x\rowner: 1\r${STAGES.replaceAll('\n', '\r')}`; + expect(codesOf(lone)).toEqual([['UNKNOWN_KEY', 'owner', 3, 1]]); + const crlf = `name: hello\r\nintent: x\r\nowner: 1\r\n${STAGES}`; + expect(codesOf(crlf)).toEqual([['UNKNOWN_KEY', 'owner', 3, 1]]); + }); +}); + +describe('a key in a message', () => { + function unknownKeyMessage(key: string): string | undefined { + const text = `${HEAD}${key}: 1\n${STAGES}`; + return checkWorkflowText(text, 'hello').diagnostics.find( + (d) => d.code === 'UNKNOWN_KEY', + )?.message; + } + + it('is named when it is a short plain word', () => { + expect(unknownKeyMessage('owner')).toBe('Unknown key `owner`.'); + expect(unknownKeyMessage('max_retries')).toBe('Unknown key `max_retries`.'); + expect(unknownKeyMessage('key2')).toBe('Unknown key `key2`.'); + }); + + it.each([ + ['an access key id', 'AKIAIOSFODNN7EXAMPLE'], + ['a 32-digit hex key', '0123456789abcdef0123456789abcdef'], + ['a token with a prefix', `ghp_${'a'.repeat(36)}`], + ['a word of 12 characters with a digit', 'abcdefgh1234'], + ['a long word', 'a'.repeat(25)], + ])('is not shown for %s', (_, key) => { + const message = unknownKeyMessage(key); + expect(message).toBe('Unknown key (not shown).'); + }); +}); diff --git a/workflow-canvas/stub-api/core/validator.ts b/workflow-canvas/stub-api/core/validator.ts new file mode 100644 index 00000000..b0d01b98 --- /dev/null +++ b/workflow-canvas/stub-api/core/validator.ts @@ -0,0 +1,328 @@ +// SPDX-License-Identifier: Apache-2.0 +import { isMap, isScalar, isSeq, type YAMLMap } from 'yaml'; +import { + codePointLength, + isName, + isUsableStageName, + MAX_INTENT_CHARS, + MAX_STAGES, + RESERVED_STAGE_NAMES, +} from './contract.ts'; +import { Reporter, type Segment } from './diagnostics.ts'; +import type { CheckResult, Stage, WorkflowView } from './model.ts'; +import { SourceMap } from './source-map.ts'; +import { suggestKey } from './suggest.ts'; +import { + entriesOf, + findUnsupported, + documentSpanOf, + parseDocuments, + spanOf, + valueSpanOf, + type MapEntry, +} from './yaml-subset.ts'; + +const TOP_KEYS = ['version', 'name', 'intent', 'stages']; +const INTENT_KEYS = ['outcome']; +const STAGE_KEYS = ['name', 'agent']; +const NAME_RULE = + 'must be lower-case letters, digits and hyphens, start with a letter or digit, and have at most 64 characters'; + +interface Context { + reporter: Reporter; + workflowName: string; +} + +/** + * Checks the text of a workflow and reports every problem it finds. The view + * is `null` when there is an error. + */ +export function checkWorkflowText( + text: string, + workflowName: string, +): CheckResult { + const reporter = new Reporter(`${workflowName}.yml`, new SourceMap(text)); + const view = readWorkflow(text, { reporter, workflowName }); + const diagnostics = reporter.diagnostics(); + const errors = diagnostics.length; + const checkedView = errors === 0 && view ? view : null; + return { + errors, + warnings: 0, + infos: 0, + view: checkedView, + diagnostics, + summary: { + valid: errors === 0, + counts: { stages: checkedView?.stages.length ?? 0 }, + }, + }; +} + +function readWorkflow(text: string, ctx: Context): WorkflowView | undefined { + const { reporter } = ctx; + const docs = parseDocuments(text); + const failure = docs.flatMap((doc) => doc.errors)[0]; + if (failure) { + const at = failure.pos[0]; + const message = `The text is not valid YAML (${failure.code}).`; + reporter.report('YAML_SYNTAX', [], [at, at], message); + return undefined; + } + const [first, ...others] = docs; + const root = first?.contents; + if (isMap(root)) reporter.nameStages(stageNamesOf(root)); + for (const found of findUnsupported(root)) { + reporter.report('YAML_UNSUPPORTED', found.path, found.span, found.message); + } + for (const doc of others) { + const message = 'Only one YAML document is allowed.'; + reporter.report('YAML_UNSUPPORTED', [], documentSpanOf(doc), message); + } + if (reporter.hasErrors()) return undefined; + if (!isMap(root)) { + const message = 'The workflow must be a mapping.'; + reporter.report('NOT_A_MAPPING', [], spanOf(root), message); + return undefined; + } + return readTopLevel(root, ctx); +} + +// The names that address stages in paths: usable, and not used before. +function stageNamesOf(root: YAMLMap): (string | undefined)[] { + const stages = entriesOf(root).find((e) => e.keyText === 'stages')?.value; + if (!isSeq(stages)) return []; + const used = new Set(); + return stages.items.map((item) => { + const name = isMap(item) ? firstString(item, 'name') : undefined; + if (name === undefined || !isUsableStageName(name) || used.has(name)) { + return undefined; + } + used.add(name); + return name; + }); +} + +function firstString(map: YAMLMap, key: string): string | undefined { + const value = entriesOf(map).find((e) => e.keyText === key)?.value; + return isScalar(value) && typeof value.value === 'string' + ? value.value + : undefined; +} + +function readTopLevel(root: YAMLMap, ctx: Context): WorkflowView { + const entries = readEntries(root, [], TOP_KEYS, ctx); + const version = entries.get('version'); + if (version) checkVersion(version, ctx); + const name = readWorkflowName(entries.get('name'), ctx); + const outcome = readIntent(entries.get('intent'), root, ctx); + const stages = readStages(entries.get('stages'), root, ctx); + return { name, intent: { outcome }, stages }; +} + +/** + * The first entry of each known key. A later repeat of any key is a + * DUPLICATE_KEY, and a first key that is not known is an UNKNOWN_KEY. + */ +function readEntries( + map: YAMLMap, + path: Segment[], + known: readonly string[], + ctx: Context, +): Map { + const seen = new Set(); + const entries = new Map(); + for (const entry of entriesOf(map)) { + const keyPath = [...path, entry.keyText]; + const keySpan = spanOf(entry.key); + if (seen.has(entry.keyText)) { + const message = `The key ${quote(entry.keyText)} is already used.`; + ctx.reporter.report('DUPLICATE_KEY', keyPath, keySpan, message); + } else if (known.includes(entry.keyText)) { + entries.set(entry.keyText, entry); + } else { + const message = `Unknown key ${quote(entry.keyText)}.`; + const suggestion = suggestKey(entry.keyText, known); + ctx.reporter.report('UNKNOWN_KEY', keyPath, keySpan, message, suggestion); + } + seen.add(entry.keyText); + } + return entries; +} + +// Names a key in a message only if it is short and plain. A longer key, or a +// key of 12 or more characters with a digit, may be an access key, a token or +// a hash, and is never echoed. +function quote(text: string): string { + const plain = /^[\w.~-]{1,24}$/.test(text); + const looksSecret = text.length >= 12 && /\d/.test(text); + return plain && !looksSecret ? `\`${text}\`` : '(not shown)'; +} + +function reportMissing( + map: YAMLMap, + path: Segment[], + key: string, + ctx: Context, +): void { + const message = `The mapping needs the key \`${key}\`.`; + ctx.reporter.report('MISSING_KEY', path, spanOf(map), message); +} + +/** The string value of an entry, or `undefined` after a WRONG_TYPE. */ +function readString( + entry: MapEntry, + path: Segment[], + ctx: Context, +): string | undefined { + const { value } = entry; + if (isScalar(value) && typeof value.value === 'string') return value.value; + const message = `\`${entry.keyText}\` must be a string.`; + ctx.reporter.report('WRONG_TYPE', path, valueSpanOf(entry), message); + return undefined; +} + +/** A string that matches the name pattern, or `undefined` after a report. */ +function readName( + entry: MapEntry, + path: Segment[], + ctx: Context, +): string | undefined { + const text = readString(entry, path, ctx); + if (text === undefined || isName(text)) return text; + const message = `\`${entry.keyText}\` ${NAME_RULE}.`; + ctx.reporter.report('INVALID_NAME', path, valueSpanOf(entry), message); + return undefined; +} + +function checkVersion(entry: MapEntry, ctx: Context): void { + const text = readString(entry, ['version'], ctx); + if (text === undefined || text === '1.1') return; + const message = "`version` must be the string '1.1'."; + ctx.reporter.report( + 'INVALID_VALUE', + ['version'], + valueSpanOf(entry), + message, + ); +} + +function readWorkflowName(entry: MapEntry | undefined, ctx: Context): string { + if (!entry) return ctx.workflowName; + const name = readName(entry, ['name'], ctx); + if (name !== undefined && name !== ctx.workflowName) { + const message = `\`name\` must be \`${ctx.workflowName}\`, the name of the file.`; + ctx.reporter.report('NAME_MISMATCH', ['name'], valueSpanOf(entry), message); + } + return name ?? ctx.workflowName; +} + +// The intent is one line of text, or a mapping that holds it as `outcome`. +function readIntent( + entry: MapEntry | undefined, + root: YAMLMap, + ctx: Context, +): string { + if (!entry) { + reportMissing(root, [], 'intent', ctx); + return ''; + } + if (!isMap(entry.value)) return readOutcome(entry, ['intent'], ctx); + const entries = readEntries(entry.value, ['intent'], INTENT_KEYS, ctx); + const outcome = entries.get('outcome'); + if (outcome) return readOutcome(outcome, ['intent', 'outcome'], ctx); + reportMissing(entry.value, ['intent'], 'outcome', ctx); + return ''; +} + +function readOutcome(entry: MapEntry, path: Segment[], ctx: Context): string { + const text = readString(entry, path, ctx); + if (text === undefined) return ''; + const problem = outcomeProblem(text); + if (problem) { + const message = `\`${entry.keyText}\` ${problem}.`; + ctx.reporter.report('INVALID_VALUE', path, valueSpanOf(entry), message); + } + return text; +} + +function outcomeProblem(text: string): string | undefined { + if (text === '') return 'must not be empty'; + if (/[\r\n]/.test(text)) return 'must be one line'; + if (codePointLength(text) > MAX_INTENT_CHARS) { + return `must have at most ${MAX_INTENT_CHARS} characters`; + } + return undefined; +} + +function readStages( + entry: MapEntry | undefined, + root: YAMLMap, + ctx: Context, +): Stage[] { + if (!entry) { + reportMissing(root, [], 'stages', ctx); + return []; + } + const list = entry.value; + if (!isSeq(list)) { + const message = '`stages` must be a list.'; + ctx.reporter.report('WRONG_TYPE', ['stages'], valueSpanOf(entry), message); + return []; + } + if (list.items.length < 1 || list.items.length > MAX_STAGES) { + const message = `\`stages\` must have 1 to ${MAX_STAGES} entries.`; + ctx.reporter.report('INVALID_VALUE', ['stages'], spanOf(list), message); + } + const used = new Set(); + return list.items.flatMap((item, index) => { + const stage = readStage(item, index, used, ctx); + return stage ? [stage] : []; + }); +} + +function readStage( + item: unknown, + index: number, + used: Set, + ctx: Context, +): Stage | undefined { + const path = ['stages', index]; + if (!isMap(item)) { + const message = 'A stage must be a mapping.'; + ctx.reporter.report('NOT_A_MAPPING', path, spanOf(item), message); + return undefined; + } + const entries = readEntries(item, path, STAGE_KEYS, ctx); + const nameEntry = entries.get('name'); + const agentEntry = entries.get('agent'); + if (!nameEntry) reportMissing(item, path, 'name', ctx); + if (!agentEntry) reportMissing(item, path, 'agent', ctx); + const name = nameEntry && readStageName(nameEntry, path, used, ctx); + const agent = agentEntry && readName(agentEntry, [...path, 'agent'], ctx); + return name && agent ? { name, agent } : undefined; +} + +function readStageName( + entry: MapEntry, + path: Segment[], + used: Set, + ctx: Context, +): string | undefined { + const namePath = [...path, 'name']; + const name = readName(entry, namePath, ctx); + if (name === undefined) return undefined; + const span = valueSpanOf(entry); + if (RESERVED_STAGE_NAMES.has(name)) { + const message = `The stage name \`${name}\` is reserved.`; + ctx.reporter.report('RESERVED_NAME', namePath, span, message); + return undefined; + } + if (used.has(name)) { + const message = `The stage name \`${name}\` is already used.`; + ctx.reporter.report('DUPLICATE_STAGE', namePath, span, message); + return undefined; + } + used.add(name); + return name; +} diff --git a/workflow-canvas/stub-api/core/workflow-api.ts b/workflow-canvas/stub-api/core/workflow-api.ts new file mode 100644 index 00000000..82498b38 --- /dev/null +++ b/workflow-canvas/stub-api/core/workflow-api.ts @@ -0,0 +1,149 @@ +// SPDX-License-Identifier: Apache-2.0 +import { + HASH_PATTERN, + isName, + MAX_TEXT_BYTES, + RUN_ID_PATTERN, +} from './contract.ts'; +import { ApiError, badName } from './errors.ts'; +import type { + CheckResult, + Run, + SaveResult, + WorkflowDocument, +} from './model.ts'; +import type { RunSimulator } from './runs.ts'; +import type { WorkflowStore } from './store.ts'; +import { checkWorkflowText } from './validator.ts'; + +export interface WorkflowApiOptions { + /** The one workflow that can be loaded, saved, checked and run. */ + root: string; + store: WorkflowStore; + runs: RunSimulator; +} + +/** + * The operations of the contract, free of HTTP. Each makes its checks in the + * contract's order and throws an `ApiError` for the first one that fails. + */ +export class WorkflowApi { + readonly root: string; + private readonly store: WorkflowStore; + private readonly runs: RunSimulator; + + constructor(options: WorkflowApiOptions) { + this.root = options.root; + this.store = options.store; + this.runs = options.runs; + } + + loadWorkflow(name: string | undefined): WorkflowDocument { + const target = this.nameOrRoot(name); + if (target !== this.root) { + throw new ApiError('not_found', 'There is no workflow with this name.'); + } + const file = this.store.read(target); + if (!file) return emptyDocument(target); + const check = checkWorkflowText(file.text, target); + return { + name: target, + text: file.text, + hash: file.hash, + read_only: false, + view: check.view, + diagnostics: check.diagnostics, + }; + } + + saveWorkflow(request: { + name: string | undefined; + text: string; + base_hash: string | null; + }): SaveResult { + const target = this.writableName(request.name); + if (request.base_hash !== null && !HASH_PATTERN.test(request.base_hash)) { + throw new ApiError( + 'bad_hash', + 'base_hash must be 64 hex digits or null.', + ); + } + const check = checkText(request.text, target); + if (!check.view) throw invalidWorkflow(check); + const hash = this.store.save(target, request.text, request.base_hash); + return { hash, view: check.view, diagnostics: check.diagnostics }; + } + + checkWorkflow(request: { + name: string | undefined; + text: string; + }): CheckResult { + return checkText(request.text, this.writableName(request.name)); + } + + /** Starts a run of the saved root workflow and returns its id. */ + startRun(): { run_id: string } { + const file = this.store.read(this.root); + if (!file) { + throw new ApiError('not_found', 'The workflow has not been saved yet.'); + } + const check = checkWorkflowText(file.text, this.root); + if (!check.view) throw invalidWorkflow(check); + const active = this.runs.activeRunId(); + if (active) { + throw new ApiError('already_running', 'A run is already active.', { + run_id: active, + }); + } + const stageNames = check.view.stages.map((stage) => stage.name); + return { run_id: this.runs.start(this.root, stageNames) }; + } + + getRun(runId: string): Run { + const run = RUN_ID_PATTERN.test(runId) ? this.runs.get(runId) : undefined; + if (!run) throw new ApiError('not_found', 'There is no run with this id.'); + return run; + } + + // A missing name is the root; a malformed one is refused. + private nameOrRoot(name: string | undefined): string { + if (name === undefined) return this.root; + if (!isName(name)) { + throw badName(); + } + return name; + } + + private writableName(name: string | undefined): string { + const target = this.nameOrRoot(name); + if (target !== this.root) { + throw new ApiError('read_only_workflow', 'This workflow is read-only.'); + } + return target; + } +} + +function emptyDocument(name: string): WorkflowDocument { + return { + name, + text: null, + hash: null, + read_only: false, + view: null, + diagnostics: [], + }; +} + +// The size limit comes before the check, as the contract orders them. +function checkText(text: string, name: string): CheckResult { + if (new TextEncoder().encode(text).length > MAX_TEXT_BYTES) { + throw new ApiError('too_large', 'The workflow text is too large.'); + } + return checkWorkflowText(text, name); +} + +function invalidWorkflow(check: CheckResult): ApiError { + return new ApiError('invalid_workflow', 'The workflow has errors.', { + diagnostics: check.diagnostics, + }); +} diff --git a/workflow-canvas/stub-api/core/yaml-subset.ts b/workflow-canvas/stub-api/core/yaml-subset.ts new file mode 100644 index 00000000..c63739ea --- /dev/null +++ b/workflow-canvas/stub-api/core/yaml-subset.ts @@ -0,0 +1,128 @@ +// SPDX-License-Identifier: Apache-2.0 +import { + isAlias, + isMap, + isNode, + isScalar, + isSeq, + parseAllDocuments, + Scalar, + type Document, + type YAMLMap, +} from 'yaml'; +import type { Segment, Span } from './diagnostics.ts'; + +/** A mapping entry whose key is a scalar, with the key as plain text. */ +export interface MapEntry { + keyText: string; + key: Scalar; + value: unknown; +} + +/** Something the plain YAML subset of the contract does not allow. */ +export interface Unsupported { + path: Segment[]; + span: Span; + message: string; +} + +// YAML 1.2 with the core schema; a repeated key is the check's to report. +const PARSE_OPTIONS = { + version: '1.2', + schema: 'core', + uniqueKeys: false, + merge: false, +} as const; + +/** + * Parses every document of the text. A lone CR is swapped for LF, which keeps + * every offset, because only the contract treats it as a line break. + */ +export function parseDocuments(text: string): Document.Parsed[] { + return Array.from( + parseAllDocuments(text.replace(/\r(?!\n)/g, '\n'), PARSE_OPTIONS), + ); +} + +/** The start and end offsets of a node, or the fallback if it has none. */ +export function spanOf(node: unknown, fallback: Span = [0, 0]): Span { + const range = isNode(node) ? node.range : undefined; + return range ? [range[0], range[1]] : fallback; +} + +/** The span of a document: from its `---` line to the end of its content. */ +export function documentSpanOf(doc: Document.Parsed): Span { + return [doc.range[0], doc.range[1]]; +} + +/** The span of an entry's value, or of its key when the value has no text. */ +export function valueSpanOf(entry: MapEntry): Span { + const span = spanOf(entry.value); + return span[1] > span[0] ? span : spanOf(entry.key); +} + +/** + * The text of a key before it is read as a number, boolean or null, so `1` + * and `'1'` are one key while `1` and `1.0` are two. + */ +export function keyTextOf(key: Scalar): string { + return typeof key.value === 'string' ? key.value : (key.source ?? ''); +} + +/** The entries of a mapping that have a scalar key, in text order. */ +export function entriesOf(map: YAMLMap): MapEntry[] { + return map.items.flatMap((pair) => + isScalar(pair.key) + ? [{ keyText: keyTextOf(pair.key), key: pair.key, value: pair.value }] + : [], + ); +} + +/** Finds every anchor, `*` reference, tag, merge key and non-scalar key in a node. */ +export function findUnsupported( + node: unknown, + path: Segment[] = [], +): Unsupported[] { + if (isAlias(node)) + return [unsupported(path, node, 'A `*` reference is not allowed.')]; + if (!isNode(node)) return []; + const found = marksOf(node, path); + if (isSeq(node)) { + node.items.forEach((item, index) => + found.push(...findUnsupported(item, [...path, index])), + ); + } else if (isMap(node)) { + for (const pair of node.items) { + found.push(...findInPair(pair.key, pair.value, path)); + } + } + return found; +} + +function findInPair(key: unknown, value: unknown, path: Segment[]) { + if (!isScalar(key)) { + return [unsupported(path, key, 'A key must be a plain value.')]; + } + const keyText = keyTextOf(key); + const found = marksOf(key, path); + if (keyText === '<<' && key.type === Scalar.PLAIN) { + found.push(unsupported(path, key, 'A merge key is not allowed.')); + } + return [...found, ...findUnsupported(value, [...path, keyText])]; +} + +// An anchor or an explicit tag on a node. +function marksOf(node: { anchor?: string; tag?: string }, path: Segment[]) { + const found: Unsupported[] = []; + if (node.anchor !== undefined) { + found.push(unsupported(path, node, 'An anchor is not allowed.')); + } + if (node.tag !== undefined) { + found.push(unsupported(path, node, 'A tag is not allowed.')); + } + return found; +} + +function unsupported(path: Segment[], node: unknown, message: string) { + return { path, span: spanOf(node), message }; +} diff --git a/workflow-canvas/stub-api/dev-proxy.test.ts b/workflow-canvas/stub-api/dev-proxy.test.ts new file mode 100644 index 00000000..347098ed --- /dev/null +++ b/workflow-canvas/stub-api/dev-proxy.test.ts @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: Apache-2.0 +import { describe, expect, it } from 'vitest'; +import { isDevPageOrigin } from './dev-proxy.ts'; + +describe('isDevPageOrigin', () => { + it.each([ + ['http://127.0.0.1:5173', 5173], + ['http://localhost:5173', 5173], + ['http://127.0.0.1:4173', 4173], + ])('accepts %s on port %d', (origin, port) => { + expect(isDevPageOrigin(origin, port)).toBe(true); + }); + + it.each([ + ['another port', 'http://localhost:3000', 5173], + ['another host', 'http://evil.example:5173', 5173], + ['https', 'https://localhost:5173', 5173], + ['a suffix of the host', 'http://localhost:5173.evil.example', 5173], + ['no Origin', undefined, 5173], + ['an unknown local port', 'http://localhost:5173', undefined], + ])('rejects %s', (_name, origin, port) => { + expect(isDevPageOrigin(origin, port)).toBe(false); + }); +}); diff --git a/workflow-canvas/stub-api/dev-proxy.ts b/workflow-canvas/stub-api/dev-proxy.ts new file mode 100644 index 00000000..e3a91376 --- /dev/null +++ b/workflow-canvas/stub-api/dev-proxy.ts @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: Apache-2.0 +import type { ProxyOptions } from 'vite'; +import { LOOPBACK } from './address.ts'; + +/** + * Whether `origin` is the page of this dev or preview server: a loopback name + * on the port the server listens on. `port` is the local port of the request. + */ +export function isDevPageOrigin( + origin: string | undefined, + port: number | undefined, +): boolean { + if (origin === undefined || port === undefined) return false; + return [LOOPBACK, 'localhost'].some( + (host) => new URL(`http://${host}:${port}`).origin === origin, + ); +} + +/** + * The proxy of `/api` to `apiOrigin`. The API accepts only its own `Origin`, + * so the page's own `Origin` becomes that one. Any other `Origin`, or none, goes + * through unchanged and the API refuses it, so a page on another origin cannot + * use this server to reach the API. + */ +export function apiProxy(apiOrigin: string): Record { + return { + '/api': { + target: apiOrigin, + changeOrigin: true, + configure: (proxy) => { + proxy.on('proxyReq', (proxyReq, req) => { + if (isDevPageOrigin(req.headers.origin, req.socket.localPort)) { + proxyReq.setHeader('Origin', apiOrigin); + } + }); + }, + }, + }; +} diff --git a/workflow-canvas/stub-api/dev.test.ts b/workflow-canvas/stub-api/dev.test.ts new file mode 100644 index 00000000..5ec37465 --- /dev/null +++ b/workflow-canvas/stub-api/dev.test.ts @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: Apache-2.0 +import { existsSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { devCommands, startDev } from './dev.ts'; +import { apiTarget } from './target.ts'; +import { environmentOf, type Together } from './together.ts'; + +const [stub, vite] = devCommands(); +const entry = (command: typeof stub) => command?.args.at(-1) ?? ''; + +describe('the dev:stub commands', () => { + it('start the stub and Vite with the same node', () => { + expect(devCommands()).toHaveLength(2); + expect(stub?.file).toBe(process.execPath); + expect(vite?.file).toBe(process.execPath); + }); + + it('point at files that exist', () => { + expect( + existsSync(fileURLToPath(new URL('./main.ts', import.meta.url))), + ).toBe(true); + expect(existsSync(entry(vite))).toBe(true); + expect(entry(vite)).toMatch(/vite[\\\\/]bin[\\\\/]vite\.js$/); + }); + + it('tell the stub that the dev server runs beside it', () => { + expect(stub?.args.at(-1)).toBe('--with-dev-server'); + expect(stub?.args.at(-2)).toMatch(/main\.ts$/); + }); + + it('give Vite no WC_API_TARGET, whatever the shell has', () => { + const shell = { WC_API_TARGET: 'http://127.0.0.1:8080', KEEP: 'yes' }; + + const env = environmentOf(vite!, shell); + + expect(apiTarget(env).isStub).toBe(true); + expect(env.KEEP).toBe('yes'); + }); + + it('leave the environment of the stub alone', () => { + expect(stub?.env).toBeUndefined(); + }); +}); + +describe('startDev', () => { + const before = { + SIGINT: process.listeners('SIGINT'), + SIGTERM: process.listeners('SIGTERM'), + }; + + /** Removes the signal listeners that `startDev` added, and only those. */ + afterEach(() => { + for (const signal of ['SIGINT', 'SIGTERM'] as const) { + for (const listener of process.listeners(signal)) { + if (!before[signal].includes(listener)) process.off(signal, listener); + } + } + }); + + it('runs the dev commands and stops them on SIGINT and SIGTERM', () => { + const together: Together = { stop: vi.fn(), done: Promise.resolve(0) }; + const run = vi.fn(() => together); + + expect(startDev(run)).toBe(together); + expect(run).toHaveBeenCalledWith(devCommands()); + + process.emit('SIGINT'); + process.emit('SIGTERM'); + + expect(together.stop).toHaveBeenCalledTimes(2); + }); +}); diff --git a/workflow-canvas/stub-api/dev.ts b/workflow-canvas/stub-api/dev.ts new file mode 100644 index 00000000..47919ee3 --- /dev/null +++ b/workflow-canvas/stub-api/dev.ts @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: Apache-2.0 +/** + * `npm run dev:stub`. Starts the stub and the Vite dev server together. Ctrl+C + * stops both. Vite always proxies `/api` to the stub here, even when + * `WC_API_TARGET` is set in the shell; use `npm run dev` for a real server. + */ +import { fileURLToPath } from 'node:url'; +import { TARGET_VARIABLE } from './target.ts'; +import { runTogether, type Command, type Together } from './together.ts'; + +const stubMain = fileURLToPath(new URL('./main.ts', import.meta.url)); +const viteBin = fileURLToPath(import.meta.resolve('vite/package.json')).replace( + /package\.json$/, + 'bin/vite.js', +); + +/** + * The two commands of `dev:stub`. Vite gets an empty `WC_API_TARGET`, so it + * proxies to the stub even when the shell names another server. + */ +export function devCommands(): Command[] { + return [ + { + // The flag only changes the hint that `main.ts` prints. + file: process.execPath, + args: [stubMain, '--with-dev-server'], + }, + { + file: process.execPath, + args: [viteBin], + env: { [TARGET_VARIABLE]: '' }, + }, + ]; +} + +/** Starts both commands, and stops them on SIGINT or SIGTERM. */ +export function startDev(run: typeof runTogether = runTogether): Together { + const together = run(devCommands()); + for (const signal of ['SIGINT', 'SIGTERM'] as const) { + process.once(signal, together.stop); + } + return together; +} + +if (import.meta.main) { + process.exitCode = await startDev().done; +} diff --git a/workflow-canvas/stub-api/http/body.test.ts b/workflow-canvas/stub-api/http/body.test.ts new file mode 100644 index 00000000..918f39e6 --- /dev/null +++ b/workflow-canvas/stub-api/http/body.test.ts @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: Apache-2.0 +/** Checks how a request body is read, without a server. */ +import { Readable } from 'node:stream'; +import { describe, expect, it } from 'vitest'; +import { MAX_BODY_BYTES, MAX_DRAIN_BYTES, readJsonBody } from './body.ts'; + +/** A body of `chunks` chunks of `size` bytes, made as the reader asks for them. */ +function bodyOf(chunks: number, size: number) { + let made = 0; + const stream = Readable.from( + (function* () { + while (made < chunks) { + made++; + yield Buffer.alloc(size, 'a'); + } + })(), + ); + return { stream, made: () => made }; +} + +describe('readJsonBody', () => { + it('reads JSON', async () => { + const body = Readable.from([Buffer.from('{"a":1}')]); + await expect(readJsonBody(body)).resolves.toEqual({ a: 1 }); + }); + + it('reads on past the limit, to the end, and then says too large', async () => { + const { stream, made } = bodyOf(MAX_BODY_BYTES / 1024 + 1, 1024); + await expect(readJsonBody(stream)).rejects.toMatchObject({ + code: 'too_large', + }); + expect(made()).toBe(MAX_BODY_BYTES / 1024 + 1); + }); + + it('cuts off a body that goes on past the drain limit', async () => { + const chunk = 1024 * 1024; + const { stream, made } = bodyOf(10 * (MAX_DRAIN_BYTES / chunk), chunk); + await expect(readJsonBody(stream)).rejects.toMatchObject({ + code: 'too_large', + }); + expect(made()).toBeLessThan(MAX_DRAIN_BYTES / chunk + 4); + expect(stream.destroyed).toBe(true); + }); +}); diff --git a/workflow-canvas/stub-api/http/body.ts b/workflow-canvas/stub-api/http/body.ts new file mode 100644 index 00000000..0045dad2 --- /dev/null +++ b/workflow-canvas/stub-api/http/body.ts @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: Apache-2.0 +import type { Readable } from 'node:stream'; +import { ApiError, badRequest } from '../core/errors.ts'; + +/** The largest request body, in bytes. */ +export const MAX_BODY_BYTES = 1_048_576; +/** The most of a body that is read, though not kept, to answer a 413. */ +export const MAX_DRAIN_BYTES = 16 * MAX_BODY_BYTES; + +const utf8 = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }); + +const tooLarge = () => + new ApiError('too_large', 'The request body is too large.'); + +/** + * Reads the body as UTF-8 JSON. A body over the limit is read on, not kept, so + * the client gets its 413. Past the drain limit the request is cut off. + */ +export async function readJsonBody(req: Readable): Promise { + const chunks: Buffer[] = []; + let size = 0; + for await (const chunk of req) { + size += chunk.length; + if (size > MAX_DRAIN_BYTES) { + req.destroy(); + throw tooLarge(); + } + if (size <= MAX_BODY_BYTES) chunks.push(chunk); + } + if (size > MAX_BODY_BYTES) throw tooLarge(); + try { + return JSON.parse(utf8.decode(Buffer.concat(chunks))); + } catch { + throw badRequest(); + } +} diff --git a/workflow-canvas/stub-api/http/dev-proxy.test.ts b/workflow-canvas/stub-api/http/dev-proxy.test.ts new file mode 100644 index 00000000..0eb060ee --- /dev/null +++ b/workflow-canvas/stub-api/http/dev-proxy.test.ts @@ -0,0 +1,200 @@ +// SPDX-License-Identifier: Apache-2.0 +/** Checks where `npm run dev` sends `/api`, and which `Origin` it sends. */ +import { + createServer as createHttpServer, + type IncomingMessage, + type Server, +} from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { createServer, resolveConfig, type ViteDevServer } from 'vite'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { LOOPBACK, originOf } from '../address.ts'; +import { LAUNCH_CODE, openStub } from '../test-support/harness.ts'; + +const CONFIG = new URL('../../vite.config.ts', import.meta.url).pathname; +const VARIABLES = ['STUB_PORT', 'WC_API_TARGET'] as const; + +/** A server that records each request it gets and answers 204. */ +async function openRecorder() { + const seen: IncomingMessage[] = []; + const server: Server = createHttpServer((req, res) => { + seen.push(req); + res.statusCode = 204; + res.end(); + }); + await new Promise((resolve) => server.listen(0, LOOPBACK, resolve)); + const port = (server.address() as AddressInfo).port; + const close = () => + new Promise((resolve) => server.close(() => resolve())); + return { seen, origin: originOf(port), close }; +} + +describe('the Vite dev server', () => { + let dev: ViteDevServer | undefined; + const cleanups: Array<() => Promise> = []; + const saved = Object.fromEntries(VARIABLES.map((v) => [v, process.env[v]])); + + /** Sets or clears the variables that choose the API target. */ + function useEnv(env: Partial>) { + for (const name of VARIABLES) { + if (env[name] === undefined) delete process.env[name]; + else process.env[name] = env[name]; + } + } + + /** Starts Vite with the config of the project and returns its origin. */ + async function startDev(): Promise { + dev = await createServer({ + configFile: CONFIG, + logLevel: 'silent', + server: { host: LOOPBACK, port: 0, strictPort: false }, + }); + await dev.listen(); + return `http://${LOOPBACK}:${dev.config.server.port}`; + } + + afterEach(async () => { + vi.restoreAllMocks(); + await dev?.close(); + dev = undefined; + for (const close of cleanups.splice(0)) await close(); + for (const name of VARIABLES) { + const value = saved[name]; + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } + }); + + it('forwards /api to the stub by default, which accepts the dev origin', async () => { + const running = await openStub(); + cleanups.push(running.stub.close); + useEnv({ STUB_PORT: String(running.stub.port) }); + const devOrigin = await startDev(); + const headers = { 'Content-Type': 'application/json', Origin: devOrigin }; + + const session = await fetch(`${devOrigin}/api/session`, { + method: 'POST', + headers, + body: JSON.stringify({ code: LAUNCH_CODE }), + }); + expect(session.status).toBe(200); + const { token } = (await session.json()) as { token: string }; + + const workflow = await fetch(`${devOrigin}/api/workflow`, { + headers: { Authorization: `Bearer ${token}` }, + }); + expect(workflow.status).toBe(200); + expect(await workflow.json()).toMatchObject({ name: 'hello' }); + }); + + it('forwards /api to WC_API_TARGET and sends that origin, not the stub', async () => { + const stub = await openStub(); + const target = await openRecorder(); + cleanups.push(stub.stub.close, target.close); + useEnv({ + STUB_PORT: String(stub.stub.port), + WC_API_TARGET: target.origin, + }); + const devOrigin = await startDev(); + + const reply = await fetch(`${devOrigin}/api/workflow?probe=1`, { + headers: { Origin: devOrigin }, + }); + + expect(reply.status).toBe(204); + expect(target.seen).toHaveLength(1); + expect(target.seen[0]?.url).toBe('/api/workflow?probe=1'); + expect(target.seen[0]?.headers.origin).toBe(target.origin); + expect(target.origin).not.toBe(stub.stub.origin); + }); + + it('leaves a foreign or missing Origin as it is, so the target can refuse it', async () => { + const target = await openRecorder(); + cleanups.push(target.close); + useEnv({ WC_API_TARGET: target.origin }); + const devOrigin = await startDev(); + + await fetch(`${devOrigin}/api/run`, { + method: 'POST', + headers: { Origin: 'http://127.0.0.1:9' }, + }); + await fetch(`${devOrigin}/api/run`, { method: 'POST' }); + + expect(target.seen.map((r) => r.headers.origin)).toEqual([ + 'http://127.0.0.1:9', + undefined, + ]); + }); + + it.each([ + ['a foreign Origin', { Origin: 'http://127.0.0.1:9' }], + ['no Origin', {}], + ])('refuses a POST with %s through the proxy', async (_name, extra) => { + const running = await openStub(); + cleanups.push(running.stub.close); + useEnv({ STUB_PORT: String(running.stub.port) }); + const devOrigin = await startDev(); + + const reply = await fetch(`${devOrigin}/api/session`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...extra }, + body: JSON.stringify({ code: LAUNCH_CODE }), + }); + + expect(reply.status).toBe(403); + }); + + it('moves with the target: a second target gets its own origin', async () => { + const first = await openRecorder(); + const second = await openRecorder(); + cleanups.push(first.close, second.close); + + useEnv({ WC_API_TARGET: first.origin }); + const firstDev = await startDev(); + await fetch(`${firstDev}/api/workflow`, { headers: { Origin: firstDev } }); + await dev?.close(); + + useEnv({ WC_API_TARGET: second.origin }); + const secondDev = await startDev(); + await fetch(`${secondDev}/api/workflow`, { + headers: { Origin: secondDev }, + }); + + expect(first.seen.map((r) => r.headers.origin)).toEqual([first.origin]); + expect(second.seen.map((r) => r.headers.origin)).toEqual([second.origin]); + }); + + it('refuses to start for a bad WC_API_TARGET, with one clear line', async () => { + useEnv({ WC_API_TARGET: 'not-a-target' }); + vi.spyOn(process, 'exit').mockImplementation((() => { + throw new Error('exit called'); + }) as never); + const print = vi.spyOn(console, 'error').mockImplementation(() => {}); + + await expect(startDev()).rejects.toThrow('exit called'); + + expect(print).toHaveBeenCalledWith( + expect.stringContaining('WC_API_TARGET="not-a-target" is not usable'), + ); + }); + + it.each([ + ['build', 'build', 'production'], + ['the test runner', 'serve', 'test'], + ] as const)( + 'ignores a bad WC_API_TARGET for %s, which has no proxy', + async (_name, command, mode) => { + useEnv({ WC_API_TARGET: 'not-a-target' }); + const exit = vi.spyOn(process, 'exit'); + + const config = await resolveConfig( + { configFile: CONFIG, logLevel: 'silent' }, + command, + mode, + ); + + expect(exit).not.toHaveBeenCalled(); + expect(config.server.proxy).toBeUndefined(); + }, + ); +}); diff --git a/workflow-canvas/stub-api/http/dispatch.ts b/workflow-canvas/stub-api/http/dispatch.ts new file mode 100644 index 00000000..ce7a7081 --- /dev/null +++ b/workflow-canvas/stub-api/http/dispatch.ts @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: Apache-2.0 +import type { IncomingMessage } from 'node:http'; +import { ApiError } from '../core/errors.ts'; +import { readJsonBody } from './body.ts'; +import { + requireJsonContentType, + requireLocalHost, + requireOwnOrigin, +} from './guards.ts'; +import { errorReply } from './respond.ts'; +import type { Reply, Route } from './routes.ts'; +import type { SessionGate } from './session.ts'; + +export interface Dispatcher { + routes: Route[]; + session: SessionGate; + /** The port the server listens on, which a `Host` must name. */ + port(): number; +} + +/** + * Answers a request. The checks come in the order that the contract fixes: + * `Host`, path and method, `Origin`, the token, then the body. + */ +export async function dispatch( + req: IncomingMessage, + { routes, session, port }: Dispatcher, +): Promise { + const host = requireLocalHost(req, port()); + const method = req.method ?? 'GET'; + const { path, query } = splitTarget(req.url ?? '/'); + const match = matchRoute(routes, path); + if (!match) throw new ApiError('not_found', 'There is no such path.'); + const operation = match.route.operations[method]; + if (!operation) return methodNotAllowed(match.route); + requireOwnOrigin(req, host, method); + if (operation.secured) session.authorize(req.headers.authorization); + const body = method === 'GET' ? undefined : await readBody(req); + return operation.handle({ query, params: match.params, body }); +} + +async function readBody(req: IncomingMessage): Promise { + requireJsonContentType(req); + return readJsonBody(req); +} + +// The request target is a path and a query; the fragment never arrives. +function splitTarget(target: string) { + const at = target.indexOf('?'); + return { + path: at < 0 ? target : target.slice(0, at), + query: new URLSearchParams(at < 0 ? '' : target.slice(at + 1)), + }; +} + +function matchRoute(routes: Route[], path: string) { + for (const route of routes) { + const found = route.pattern.exec(path); + if (found) return { route, params: found.slice(1) }; + } + return undefined; +} + +function methodNotAllowed(route: Route): Reply { + const error = new ApiError( + 'method_not_allowed', + 'This method is not allowed.', + ); + return { + ...errorReply(error), + headers: { Allow: Object.keys(route.operations).join(', ') }, + }; +} diff --git a/workflow-canvas/stub-api/http/guards.test.ts b/workflow-canvas/stub-api/http/guards.test.ts new file mode 100644 index 00000000..31757507 --- /dev/null +++ b/workflow-canvas/stub-api/http/guards.test.ts @@ -0,0 +1,279 @@ +// SPDX-License-Identifier: Apache-2.0 +/** Checks the rules that apply to every call: headers, bodies and routing. */ +import { describe, expect, it } from 'vitest'; +import { expectError } from '../test-support/contract.ts'; +import { LAUNCH_CODE, useStub } from '../test-support/harness.ts'; +import { MAX_BODY_BYTES } from './body.ts'; + +const WORKFLOW = '/api/workflow'; +const VALIDATE = '/api/validate'; + +describe('the Host header', () => { + const stub = useStub(); + + it.each([ + ['another name', () => 'example.test'], + ['the right name and another port', () => '127.0.0.1:1'], + ['no port', () => '127.0.0.1'], + ])('is refused with %s', async (_, host) => { + const reply = await stub().client.get(WORKFLOW, { + headers: { host: host() }, + }); + expectError(reply, 403, 'forbidden_host'); + }); + + it('may be localhost with the right port', async () => { + const { client, stub: running } = stub(); + const reply = await client.get(WORKFLOW, { + headers: { host: `localhost:${running.port}` }, + }); + expect(reply.status).toBe(200); + }); + + it('is checked before the token', async () => { + const { client } = stub(); + client.token = undefined; + const reply = await client.get(WORKFLOW, { + headers: { host: 'example.test' }, + }); + expectError(reply, 403, 'forbidden_host'); + }); +}); + +describe('the Origin header', () => { + const stub = useStub(); + const text = { text: 'x' }; + + it('is required on a POST and a PUT', async () => { + const { client } = stub(); + const headers = { origin: undefined }; + expectError( + await client.post(VALIDATE, text, { headers }), + 403, + 'forbidden_origin', + ); + const save = { text: 'x', base_hash: null }; + expectError( + await client.put(WORKFLOW, save, { headers }), + 403, + 'forbidden_origin', + ); + }); + + it('must be the server itself on a POST', async () => { + const headers = { origin: 'http://example.test' }; + const reply = await stub().client.post(VALIDATE, text, { headers }); + expectError(reply, 403, 'forbidden_origin'); + }); + + it('may be left out of a GET', async () => { + expect((await stub().client.get(WORKFLOW)).status).toBe(200); + }); + + it('must not point elsewhere on a GET', async () => { + const headers = { origin: 'http://example.test' }; + expectError( + await stub().client.get(WORKFLOW, { headers }), + 403, + 'forbidden_origin', + ); + }); + + it.each([ + ['cross-site', 403], + ['same-site', 403], + ['same-origin', 200], + ['none', 200], + ])('Sec-Fetch-Site %s on a GET is %i', async (site, status) => { + const reply = await stub().client.get(WORKFLOW, { + headers: { 'sec-fetch-site': site }, + }); + expect(reply.status).toBe(status); + if (status === 403) + expect(reply.body).toMatchObject({ code: 'forbidden_origin' }); + }); + + it('is checked before the token', async () => { + const { client } = stub(); + client.token = undefined; + const headers = { origin: 'http://example.test' }; + expectError( + await client.post(VALIDATE, text, { headers }), + 403, + 'forbidden_origin', + ); + }); +}); + +describe('the body', () => { + const stub = useStub(); + + it.each([ + ['text/plain', 'text/plain'], + ['no content type', undefined], + ['JSON in another charset', 'application/json; charset=latin1'], + ['a look-alike media type', 'application/jsonp'], + ])('is a bad request with %s', async (_, type) => { + const reply = await stub().client.post( + VALIDATE, + { text: 'x' }, + { + headers: { 'content-type': type }, + }, + ); + expectError(reply, 400, 'bad_request'); + }); + + it.each([ + 'application/json', + 'application/json; charset=utf-8', + 'Application/JSON;charset=UTF-8', + ])('may have the content type %s', async (type) => { + const reply = await stub().client.post( + VALIDATE, + { text: 'x' }, + { + headers: { 'content-type': type }, + }, + ); + expect(reply.status).toBe(200); + }); + + it.each([ + ['text that is not JSON', '{"text": '], + ['an empty body', ''], + ['a byte order mark', '\uFEFF{"text":"x"}'], + ['a field the contract does not name', '{"text":"x","extra":1}'], + ['the wrong type', '{"text":5}'], + ['JSON that is not an object', '"x"'], + ])('is a bad request with %s', async (_, rawBody) => { + expectError( + await stub().client.post(VALIDATE, undefined, { rawBody }), + 400, + 'bad_request', + ); + }); + + it('is a bad request when it is not UTF-8', async () => { + const rawBody = Buffer.from([0x7b, 0x22, 0xff, 0x22, 0x3a, 0x31, 0x7d]); + expectError( + await stub().client.post(VALIDATE, undefined, { rawBody }), + 400, + 'bad_request', + ); + }); + + it('is too large over 1 MiB, though the text alone is within its limit', async () => { + const rawBody = JSON.stringify({ + text: 'x', + pad: 'y'.repeat(MAX_BODY_BYTES), + }); + expectError( + await stub().client.post(VALIDATE, undefined, { rawBody }), + 413, + 'too_large', + ); + }); + + it('may be exactly 1 MiB', async () => { + const head = '{"text":"x","name":"'; + const pad = 'a'.repeat(MAX_BODY_BYTES - head.length - 2); + const rawBody = `${head}${pad}"}`; + expect(rawBody.length).toBe(MAX_BODY_BYTES); + expectError( + await stub().client.post(VALIDATE, undefined, { rawBody }), + 400, + 'bad_name', + ); + }); + + it('is too large when the workflow text is over 256 KiB of UTF-8', async () => { + const limit = 262_144; + const fits = await stub().client.post(VALIDATE, { + text: '#'.repeat(limit), + }); + expect(fits.status).toBe(200); + const bytes = await stub().client.post(VALIDATE, { + text: '€'.repeat(limit / 3 + 1), + }); + expectError(bytes, 413, 'too_large'); + }); + + it('is judged by size before its shape', async () => { + const rawBody = JSON.stringify({ pad: 'y'.repeat(MAX_BODY_BYTES) }); + expectError( + await stub().client.post(VALIDATE, undefined, { rawBody }), + 413, + 'too_large', + ); + }); +}); + +describe('the order of checks', () => { + const stub = useStub(); + + it('puts the token before the content type', async () => { + const { client } = stub(); + client.token = undefined; + const reply = await client.post( + VALIDATE, + { text: 'x' }, + { + headers: { 'content-type': 'text/plain' }, + }, + ); + expectError(reply, 401, 'unauthorized'); + }); + + it('puts the content type before the body size', async () => { + const rawBody = 'x'.repeat(MAX_BODY_BYTES + 1); + const reply = await stub().client.post(VALIDATE, undefined, { + rawBody, + headers: { 'content-type': 'text/plain' }, + }); + expectError(reply, 400, 'bad_request'); + }); +}); + +describe('paths and methods', () => { + const stub = useStub({}, false); + + it('answers an unknown path with 404', async () => { + expectError(await stub().client.get('/api/nothing'), 404, 'not_found'); + expectError(await stub().client.get('/'), 404, 'not_found'); + expectError(await stub().client.get('/api/workflow/'), 404, 'not_found'); + }); + + it('answers a method the path does not list with 405 and an Allow header', async () => { + const { client } = stub(); + const cases: [string, string, string][] = [ + ['DELETE', WORKFLOW, 'GET, PUT'], + ['GET', VALIDATE, 'POST'], + ['POST', '/api/runs/20261024-0412-00001', 'GET'], + ['OPTIONS', '/api/session', 'POST'], + ['HEAD', WORKFLOW, 'GET, PUT'], + ]; + for (const [method, path, allow] of cases) { + const reply = await client.call(method, path); + expect(reply.status, `${method} ${path}`).toBe(405); + expect(reply.headers.allow).toBe(allow); + if (method !== 'HEAD') + expect(reply.body).toMatchObject({ code: 'method_not_allowed' }); + } + }); + + it('answers 405 before it asks for a token', async () => { + expect((await stub().client.call('DELETE', WORKFLOW)).status).toBe(405); + }); + + it('sets no cookie and sends no CORS headers', async () => { + const reply = await stub().client.post('/api/session', { + code: LAUNCH_CODE, + }); + expect( + Object.keys(reply.headers).filter((h) => + /^access-control-|^set-cookie$/.test(h), + ), + ).toEqual([]); + }); +}); diff --git a/workflow-canvas/stub-api/http/guards.ts b/workflow-canvas/stub-api/http/guards.ts new file mode 100644 index 00000000..9a6eae0d --- /dev/null +++ b/workflow-canvas/stub-api/http/guards.ts @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: Apache-2.0 +import type { IncomingMessage } from 'node:http'; +import { ApiError, badRequest } from '../core/errors.ts'; + +/** The `Host` of the request, if it is this server on a loopback name. */ +export function requireLocalHost(req: IncomingMessage, port: number): string { + const host = req.headers.host?.toLowerCase(); + if (host === `127.0.0.1:${port}` || host === `localhost:${port}`) return host; + throw new ApiError( + 'forbidden_host', + 'The Host header does not match this server.', + ); +} + +/** + * A PUT or POST must carry this server's own `Origin`. A GET may leave it out, + * but a header it does carry must not point elsewhere. + */ +export function requireOwnOrigin( + req: IncomingMessage, + host: string, + method: string, +): void { + const { origin } = req.headers; + const site = req.headers['sec-fetch-site']; + const own = `http://${host}`; + const allowed = + method === 'GET' + ? (origin === undefined || origin === own) && + (site === undefined || site === 'same-origin' || site === 'none') + : origin === own; + if (!allowed) { + throw new ApiError( + 'forbidden_origin', + 'The Origin header does not match this server.', + ); + } +} + +/** A body must be `application/json`, with no parameter or with UTF-8. */ +export function requireJsonContentType(req: IncomingMessage): void { + const type = req.headers['content-type'] ?? ''; + if (!/^application\/json\s*(;\s*charset=utf-8\s*)?$/i.test(type)) { + throw badRequest(); + } +} diff --git a/workflow-canvas/stub-api/http/readers.ts b/workflow-canvas/stub-api/http/readers.ts new file mode 100644 index 00000000..3f215b82 --- /dev/null +++ b/workflow-canvas/stub-api/http/readers.ts @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: Apache-2.0 +import { codePointLength, MAX_GOAL_CHARS } from '../core/contract.ts'; +import { ApiError, badRequest } from '../core/errors.ts'; + +/** Reads one value of a request body, or throws the error to answer with. */ +export type Reader = (value: unknown) => T; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +export const string: Reader = (value) => { + if (typeof value !== 'string') throw badRequest(); + return value; +}; + +export const optional = + (read: Reader): Reader => + (value) => + value === undefined ? undefined : read(value); + +export const nullable = + (read: Reader): Reader => + (value) => + value === null ? null : read(value); + +/** A string of `min` to `max` code points. */ +export const boundedString = + (min: number, max: number): Reader => + (value) => { + const text = string(value); + const length = codePointLength(text); + if (length < min || length > max) throw badRequest(); + return text; + }; + +/** An object with exactly the fields of the shape, each read by its reader. */ +export function object(shape: { + [K in keyof T]-?: Reader; +}): Reader { + return (value) => { + if (!isRecord(value)) throw badRequest(); + if (Object.keys(value).some((key) => !Object.hasOwn(shape, key))) { + throw badRequest(); + } + const read = {} as T; + for (const key of Object.keys(shape) as (keyof T & string)[]) { + read[key] = shape[key](value[key]); + } + return read; + }; +} + +/** The `params` of a run: only a `goal`, which must be 1 to 2048 characters. */ +const runParams: Reader<{ goal: string }> = (value) => { + if (!isRecord(value)) throw badRequest(); + const { goal, ...others } = value; + const badParams = (message: string) => new ApiError('bad_params', message); + if (Object.keys(others).length > 0) + throw badParams('Only a goal is allowed.'); + if (goal === undefined) throw badParams('The run needs a goal.'); + const text = string(goal); + const length = codePointLength(text); + if (length < 1) throw badParams('The run needs a goal.'); + if (length > MAX_GOAL_CHARS) { + throw badParams(`The goal is longer than ${MAX_GOAL_CHARS} characters.`); + } + return { goal: text }; +}; + +export const readSessionRequest = object<{ code: string }>({ + code: boundedString(1, 256), +}); + +export const readSaveRequest = object<{ + name: string | undefined; + text: string; + base_hash: string | null; +}>({ + name: optional(string), + text: string, + base_hash: nullable(string), +}); + +export const readCheckRequest = object<{ + name: string | undefined; + text: string; +}>({ + name: optional(string), + text: string, +}); + +export const readRunRequest = object<{ params: { goal: string } }>({ + params: runParams, +}); diff --git a/workflow-canvas/stub-api/http/respond.ts b/workflow-canvas/stub-api/http/respond.ts new file mode 100644 index 00000000..c1a3ba69 --- /dev/null +++ b/workflow-canvas/stub-api/http/respond.ts @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: Apache-2.0 +import type { ServerResponse } from 'node:http'; +import { API_VERSION } from '../core/contract.ts'; +import { ApiError, type ErrorCode } from '../core/errors.ts'; +import type { Reply } from './routes.ts'; + +/** The status that goes with each error code. */ +export const STATUS_BY_CODE: Record = { + bad_request: 400, + bad_name: 400, + bad_hash: 400, + unauthorized: 401, + invalid_code: 401, + forbidden_host: 403, + forbidden_origin: 403, + read_only_workflow: 403, + not_found: 404, + method_not_allowed: 405, + conflict: 409, + already_running: 409, + harness_unavailable: 409, + too_large: 413, + invalid_workflow: 422, + bad_params: 422, + io_error: 500, +}; + +/** An error response, with the fields that come with its code. */ +export function errorReply(error: ApiError): Reply { + return { + status: STATUS_BY_CODE[error.code], + body: { error: error.message, code: error.code, ...error.extra }, + }; +} + +/** The answer to anything that is not an `ApiError`: a fault of the stub. */ +export function faultReply(): Reply { + const message = 'The server could not read or write its files.'; + return errorReply(new ApiError('io_error', message)); +} + +/** Writes a reply with the headers that every response carries. */ +export function send(res: ServerResponse, reply: Reply): void { + res.writeHead(reply.status, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + 'X-Konductor-Api': String(API_VERSION), + ...reply.headers, + }); + res.end(JSON.stringify(reply.body)); +} diff --git a/workflow-canvas/stub-api/http/routes.ts b/workflow-canvas/stub-api/http/routes.ts new file mode 100644 index 00000000..6ee8241c --- /dev/null +++ b/workflow-canvas/stub-api/http/routes.ts @@ -0,0 +1,117 @@ +// SPDX-License-Identifier: Apache-2.0 +import { API_VERSION, HARNESS } from '../core/contract.ts'; +import { badName } from '../core/errors.ts'; +import type { WorkflowApi } from '../core/workflow-api.ts'; +import { + readCheckRequest, + readRunRequest, + readSaveRequest, + readSessionRequest, +} from './readers.ts'; +import type { SessionGate } from './session.ts'; + +/** What a handler answers with, before it is written to the socket. */ +export interface Reply { + status: number; + body: unknown; + headers?: Record; +} + +export interface Call { + query: URLSearchParams; + /** The parts of the path that the route pattern captured. */ + params: string[]; + /** The parsed body of a PUT or POST. */ + body: unknown; +} + +export interface Operation { + /** Whether the call needs the session token. */ + secured: boolean; + handle(call: Call): Reply; +} + +export interface Route { + pattern: RegExp; + /** The operations of the path, by HTTP method. */ + operations: Record; +} + +const ok = (body: unknown): Reply => ({ status: 200, body }); + +// A `name` that is repeated in the query is as bad as one that is not a name. +function nameFrom(query: URLSearchParams): string | undefined { + const names = query.getAll('name'); + if (names.length > 1) { + throw badName(); + } + return names[0]; +} + +/** The six operations of the contract, on the paths it gives them. */ +export function buildRoutes(api: WorkflowApi, session: SessionGate): Route[] { + return [ + { + pattern: /^\/api\/session$/, + operations: { + POST: { + secured: false, + handle: ({ body }) => { + const { code } = readSessionRequest(body); + const token = session.exchange(code); + const { root } = api; + return ok({ + token, + root, + harness: HARNESS, + api_version: API_VERSION, + }); + }, + }, + }, + }, + { + pattern: /^\/api\/workflow$/, + operations: { + GET: { + secured: true, + handle: ({ query }) => ok(api.loadWorkflow(nameFrom(query))), + }, + PUT: { + secured: true, + handle: ({ body }) => ok(api.saveWorkflow(readSaveRequest(body))), + }, + }, + }, + { + pattern: /^\/api\/validate$/, + operations: { + POST: { + secured: true, + handle: ({ body }) => ok(api.checkWorkflow(readCheckRequest(body))), + }, + }, + }, + { + pattern: /^\/api\/run$/, + operations: { + POST: { + secured: true, + handle: ({ body }) => { + readRunRequest(body); + return { status: 202, body: api.startRun() }; + }, + }, + }, + }, + { + pattern: /^\/api\/runs\/([^/]+)$/, + operations: { + GET: { + secured: true, + handle: ({ params }) => ok({ run: api.getRun(params[0] ?? '') }), + }, + }, + }, + ]; +} diff --git a/workflow-canvas/stub-api/http/run.test.ts b/workflow-canvas/stub-api/http/run.test.ts new file mode 100644 index 00000000..e0a33c0b --- /dev/null +++ b/workflow-canvas/stub-api/http/run.test.ts @@ -0,0 +1,205 @@ +// SPDX-License-Identifier: Apache-2.0 +/** Calls POST /api/run and GET /api/runs/{id} over HTTP; time is a fake clock. */ +import { describe, expect, it } from 'vitest'; +import { DEFAULT_STAGE_MS } from '../core/runs.ts'; +import { + expectContract, + expectError, + responseExample, +} from '../test-support/contract.ts'; +import { useStub, type TestStub } from '../test-support/harness.ts'; +import { oneStageWorkflow } from '../test-support/workflows.ts'; + +const RUN = '/api/run'; +const RUNS = '/api/runs/{id}'; +const GOAL = { params: { goal: 'Add a greeting to the README' } }; + +async function start(stub: TestStub): Promise { + const reply = await stub.client.post(RUN, GOAL); + expectContract(reply, 'POST', RUN); + return (reply.body as { run_id: string }).run_id; +} + +async function stagesOf(stub: TestStub, runId: string): Promise { + const reply = await stub.client.get(`/api/runs/${runId}`); + expectContract(reply, 'GET', RUNS); + const { run } = reply.body as { + run: { stages: Record }; + }; + return Object.values(run.stages).map((stage) => stage.status); +} + +describe('POST /api/run', () => { + const stub = useStub(); + + it('accepts the run at once and returns its id', async () => { + const reply = await stub().client.post(RUN, GOAL); + expectContract(reply, 'POST', RUN); + expect(reply.status).toBe(202); + expect(reply.body).toEqual({ run_id: '20261024-0412-00001' }); + }); + + it('refuses a second run while one is active, with the id of the first', async () => { + const runId = await start(stub()); + const reply = await stub().client.post(RUN, GOAL); + expectContract(reply, 'POST', RUN); + expect(reply.status).toBe(409); + expect(reply.body).toEqual({ + ...(responseExample('POST', RUN, 409, 'busy') as object), + run_id: runId, + }); + }); + + it('accepts a new run once the first has finished', async () => { + const first = await start(stub()); + stub().clock.advance(2 * DEFAULT_STAGE_MS); + const second = await start(stub()); + expect(second).not.toBe(first); + }); + + it('works from the saved file: a save after the start does not change the run', async () => { + const { client } = stub(); + const runId = await start(stub()); + const loaded = (await client.get('/api/workflow')).body as { hash: string }; + const text = oneStageWorkflow('only', 'k-developer'); + expect( + (await client.put('/api/workflow', { text, base_hash: loaded.hash })) + .status, + ).toBe(200); + expect(await stagesOf(stub(), runId)).toHaveLength(2); + }); + + it.each([ + ['no goal', { params: {} }], + ['an empty goal', { params: { goal: '' } }], + ['another key in params', { params: { goal: 'x', extra: 1 } }], + ['a goal of 2049 characters', { params: { goal: 'x'.repeat(2049) } }], + ])('is bad_params with %s', async (_, body) => { + const reply = await stub().client.post(RUN, body); + expectContract(reply, 'POST', RUN); + expect(reply.status).toBe(422); + expect(reply.body).toMatchObject({ code: 'bad_params' }); + }); + + it('accepts a goal of 2048 code points', async () => { + const goal = '😀'.repeat(2048); + expect((await stub().client.post(RUN, { params: { goal } })).status).toBe( + 202, + ); + }); + + it.each([ + ['no params', {}], + ['params that is not an object', { params: 'x' }], + ['a goal that is not a string', { params: { goal: 7 } }], + ['another field', { params: { goal: 'x' }, extra: 1 }], + ])('is a bad request with %s', async (_, body) => { + expectError(await stub().client.post(RUN, body), 400, 'bad_request'); + }); + + it('checks the goal before the state of the run', async () => { + await start(stub()); + expectError( + await stub().client.post(RUN, { params: {} }), + 422, + 'bad_params', + ); + }); +}); + +describe('POST /api/run when the root has no file', () => { + const stub = useStub({ files: {} }); + + it('is not_found', async () => { + expectError(await stub().client.post(RUN, GOAL), 404, 'not_found'); + }); +}); + +describe('POST /api/run when the saved file has errors', () => { + const stub = useStub({ files: { hello: 'name: hello\nowner: me\n' } }); + + it('is invalid_workflow, with the diagnostics', async () => { + const reply = await stub().client.post(RUN, GOAL); + expectContract(reply, 'POST', RUN); + expect(reply.status).toBe(422); + expect(reply.body).toMatchObject({ + code: 'invalid_workflow', + diagnostics: [ + { code: 'MISSING_KEY' }, + { code: 'MISSING_KEY' }, + { code: 'UNKNOWN_KEY' }, + ], + }); + }); +}); + +describe('GET /api/runs/{id}', () => { + const stub = useStub(); + + it('shows the first stage running when the run starts', async () => { + const runId = await start(stub()); + const reply = await stub().client.get(`/api/runs/${runId}`); + expectContract(reply, 'GET', RUNS); + expect(reply.body).toEqual({ + run: { + run_id: runId, + workflow: 'hello', + harness: 'kiro-cli', + status: 'running', + started_at: '2026-10-24T04:12:05Z', + finished_at: null, + exit_code: null, + order: ['draft', 'review'], + stages: { draft: { status: 'running' }, review: { status: 'pending' } }, + }, + }); + }); + + it('goes through stage 1, stage 2 and the end as the clock moves', async () => { + const runId = await start(stub()); + const seen = [await stagesOf(stub(), runId)]; + stub().clock.advance(DEFAULT_STAGE_MS); + seen.push(await stagesOf(stub(), runId)); + stub().clock.advance(DEFAULT_STAGE_MS); + seen.push(await stagesOf(stub(), runId)); + expect(seen).toEqual([ + ['running', 'pending'], + ['passed', 'running'], + ['passed', 'passed'], + ]); + const finished = await stub().client.get(`/api/runs/${runId}`); + expect(finished.body).toMatchObject({ + run: { + status: 'passed', + finished_at: '2026-10-24T04:12:11Z', + exit_code: 0, + }, + }); + }); + + it('stays readable after the run has finished', async () => { + const runId = await start(stub()); + stub().clock.advance(10 * 60_000); + const reply = await stub().client.get(`/api/runs/${runId}`); + expect(reply.body).toMatchObject({ run: { status: 'passed' } }); + }); + + it('is not_found for an id that was never given', async () => { + const reply = await stub().client.get('/api/runs/20261024-0412-zzzzz'); + expectContract(reply, 'GET', RUNS); + expect(reply.status).toBe(404); + expect(reply.body).toMatchObject({ code: 'not_found' }); + }); + + it.each(['abc', '20261024-0412-ABCDE', '20261024-0412-abcd', '../workflow'])( + 'is not_found, like a missing run, for the malformed id %s', + async (id) => { + const missing = await stub().client.get('/api/runs/20261024-0412-zzzzz'); + const malformed = await stub().client.get( + `/api/runs/${encodeURIComponent(id)}`, + ); + expect(malformed.status).toBe(404); + expect(malformed.body).toEqual(missing.body); + }, + ); +}); diff --git a/workflow-canvas/stub-api/http/server.ts b/workflow-canvas/stub-api/http/server.ts new file mode 100644 index 00000000..3d751d68 --- /dev/null +++ b/workflow-canvas/stub-api/http/server.ts @@ -0,0 +1,105 @@ +// SPDX-License-Identifier: Apache-2.0 +import { createServer, type Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import type { Clock } from '../core/clock.ts'; +import { ApiError } from '../core/errors.ts'; +import { RunSimulator } from '../core/runs.ts'; +import { SAMPLE_FILES, SAMPLE_ROOT } from '../core/sample.ts'; +import { WorkflowStore } from '../core/store.ts'; +import { WorkflowApi } from '../core/workflow-api.ts'; +import { DEFAULT_PORT, LOOPBACK, originOf } from '../address.ts'; +import { dispatch, type Dispatcher } from './dispatch.ts'; +import { errorReply, faultReply, send } from './respond.ts'; +import { buildRoutes } from './routes.ts'; +import { SessionGate } from './session.ts'; +import { newRunSuffix, newSecret, systemClock } from './system.ts'; + +export interface StubOptions { + /** `0` picks a free port. */ + port?: number; + launchCode?: string; + /** Lets the launch code be traded again; see `SessionGateOptions`. */ + reusableLaunchCode?: boolean; + clock?: Clock; + runSuffix?: () => string; + /** How long each simulated stage takes. */ + stageMs?: number; + /** The workflow files at the start, by name. */ + files?: Readonly>; +} + +export interface RunningStub { + /** The address the socket is bound to, as the operating system reports it. */ + host: string; + port: number; + origin: string; + launchCode: string; + close(): Promise; +} + +/** Starts the stub on the loopback address and resolves once it listens. */ +export async function startStub( + options: StubOptions = {}, +): Promise { + const clock = options.clock ?? systemClock; + const launchCode = options.launchCode ?? newSecret(); + const api = new WorkflowApi({ + root: SAMPLE_ROOT, + store: new WorkflowStore(options.files ?? SAMPLE_FILES), + runs: new RunSimulator({ + clock, + suffix: options.runSuffix ?? newRunSuffix, + stageMs: options.stageMs, + }), + }); + const session = new SessionGate({ + clock, + launchCode, + reusableLaunchCode: options.reusableLaunchCode, + }); + const dispatcher: Dispatcher = { + routes: buildRoutes(api, session), + session, + port: () => portOf(server), + }; + const server = createServer((req, res) => { + dispatch(req, dispatcher) + .catch((error: unknown) => { + if (error instanceof ApiError) return errorReply(error); + console.error(error); + return faultReply(); + }) + .then((reply) => send(res, reply)); + }); + await listen(server, options.port ?? DEFAULT_PORT); + session.openWindow(); + const { address, port } = server.address() as AddressInfo; + return { + host: address, + port, + origin: originOf(port), + launchCode, + close: () => close(server), + }; +} + +function portOf(server: Server): number { + return (server.address() as AddressInfo).port; +} + +function listen(server: Server, port: number): Promise { + return new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(port, LOOPBACK, () => { + server.off('error', reject); + resolve(); + }); + }); +} + +function close(server: Server): Promise { + return new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + server.closeAllConnections(); + }); +} diff --git a/workflow-canvas/stub-api/http/session.test.ts b/workflow-canvas/stub-api/http/session.test.ts new file mode 100644 index 00000000..3250e42b --- /dev/null +++ b/workflow-canvas/stub-api/http/session.test.ts @@ -0,0 +1,197 @@ +// SPDX-License-Identifier: Apache-2.0 +/** Calls POST /api/session over HTTP and checks it against the contract. */ +import { connect } from 'node:net'; +import { networkInterfaces } from 'node:os'; +import { describe, expect, it } from 'vitest'; +import { LOOPBACK, originOf } from '../address.ts'; +import { expectContract, expectError } from '../test-support/contract.ts'; +import { LAUNCH_CODE, openStub, useStub } from '../test-support/harness.ts'; +import { FakeClock } from '../test-support/fake-clock.ts'; +import { LAUNCH_WINDOW_MS, SessionGate } from './session.ts'; + +const SESSION = '/api/session'; + +describe('POST /api/session', () => { + const stub = useStub({}, false); + + it('trades the launch code for a token and says what it serves', async () => { + const reply = await stub().client.post(SESSION, { code: LAUNCH_CODE }); + expectContract(reply, 'POST', SESSION); + expect(reply.status).toBe(200); + expect(reply.body).toEqual({ + token: expect.stringMatching(/^[\w-]{43}$/), + root: 'hello', + harness: 'kiro-cli', + api_version: 1, + }); + }); + + it('works for the launch code once only', async () => { + const { client } = stub(); + expect((await client.post(SESSION, { code: LAUNCH_CODE })).status).toBe( + 200, + ); + const again = await client.post(SESSION, { code: LAUNCH_CODE }); + expectContract(again, 'POST', SESSION); + expect(again.body).toMatchObject({ code: 'invalid_code' }); + expect(again.status).toBe(401); + }); + + it('refuses a wrong code and still accepts the right one', async () => { + const { client } = stub(); + const wrong = await client.post(SESSION, { code: 'not-the-code' }); + expectContract(wrong, 'POST', SESSION); + expect(wrong.status).toBe(401); + expect(wrong.body).toMatchObject({ code: 'invalid_code' }); + expect((await client.post(SESSION, { code: LAUNCH_CODE })).status).toBe( + 200, + ); + }); + + it('accepts the launch code a millisecond before the window ends', async () => { + const { client, clock } = stub(); + clock.advance(LAUNCH_WINDOW_MS - 1); + expect((await client.post(SESSION, { code: LAUNCH_CODE })).status).toBe( + 200, + ); + }); + + it('refuses the launch code once 60 seconds have passed', async () => { + const { client, clock } = stub(); + clock.advance(LAUNCH_WINDOW_MS); + const reply = await client.post(SESSION, { code: LAUNCH_CODE }); + expectContract(reply, 'POST', SESSION); + expect(reply.body).toMatchObject({ code: 'invalid_code' }); + }); + + it.each([ + ['no code', {}], + ['an empty code', { code: '' }], + ['a code of 257 characters', { code: 'x'.repeat(257) }], + ['a code that is not a string', { code: 7 }], + ['another field', { code: LAUNCH_CODE, extra: true }], + ['a body that is not an object', ['code']], + ])('is a bad request with %s', async (_, body) => { + const reply = await stub().client.post(SESSION, body); + expectContract(reply, 'POST', SESSION); + expect(reply.status).toBe(400); + expect(reply.body).toMatchObject({ code: 'bad_request' }); + }); +}); + +describe('POST /api/session on a stub with a reusable launch code', () => { + const stub = useStub({ reusableLaunchCode: true }, false); + + it('gives the same token again for the same code, as a reloaded page needs', async () => { + const { client } = stub(); + const first = await client.post(SESSION, { code: LAUNCH_CODE }); + const again = await client.post(SESSION, { code: LAUNCH_CODE }); + expectContract(again, 'POST', SESSION); + expect(again.status).toBe(200); + expect(again.body).toEqual(first.body); + }); + + it('keeps taking the code after the 60 seconds', async () => { + const { client, clock } = stub(); + await client.post(SESSION, { code: LAUNCH_CODE }); + clock.advance(10 * LAUNCH_WINDOW_MS); + const late = await client.post(SESSION, { code: LAUNCH_CODE }); + expect(late.status).toBe(200); + }); + + it('still refuses a wrong code', async () => { + const { client } = stub(); + await client.post(SESSION, { code: LAUNCH_CODE }); + const wrong = await client.post(SESSION, { code: 'not-the-code' }); + expectContract(wrong, 'POST', SESSION); + expect(wrong.status).toBe(401); + expect(wrong.body).toMatchObject({ code: 'invalid_code' }); + }); +}); + +describe('the other calls', () => { + const stub = useStub({}, false); + const calls: [string, string, unknown][] = [ + ['GET', '/api/workflow', undefined], + ['PUT', '/api/workflow', { text: 'x', base_hash: null }], + ['POST', '/api/validate', { text: 'x' }], + ['POST', '/api/run', { params: { goal: 'x' } }], + ['GET', '/api/runs/20261024-0412-00001', undefined], + ]; + + it.each(calls)('%s %s needs the token', async (method, path, body) => { + const { client } = stub(); + const reply = await client.call(method, path, { body }); + expectContract(reply, method, path.replace(/\/runs\/.*/, '/runs/{id}')); + expect(reply.status).toBe(401); + expect(reply.body).toMatchObject({ code: 'unauthorized' }); + }); + + it('refuses a token that the stub did not give', async () => { + const { client } = stub(); + client.token = 'made-up-token'; + expectError(await client.get('/api/workflow'), 401, 'unauthorized'); + }); + + it('refuses the launch code as a token', async () => { + const { client } = stub(); + client.token = LAUNCH_CODE; + expectError(await client.get('/api/workflow'), 401, 'unauthorized'); + }); +}); + +describe('the launch window', () => { + const makeGate = () => { + const clock = new FakeClock(); + return { clock, gate: new SessionGate({ clock, launchCode: LAUNCH_CODE }) }; + }; + + it('starts when it is opened, not when the gate is built', () => { + const { clock, gate } = makeGate(); + clock.advance(10 * LAUNCH_WINDOW_MS); + gate.openWindow(); + clock.advance(LAUNCH_WINDOW_MS - 1); + expect(gate.exchange(LAUNCH_CODE)).not.toBe(''); + }); + + it('is closed until it is opened', () => { + const { gate } = makeGate(); + expect(() => gate.exchange(LAUNCH_CODE)).toThrow('not valid'); + }); +}); + +describe('the stub', () => { + it('is bound to the loopback address and nothing else', async () => { + const { stub, client } = await openStub(); + try { + expect(stub.host).toBe(LOOPBACK); + expect(stub.origin).toBe(originOf(stub.port)); + expect((await client.get('/api/workflow')).status).toBe(401); + for (const address of otherAddresses()) { + await expect(connectTo(address, stub.port)).rejects.toMatchObject({ + code: 'ECONNREFUSED', + }); + } + } finally { + await stub.close(); + } + }); +}); + +// The IPv4 addresses of this machine that are not loopback; there may be none. +function otherAddresses(): string[] { + return Object.values(networkInterfaces()) + .flatMap((list) => list ?? []) + .filter((entry) => entry.family === 'IPv4' && !entry.internal) + .map((entry) => entry.address); +} + +function connectTo(host: string, port: number): Promise { + return new Promise((resolve, reject) => { + const socket = connect({ host, port }, () => { + socket.destroy(); + resolve(); + }); + socket.once('error', reject); + }); +} diff --git a/workflow-canvas/stub-api/http/session.ts b/workflow-canvas/stub-api/http/session.ts new file mode 100644 index 00000000..23fa6dca --- /dev/null +++ b/workflow-canvas/stub-api/http/session.ts @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: Apache-2.0 +import type { Clock } from '../core/clock.ts'; +import { ApiError } from '../core/errors.ts'; +import { newSecret, sameSecret } from './system.ts'; + +/** How long the launch code works, from the moment the stub listens. */ +export const LAUNCH_WINDOW_MS = 60_000; + +export interface SessionGateOptions { + clock: Clock; + launchCode: string; + /** + * For a dev flow that reloads the page: the launch code works again, with + * no time limit, and gives the token it gave the first time. A reload + * drops the token from the page, and the address it was read from. + */ + reusableLaunchCode?: boolean; +} + +/** + * The only login of the stub: a one-time launch code is traded for a bearer + * token. This is a local development stub and keeps no real credentials. + */ +export class SessionGate { + private readonly clock: Clock; + private readonly launchCode: string; + private readonly reusable: boolean; + private expiresAt = 0; + private token: string | undefined; + + constructor(options: SessionGateOptions) { + this.clock = options.clock; + this.launchCode = options.launchCode; + this.reusable = options.reusableLaunchCode === true; + } + + /** Starts the launch window: call it as the stub hands out the code. */ + openWindow(): void { + this.expiresAt = this.clock.now() + LAUNCH_WINDOW_MS; + } + + /** + * Trades the launch code for the token. The code works once, unless the + * gate is reusable: then it works any time and gives the same token. + */ + exchange(code: string): string { + const right = sameSecret(code, this.launchCode); + if (!right || !this.isOpen()) { + throw new ApiError('invalid_code', 'The launch code is not valid.'); + } + this.token ??= newSecret(); + return this.token; + } + + private isOpen(): boolean { + if (this.reusable) return true; + return this.token === undefined && this.clock.now() < this.expiresAt; + } + + /** Refuses a call whose `Authorization` header is not the session token. */ + authorize(header: string | undefined): void { + const sent = /^Bearer (\S+)$/i.exec(header ?? '')?.[1]; + if ( + this.token === undefined || + sent === undefined || + !sameSecret(sent, this.token) + ) { + throw new ApiError( + 'unauthorized', + 'The session token is missing or wrong.', + ); + } + } +} diff --git a/workflow-canvas/stub-api/http/system.ts b/workflow-canvas/stub-api/http/system.ts new file mode 100644 index 00000000..8dd23e50 --- /dev/null +++ b/workflow-canvas/stub-api/http/system.ts @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: Apache-2.0 +import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'; +import type { Clock } from '../core/clock.ts'; + +const SUFFIX_ALPHABET = 'abcdefghijklmnopqrstuvwxyz0123456789'; + +/** The real clock. */ +export const systemClock: Clock = { now: () => Date.now() }; + +/** A launch code or token: 256 bits from a secure random source. */ +export function newSecret(): string { + return randomBytes(32).toString('base64url'); +} + +/** Five random characters for the end of a run id. */ +export function newRunSuffix(): string { + return Array.from( + randomBytes(5), + (byte) => SUFFIX_ALPHABET[byte % SUFFIX_ALPHABET.length], + ).join(''); +} + +/** Compares two secrets in constant time, whatever their lengths. */ +export function sameSecret(a: string, b: string): boolean { + const digest = (text: string) => createHash('sha256').update(text).digest(); + return timingSafeEqual(digest(a), digest(b)); +} diff --git a/workflow-canvas/stub-api/http/validate.test.ts b/workflow-canvas/stub-api/http/validate.test.ts new file mode 100644 index 00000000..8e948db0 --- /dev/null +++ b/workflow-canvas/stub-api/http/validate.test.ts @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: Apache-2.0 +/** Calls POST /api/validate over HTTP and checks it against the contract. */ +import { describe, expect, it } from 'vitest'; +import { + expectContract, + expectError, + requestExample, + responseExample, + responseExampleNames, +} from '../test-support/contract.ts'; +import { useStub } from '../test-support/harness.ts'; +import { oneStageWorkflow } from '../test-support/workflows.ts'; + +const PATH = '/api/validate'; + +describe('POST /api/validate', () => { + const stub = useStub(); + + it.each(responseExampleNames('POST', PATH, 200))( + 'answers the %s request of the contract with its example', + async (name) => { + const request = requestExample('POST', PATH, name); + const reply = await stub().client.post(PATH, request); + expectContract(reply, 'POST', PATH); + expect(reply.status).toBe(200); + expect(reply.body).toEqual(responseExample('POST', PATH, 200, name)); + }, + ); + + it('reports every problem in one answer, in the order of the text', async () => { + const text = 'name: hello\nowner: me\nintent: 5\nstages:\n - name: end\n'; + const reply = await stub().client.post(PATH, { text }); + expectContract(reply, 'POST', PATH); + const body = reply.body as { + errors: number; + view: unknown; + diagnostics: { code: string; line: number }[]; + summary: { valid: boolean; counts: { stages: number } }; + }; + expect(body.diagnostics.map((d) => [d.code, d.line])).toEqual([ + ['UNKNOWN_KEY', 2], + ['WRONG_TYPE', 3], + ['MISSING_KEY', 5], + ['RESERVED_NAME', 5], + ]); + expect(body.errors).toBe(4); + expect(body.view).toBeNull(); + expect(body.summary).toEqual({ valid: false, counts: { stages: 0 } }); + }); + + it('writes nothing', async () => { + const { client } = stub(); + const before = await client.get('/api/workflow'); + await client.post(PATH, { text: oneStageWorkflow() }); + expect(await client.get('/api/workflow')).toEqual(before); + }); + + it('checks the name of the text against the name of the request', async () => { + const text = oneStageWorkflow(); + const reply = await stub().client.post(PATH, { name: 'hello', text }); + expect((reply.body as { errors: number }).errors).toBe(0); + }); + + it('answers a workflow that is not the root with read_only_workflow', async () => { + const reply = await stub().client.post(PATH, { name: 'other', text: 'x' }); + expectContract(reply, 'POST', PATH); + expect(reply.status).toBe(403); + expect(reply.body).toMatchObject({ code: 'read_only_workflow' }); + }); + + it('answers a malformed name with bad_name before the root check', async () => { + expectError( + await stub().client.post(PATH, { name: 'Bad Name', text: 'x' }), + 400, + 'bad_name', + ); + }); + + it.each([ + ['no text', {}], + ['a text that is not a string', { text: 1 }], + ['an unknown field', { text: 'x', base_hash: null }], + ])('is a bad request with %s', async (_, body) => { + const reply = await stub().client.post(PATH, body); + expectContract(reply, 'POST', PATH); + expect(reply.status).toBe(400); + expect(reply.body).toMatchObject({ code: 'bad_request' }); + }); +}); diff --git a/workflow-canvas/stub-api/http/workflow.test.ts b/workflow-canvas/stub-api/http/workflow.test.ts new file mode 100644 index 00000000..d604044b --- /dev/null +++ b/workflow-canvas/stub-api/http/workflow.test.ts @@ -0,0 +1,285 @@ +// SPDX-License-Identifier: Apache-2.0 +/** Calls GET and PUT /api/workflow over HTTP and checks them against the contract. */ +import { describe, expect, it } from 'vitest'; +import { sha256Hex } from '../core/hash.ts'; +import { SAMPLE_FILES } from '../core/sample.ts'; +import { + expectContract, + expectError, + requestExample, + responseExample, +} from '../test-support/contract.ts'; +import { useStub } from '../test-support/harness.ts'; +import type { Reply } from '../test-support/stub-client.ts'; + +const PATH = '/api/workflow'; +const HELLO = SAMPLE_FILES.hello ?? ''; +const HELLO_HASH = sha256Hex(HELLO); +const ONE_STAGE = + 'name: hello\nintent: Say hello\nstages:\n - name: only\n agent: k-developer\n'; + +interface Loaded { + text: string | null; + hash: string | null; + view: { stages: { name: string; agent: string }[] } | null; +} + +async function load(reply: Promise): Promise { + return (await reply).body as Loaded; +} + +describe('GET /api/workflow', () => { + const stub = useStub(); + + it('returns the sample workflow, its hash and its view', async () => { + const reply = await stub().client.get(PATH); + expectContract(reply, 'GET', PATH); + expect(reply.status).toBe(200); + expect(reply.body).toEqual({ + name: 'hello', + text: HELLO, + hash: HELLO_HASH, + read_only: false, + view: { + name: 'hello', + intent: { outcome: 'Add a greeting to the README' }, + stages: [ + { name: 'draft', agent: 'k-developer' }, + { name: 'review', agent: 'k-quality-assurance' }, + ], + }, + diagnostics: [], + }); + }); + + it('accepts the root by name', async () => { + const reply = await stub().client.get(`${PATH}?name=hello`); + expect(reply.status).toBe(200); + }); + + it('ignores a query parameter that is not name', async () => { + expect((await stub().client.get(`${PATH}?other=1`)).status).toBe(200); + }); + + it('answers a workflow that is not the root with 404', async () => { + const reply = await stub().client.get(`${PATH}?name=other`); + expectContract(reply, 'GET', PATH); + expect(reply.status).toBe(404); + expect(reply.body).toMatchObject({ code: 'not_found' }); + }); + + it.each(['Hello', '-x', '', 'a%20b', 'hello&name=hello'])( + 'answers the name %j with bad_name', + async (name) => { + const reply = await stub().client.get(`${PATH}?name=${name}`); + expectContract(reply, 'GET', PATH); + expect(reply.body).toMatchObject({ code: 'bad_name' }); + }, + ); +}); + +describe('GET /api/workflow when the root has no file', () => { + const stub = useStub({ files: {} }); + + it('returns null text, hash and view', async () => { + const reply = await stub().client.get(PATH); + expectContract(reply, 'GET', PATH); + expect(reply.body).toEqual(responseExample('GET', PATH, 200, 'new')); + }); +}); + +describe('GET /api/workflow with the file of the contract example', () => { + const example = responseExample('GET', PATH, 200, 'existing') as Loaded; + const stub = useStub({ files: { hello: example.text ?? '' } }); + + it('returns exactly what the example shows', async () => { + const reply = await stub().client.get(PATH); + expect(reply.body).toEqual(example); + }); +}); + +describe('GET /api/workflow with text that has errors', () => { + const stub = useStub({ files: { hello: 'name: hello\nowner: me\n' } }); + + it('returns the text with a null view and the diagnostics', async () => { + const reply = await stub().client.get(PATH); + expectContract(reply, 'GET', PATH); + expect(reply.body).toMatchObject({ + text: 'name: hello\nowner: me\n', + view: null, + diagnostics: [ + { code: 'MISSING_KEY' }, + { code: 'MISSING_KEY' }, + { code: 'UNKNOWN_KEY' }, + ], + }); + }); +}); + +describe('PUT /api/workflow', () => { + const stub = useStub(); + const save = (text: string, base_hash: string | null, name?: string) => + stub().client.put(PATH, { name, text, base_hash }); + + it('writes the text and returns its hash and view', async () => { + const reply = await save(ONE_STAGE, HELLO_HASH); + expectContract(reply, 'PUT', PATH); + expect(reply.status).toBe(200); + expect(reply.body).toEqual({ + hash: sha256Hex(ONE_STAGE), + view: { + name: 'hello', + intent: { outcome: 'Say hello' }, + stages: [{ name: 'only', agent: 'k-developer' }], + }, + diagnostics: [], + }); + const loaded = await load(stub().client.get(PATH)); + expect(loaded).toMatchObject({ + text: ONE_STAGE, + hash: sha256Hex(ONE_STAGE), + }); + }); + + it('keeps the bytes it was sent: a byte order mark, CRLF, no final newline', async () => { + const text = `\uFEFF${ONE_STAGE.replaceAll('\n', '\r\n').trimEnd()}`; + const reply = await save(text, HELLO_HASH); + expect(reply.status).toBe(200); + const loaded = await load(stub().client.get(PATH)); + expect(loaded.text).toBe(text); + expect(loaded.hash).toBe(sha256Hex(text)); + expect((reply.body as { hash: string }).hash).toBe(sha256Hex(text)); + }); + + it('is a 200 that changes nothing when the text is the file text', async () => { + const reply = await save(HELLO, HELLO_HASH); + expect(reply.status).toBe(200); + expect(await load(stub().client.get(PATH))).toMatchObject({ + text: HELLO, + hash: HELLO_HASH, + }); + }); + + it('is a conflict when the file changed since base_hash', async () => { + await save(ONE_STAGE, HELLO_HASH); + const reply = await save(HELLO, HELLO_HASH); + expectContract(reply, 'PUT', PATH); + expect(reply.status).toBe(409); + expect(reply.body).toEqual({ + ...(responseExample('PUT', PATH, 409, 'changed') as object), + current_hash: sha256Hex(ONE_STAGE), + }); + expect((await load(stub().client.get(PATH))).text).toBe(ONE_STAGE); + }); + + it('is a conflict with the current hash when base_hash is null but the file exists', async () => { + const reply = await save(ONE_STAGE, null); + expectContract(reply, 'PUT', PATH); + expect(reply.status).toBe(409); + expect(reply.body).toEqual({ + ...(responseExample('PUT', PATH, 409, 'exists') as object), + current_hash: HELLO_HASH, + }); + }); + + it('lets only one of two saves from the same base_hash succeed', async () => { + const other = ONE_STAGE.replace('Say hello', 'Say hi'); + const replies = await Promise.all([ + save(ONE_STAGE, HELLO_HASH), + save(other, HELLO_HASH), + ]); + expect(replies.map((r) => r.status).sort()).toEqual([200, 409]); + }); + + it('rejects an invalid text with its diagnostics and writes nothing', async () => { + const reply = await save( + 'name: hello\nintent: a\nintent: b\nstages:\n - name: x\n agent: y\n', + HELLO_HASH, + ); + expectContract(reply, 'PUT', PATH); + expect(reply.status).toBe(422); + expect(reply.body).toMatchObject({ + code: 'invalid_workflow', + diagnostics: [ + { code: 'DUPLICATE_KEY', path: 'intent', line: 3, column: 1 }, + ], + }); + expect((await load(stub().client.get(PATH))).hash).toBe(HELLO_HASH); + }); + + it('answers the example of an unknown key with the example error', async () => { + const request = requestExample('PUT', PATH, 'unknownKey'); + const reply = await stub().client.put(PATH, request); + expect(reply.status).toBe(422); + expect(reply.body).toEqual(responseExample('PUT', PATH, 422, 'invalid')); + }); + + it('is read-only for a workflow that is not the root', async () => { + const reply = await save(ONE_STAGE, null, 'other'); + expectContract(reply, 'PUT', PATH); + expect(reply.status).toBe(403); + expect(reply.body).toMatchObject({ code: 'read_only_workflow' }); + }); + + it.each([ + ['not 64 hex digits', 'abc'], + ['upper-case hex', HELLO_HASH.toUpperCase()], + ])('is bad_hash when base_hash is %s', async (_, hash) => { + const reply = await save(ONE_STAGE, hash); + expectContract(reply, 'PUT', PATH); + expect(reply.body).toMatchObject({ code: 'bad_hash' }); + }); + + it.each([ + ['no text', { base_hash: null }], + ['no base_hash', { text: ONE_STAGE }], + ['a number as base_hash', { text: ONE_STAGE, base_hash: 5 }], + ['an unknown field', { text: ONE_STAGE, base_hash: null, extra: 1 }], + ])('is a bad request with %s', async (_, body) => { + expectError(await stub().client.put(PATH, body), 400, 'bad_request'); + }); + + it('checks the name format before the root, and the root before base_hash', async () => { + expectError(await save(ONE_STAGE, 'abc', 'Other'), 400, 'bad_name'); + expectError( + await save(ONE_STAGE, 'abc', 'other'), + 403, + 'read_only_workflow', + ); + }); + + it('checks the size of the text before its content', async () => { + const reply = await save(`x: ${'#'.repeat(262_144)}`, HELLO_HASH); + expectError(reply, 413, 'too_large'); + }); +}); + +describe('PUT /api/workflow when the root has no file', () => { + const stub = useStub({ files: {} }); + const create = (base_hash: string | null) => + stub().client.put(PATH, { text: ONE_STAGE, base_hash }); + + it('creates the file with a base_hash of null', async () => { + const reply = await create(null); + expectContract(reply, 'PUT', PATH); + expect(reply.status).toBe(200); + expect((await load(stub().client.get(PATH))).text).toBe(ONE_STAGE); + }); + + it('creates it only once', async () => { + await create(null); + const reply = await create(null); + expect(reply.status).toBe(409); + expect(reply.body).toMatchObject({ + code: 'conflict', + current_hash: sha256Hex(ONE_STAGE), + }); + }); + + it('is a conflict without a current_hash when base_hash names a deleted file', async () => { + const reply = await create(HELLO_HASH); + expectContract(reply, 'PUT', PATH); + expect(reply.status).toBe(409); + expect(reply.body).toEqual(responseExample('PUT', PATH, 409, 'deleted')); + }); +}); diff --git a/workflow-canvas/stub-api/main.ts b/workflow-canvas/stub-api/main.ts new file mode 100644 index 00000000..bf833eba --- /dev/null +++ b/workflow-canvas/stub-api/main.ts @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: Apache-2.0 +/** + * `npm run stub`. `STUB_PORT` sets the port. `STUB_LAUNCH_CODE` sets the launch + * code, for tests only: it can be weaker than the contract asks. The flag + * `--with-dev-server` (set by `dev:stub`) changes the hint it prints, and lets + * the launch code be traded again, so that reloading the page in the browser + * is not a dead end: a reload drops both the token and the code. + */ +import { DEV_PAGE_PORT, originOf, stubPort } from './address.ts'; +import { exitOnConfigError } from './config-error.ts'; +import { startStub } from './http/server.ts'; + +// `npm run dev:stub` passes this flag, since it starts the dev server too. The +// end-to-end tests pass it as well, so a test can open the page again. +const withDevServer = process.argv.includes('--with-dev-server'); +const launchCode = process.env.STUB_LAUNCH_CODE || undefined; +const port = exitOnConfigError(() => stubPort()); +const stub = await startStub({ + port, + launchCode, + reusableLaunchCode: withDevServer, +}); + +console.log('Workflow canvas stub API (local development only, no real auth)'); +console.log(`Listening on ${stub.origin}`); +console.log( + withDevServer + ? 'The stub serves no page. The dev server runs beside it; open:' + : 'The stub serves no page. With `npm run dev` running, open:', +); +console.log(`${originOf(DEV_PAGE_PORT)}/#${stub.launchCode}`); + +for (const signal of ['SIGINT', 'SIGTERM'] as const) { + process.once(signal, () => void stub.close().then(() => process.exit(0))); +} diff --git a/workflow-canvas/stub-api/playwright-servers.test.ts b/workflow-canvas/stub-api/playwright-servers.test.ts new file mode 100644 index 00000000..03dd210e --- /dev/null +++ b/workflow-canvas/stub-api/playwright-servers.test.ts @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: Apache-2.0 +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { originOf } from './address.ts'; +import { apiTarget } from './target.ts'; +import { webServersFor } from './web-servers.ts'; + +const names = (env: NodeJS.ProcessEnv) => + webServersFor(apiTarget(env)).map((server) => server.name); + +describe('the Playwright web servers', () => { + it('start the app and the stub when no target is set', () => { + expect(names({})).toEqual(['app', 'stub-api']); + }); + + it('start only the app when a target is set', () => { + expect(names({ WC_API_TARGET: 'http://127.0.0.1:8080' })).toEqual(['app']); + }); + + it('wait for the stub at the port it listens on', () => { + const [, stub] = webServersFor(apiTarget({ STUB_PORT: '6000' })); + expect(stub).toMatchObject({ + url: `${originOf(6000)}/api/workflow`, + env: { STUB_PORT: '6000' }, + }); + }); + + it('give the stub port 80 as 80, not as an empty port', () => { + const [, stub] = webServersFor(apiTarget({ STUB_PORT: '80' })); + expect(stub).toMatchObject({ + url: 'http://127.0.0.1/api/workflow', + env: { STUB_PORT: '80' }, + }); + }); +}); + +// `playwright.shared.ts` reads the environment when it loads, so it is imported +// only here, after the test has set the environment. The static imports above +// read none, which keeps a mistyped shell variable from failing `npm test`. +describe('the shared Playwright config, read from the environment', () => { + afterEach(() => { + vi.unstubAllEnvs(); + vi.resetModules(); + }); + + /** Loads `playwright.shared.ts` fresh, as Playwright does, with `env` set. */ + async function loadShared(env: Record) { + for (const [name, value] of Object.entries(env)) vi.stubEnv(name, value); + vi.resetModules(); + return import('../playwright.shared.ts'); + } + + const serverNames = (webServer: unknown): string[] => + (webServer as Array<{ name: string }>).map((server) => server.name); + + it('starts the stub when WC_API_TARGET is unset', async () => { + const shared = await loadShared({ + WC_API_TARGET: undefined, + STUB_PORT: undefined, + }); + + expect(serverNames(shared.sharedConfig.webServer)).toEqual([ + 'app', + 'stub-api', + ]); + expect(shared.API_URL).toBe(originOf(5174)); + }); + + it('skips the stub and tests the named server when WC_API_TARGET is set', async () => { + const shared = await loadShared({ + WC_API_TARGET: 'http://127.0.0.1:8080', + STUB_PORT: undefined, + }); + + expect(serverNames(shared.sharedConfig.webServer)).toEqual(['app']); + expect(shared.API_URL).toBe('http://127.0.0.1:8080'); + }); +}); diff --git a/workflow-canvas/stub-api/target.test.ts b/workflow-canvas/stub-api/target.test.ts new file mode 100644 index 00000000..b4e52655 --- /dev/null +++ b/workflow-canvas/stub-api/target.test.ts @@ -0,0 +1,187 @@ +// SPDX-License-Identifier: Apache-2.0 +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { DEFAULT_PORT, originOf, StubPortError } from './address.ts'; +import { + apiTarget, + apiTargetOrExit, + ApiTargetError, + redact, +} from './target.ts'; + +describe('apiTarget', () => { + it('defaults to the stub on its default port', () => { + expect(apiTarget({})).toEqual({ + origin: originOf(DEFAULT_PORT), + isStub: true, + }); + }); + + it('follows STUB_PORT for the default stub', () => { + expect(apiTarget({ STUB_PORT: '6000' })).toEqual({ + origin: originOf(6000), + isStub: true, + }); + }); + + it('treats an empty value as unset', () => { + expect(apiTarget({ WC_API_TARGET: '' }).isStub).toBe(true); + }); + + it.each([ + ['http://127.0.0.1:8080', 'http://127.0.0.1:8080'], + ['http://localhost:9000/', 'http://localhost:9000'], + ['https://api.example.test', 'https://api.example.test'], + ['http://api.example.test:80', 'http://api.example.test'], + ])('takes %s as the origin %s, and not the stub', (value, origin) => { + expect(apiTarget({ WC_API_TARGET: value })).toEqual({ + origin, + isStub: false, + }); + }); + + it('writes the default stub origin the way a browser does, for port 80', () => { + expect(apiTarget({ STUB_PORT: '80' }).origin).toBe('http://127.0.0.1'); + }); + + it('uses the default port for an empty STUB_PORT', () => { + expect(apiTarget({ STUB_PORT: '' }).origin).toBe(originOf(DEFAULT_PORT)); + }); + + it.each(['abc', '5174x', '-1', '0', '65536', '1.5', ' 80', '1e3'])( + 'refuses STUB_PORT=%s with a message that names it', + (port) => { + const attempt = () => apiTarget({ STUB_PORT: port }); + expect(attempt).toThrow(StubPortError); + expect(attempt).toThrow('STUB_PORT'); + }, + ); + + it('ignores a bad STUB_PORT when a target is set', () => { + const env = { WC_API_TARGET: 'http://127.0.0.1:8080', STUB_PORT: 'abc' }; + expect(apiTarget(env).origin).toBe('http://127.0.0.1:8080'); + }); + + it('ignores STUB_PORT when a target is set', () => { + const env = { WC_API_TARGET: 'http://127.0.0.1:8080', STUB_PORT: '6000' }; + expect(apiTarget(env).origin).toBe('http://127.0.0.1:8080'); + }); + + it.each([ + ['not a url', 'not a URL'], + ['127.0.0.1:8080', 'not a URL'], + ['localhost:8080', 'scheme must be http or https'], + ['ftp://127.0.0.1:21', 'scheme must be http or https'], + ['http://127.0.0.1:8080/api', 'no path'], + ['http://127.0.0.1:8080?x=1', 'no path'], + ['http://127.0.0.1:8080/#top', 'no path'], + ['http://user:secret@127.0.0.1:8080', 'credentials'], + ])('refuses %s', (value, reason) => { + const attempt = () => apiTarget({ WC_API_TARGET: value }); + expect(attempt).toThrow(ApiTargetError); + expect(attempt).toThrow(reason); + expect(attempt).toThrow('WC_API_TARGET'); + }); +}); + +describe('the message of a refused target', () => { + const message = (value: string): string => { + try { + apiTarget({ WC_API_TARGET: value }); + } catch (error) { + return (error as Error).message; + } + throw new Error('expected a refusal'); + }; + + it.each([ + 'http://user:secret@127.0.0.1:8080', + 'http://:secret@127.0.0.1:8080', + 'user:secret@127.0.0.1:8080', + 'http://127.0.0.1:8080/?token=secret', + 'http://127.0.0.1:8080/#secret', + ' http://user:secret@127.0.0.1:8080', + 'http://user:secret@127.0.0.1:8080 ', + 'http://user:sec/ret@127.0.0.1:8080', + 'http://user:sec#ret@127.0.0.1:8080', + 'http://user:sec?ret@127.0.0.1:8080', + 'user:sec/ret@127.0.0.1:8080', + 'http://127.0.0.1:8080/?k=a@secret', + 'http://127.0.0.1:8080/#a@secret', + 'http://u:x@secret/more@h:1', + 'http://u:p@ss/word@h:1', + ])('does not repeat the secret in %s', (value) => { + expect(message(value)).not.toContain('secret'); + }); + + it('still shows the part that helps', () => { + expect(message('http://user:secret@127.0.0.1:8080')).toContain( + 'http://***@127.0.0.1:8080', + ); + }); + + it.each([ + ['http://u:p@h:1', 'http://***@h:1'], + ['u:p@h:1', '***@h:1'], + ['http://h:1/a?x=1#y', 'http://h:1/a?...'], + [' http://u:p@h:1 ', 'http://***@h:1'], + ['http://u:p@ss@h:1', 'http://***@h:1'], + ['http://u:p@h:1/a?k=x@y', 'http://***@h:1/a?...'], + // An `@` only after the authority ends: it may be a password with a + // `/`, `#` or `?`, or an `@` in the query, so all of it is masked. + ['http://u:p/q@h:1', 'http://***'], + ['http://u:p#q@h:1', 'http://***'], + ['http://u:p?q@h:1', 'http://***'], + ['user:sec/ret@h:1', '***'], + ['http://h:1/?k=a@secret', 'http://***'], + ['http://h:1/#a@secret', 'http://***'], + ['http://h:1/p@q', 'http://***'], + // A password with both an `@` and a `/`: the first `@` is not the end of + // the userinfo, so the text after it may be secret. + ['http://u:x@secret/more@h:1', 'http://***'], + ['http://u:p@ss/word@h:1', 'http://***'], + ['HTTP://u:p@h:1', 'HTTP://***@h:1'], + ['http://h:1', 'http://h:1'], + ['nope', 'nope'], + ])('redacts %s to %s', (value, shown) => { + expect(redact(value)).toBe(shown); + }); +}); + +describe('apiTargetOrExit', () => { + afterEach(() => vi.restoreAllMocks()); + + it('returns a good target without exiting', () => { + const exit = vi.spyOn(process, 'exit'); + expect(apiTargetOrExit({}).isStub).toBe(true); + expect(exit).not.toHaveBeenCalled(); + }); + + it('prints one line and exits with 1 for a bad value', () => { + const exit = vi.spyOn(process, 'exit').mockImplementation((() => { + throw new Error('exit called'); + }) as never); + const print = vi.spyOn(console, 'error').mockImplementation(() => {}); + + expect(() => apiTargetOrExit({ WC_API_TARGET: 'nope' })).toThrow( + 'exit called', + ); + + expect(exit).toHaveBeenCalledWith(1); + const [message] = print.mock.calls[0] as [string]; + expect(message).toContain('WC_API_TARGET="nope" is not usable'); + expect(message).not.toMatch(/\n\s+at /); + }); + + it('prints one line and exits with 1 for a bad STUB_PORT', () => { + const exit = vi.spyOn(process, 'exit').mockImplementation((() => { + throw new Error('exit called'); + }) as never); + const print = vi.spyOn(console, 'error').mockImplementation(() => {}); + + expect(() => apiTargetOrExit({ STUB_PORT: 'abc' })).toThrow('exit called'); + + expect(exit).toHaveBeenCalledWith(1); + const [message] = print.mock.calls[0] as [string]; + expect(message).toMatch(/^error: STUB_PORT="abc" is not usable: [^\n]*$/); + }); +}); diff --git a/workflow-canvas/stub-api/target.ts b/workflow-canvas/stub-api/target.ts new file mode 100644 index 00000000..2f1fe188 --- /dev/null +++ b/workflow-canvas/stub-api/target.ts @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: Apache-2.0 +import { originOf, stubPort } from './address.ts'; +import { ConfigError, exitOnConfigError } from './config-error.ts'; + +/** The variable that names the server behind `/api`. Unset means the stub. */ +export const TARGET_VARIABLE = 'WC_API_TARGET'; + +/** Where `/api` goes: the origin of a server, and whether it is the stub. */ +export interface ApiTarget { + origin: string; + isStub: boolean; +} + +/** + * `value` without what may be secret in it. The authority runs from `://` to + * the first `/`, `?` or `#`. If it holds an `@`, the text up to the last `@` + * is the userinfo and is masked; the host stays. Text after a `?` or `#` goes. + * + * A password may hold an unencoded `/`, `?` or `#`, so `http://u:p/q@h` and + * `http://h:1/?k=a@x` cannot be told apart: both have an `@` only after the + * authority ends. Then everything after `://` is masked (`http://***`), which + * hides the host as well. Hiding too much is the safe error. + * + * The same holds when the authority has an `@` and the path after it has one + * too (`http://u:x@secret/more@h:1`): the password may hold a `/`, so the + * "host" could be part of it. Then everything after `://` is masked. + */ +export function redact(value: string): string { + const text = value.trim(); + const [, scheme, rest = ''] = + /^(?:([a-z][a-z0-9+.-]*):\/\/)?(.*)$/is.exec(text) ?? []; + const prefix = scheme ? `${scheme}://` : ''; + const end = rest.search(/[/?#]/); + const authority = end < 0 ? rest : rest.slice(0, end); + const tail = end < 0 ? '' : rest.slice(end); + const at = authority.lastIndexOf('@'); + const path = tail.replace(/[?#].*$/s, ''); + if ((at < 0 && tail.includes('@')) || path.includes('@')) { + return `${prefix}***`; + } + const host = at < 0 ? authority : `***@${authority.slice(at + 1)}`; + return `${prefix}${host}${tail.replace(/([?#]).*$/s, '$1...')}`; +} + +/** A `WC_API_TARGET` value that is not an http(s) origin. */ +export class ApiTargetError extends ConfigError { + constructor(value: string, reason: string) { + super( + `${TARGET_VARIABLE}=${JSON.stringify(redact(value))} is not usable: ${reason}. ` + + `Use an origin such as http://127.0.0.1:8080, or unset it for the stub.`, + ); + this.name = 'ApiTargetError'; + } +} + +/** The origin of `value`, or an `ApiTargetError` that says what is wrong. */ +function parseOrigin(value: string): string { + let url: URL; + try { + url = new URL(value); + } catch { + throw new ApiTargetError(value, 'it is not a URL'); + } + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + throw new ApiTargetError(value, 'the scheme must be http or https'); + } + if (url.username || url.password) { + throw new ApiTargetError(value, 'it must not carry credentials'); + } + if (url.pathname !== '/' || url.search || url.hash) { + throw new ApiTargetError(value, 'it must be an origin, with no path'); + } + return url.origin; +} + +/** + * The server `/api` goes to: `WC_API_TARGET` if it is set, else the stub. + * An empty value counts as unset. Throws `ApiTargetError` for a bad target and + * `StubPortError` for a bad `STUB_PORT` (which only matters for the stub). + */ +export function apiTarget(env: NodeJS.ProcessEnv = process.env): ApiTarget { + const value = env[TARGET_VARIABLE]; + if (!value) return { origin: originOf(stubPort(env)), isStub: true }; + return { origin: parseOrigin(value), isStub: false }; +} + +/** + * Like `apiTarget`, but a bad `WC_API_TARGET` or `STUB_PORT` prints its message + * and exits with 1, so a config file shows one clear line, not a stack trace. + */ +export function apiTargetOrExit( + env: NodeJS.ProcessEnv = process.env, +): ApiTarget { + return exitOnConfigError(() => apiTarget(env)); +} diff --git a/workflow-canvas/stub-api/test-support/contract.ts b/workflow-canvas/stub-api/test-support/contract.ts new file mode 100644 index 00000000..15a69888 --- /dev/null +++ b/workflow-canvas/stub-api/test-support/contract.ts @@ -0,0 +1,154 @@ +// SPDX-License-Identifier: Apache-2.0 +import SwaggerParser from '@apidevtools/swagger-parser'; +import Ajv2020 from 'ajv/dist/2020.js'; +import addFormats from 'ajv-formats'; +import { fileURLToPath } from 'node:url'; +import { expect } from 'vitest'; +import type { Reply } from './stub-client.ts'; + +type Json = Record; +interface Media { + schema?: Json; + example?: unknown; + examples?: Record; +} +interface Operation { + requestBody?: { content: Record }; + responses: Record }>; +} +interface Spec { + info: { description: string }; + paths: Record>; + components: { schemas: Record }; +} + +const SPEC_FILE = fileURLToPath( + new URL('../../api/openapi.yaml', import.meta.url), +); +const spec = (await SwaggerParser.dereference(SPEC_FILE)) as unknown as Spec; +const ajv = addFormats(new Ajv2020({ strict: true, allowUnionTypes: true })); + +function operationOf(method: string, path: string): Operation { + const operation = spec.paths[path]?.[method.toLowerCase()]; + if (!operation) throw new Error(`api/openapi.yaml has no ${method} ${path}`); + return operation; +} + +function expectBodyToMatch(schema: Json | undefined, body: unknown): void { + expect(schema, 'the contract gives a schema for this body').toBeDefined(); + const validate = ajv.compile(schema ?? {}); + const valid = validate(body); + expect( + valid, + `${ajv.errorsText(validate.errors)} in ${JSON.stringify(body)}`, + ).toBe(true); +} + +function expectVersionHeaders(reply: Reply): void { + expect(reply.headers['x-konductor-api']).toBe('1'); + expect(reply.headers['cache-control']).toBe('no-store'); + expect(reply.headers['content-type']).toBe('application/json'); + expect(reply.headers['access-control-allow-origin']).toBeUndefined(); + expect(reply.headers['set-cookie']).toBeUndefined(); +} + +/** + * Checks a reply against api/openapi.yaml: the operation declares its status, + * and the body matches the schema of that response. + */ +export function expectContract( + reply: Reply, + method: string, + path: string, +): void { + const declared = operationOf(method, path).responses[String(reply.status)]; + expect(declared, `${method} ${path} declares ${reply.status}`).toBeDefined(); + expectVersionHeaders(reply); + expectBodyToMatch( + declared?.content?.['application/json']?.schema, + reply.body, + ); +} + +/** Checks an error that the contract names for every path, such as a 404. */ +export function expectError(reply: Reply, status: number, code: string): void { + expect(reply.status).toBe(status); + expectVersionHeaders(reply); + expectBodyToMatch(spec.components.schemas.Error, reply.body); + expect(reply.body).toMatchObject({ code }); +} + +/** The named request example of an operation. */ +export function requestExample( + method: string, + path: string, + name: string, +): unknown { + const media = operationOf(method, path).requestBody?.content[ + 'application/json' + ]; + return media?.examples?.[name]?.value ?? media?.example; +} + +/** The example of a response, by status and name. */ +export function responseExample( + method: string, + path: string, + status: number, + name?: string, +): unknown { + const media = operationOf(method, path).responses[String(status)]?.content?.[ + 'application/json' + ]; + return name === undefined ? media?.example : media?.examples?.[name]?.value; +} + +/** The names of the response examples of an operation and status. */ +export function responseExampleNames( + method: string, + path: string, + status: number, +): string[] { + const media = operationOf(method, path).responses[String(status)]?.content?.[ + 'application/json' + ]; + return Object.keys(media?.examples ?? {}); +} + +/** The description of the whole API, which holds the rules in prose. */ +export function contractDescription(): string { + return spec.info.description; +} + +/** A named schema of the contract, with its `$ref` pointers resolved. */ +export function schemaOf(name: string): Json { + const schema = spec.components.schemas[name]; + if (!schema) throw new Error(`api/openapi.yaml has no schema ${name}`); + return schema; +} + +/** Every error example of the contract: the status, and the `code` it shows. */ +export function errorExamples(): { status: number; code: string }[] { + return Object.values(spec.paths).flatMap((methods) => + Object.values(methods).flatMap((operation) => + Object.entries(operation.responses).flatMap(([status, response]) => { + const media = response.content?.['application/json']; + const values = [ + media?.example, + ...Object.values(media?.examples ?? {}).map((e) => e.value), + ]; + return values + .filter((v): v is { code: string } => hasCode(v)) + .map((v) => ({ status: Number(status), code: v.code })); + }), + ), + ); +} + +function hasCode(value: unknown): value is { code: string } { + return ( + typeof value === 'object' && + value !== null && + typeof (value as { code?: unknown }).code === 'string' + ); +} diff --git a/workflow-canvas/stub-api/test-support/fake-clock.ts b/workflow-canvas/stub-api/test-support/fake-clock.ts new file mode 100644 index 00000000..b4935829 --- /dev/null +++ b/workflow-canvas/stub-api/test-support/fake-clock.ts @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: Apache-2.0 +import type { Clock } from '../core/clock.ts'; + +/** A clock that only moves when a test moves it, so no test waits. */ +export class FakeClock implements Clock { + private time: number; + + constructor(start = Date.UTC(2026, 9, 24, 4, 12, 5)) { + this.time = start; + } + + now(): number { + return this.time; + } + + advance(ms: number): void { + this.time += ms; + } +} + +/** Run id suffixes `00001`, `00002` and so on. */ +export function countingSuffix(): () => string { + let count = 0; + return () => String(++count).padStart(5, '0'); +} diff --git a/workflow-canvas/stub-api/test-support/harness.ts b/workflow-canvas/stub-api/test-support/harness.ts new file mode 100644 index 00000000..4c5686df --- /dev/null +++ b/workflow-canvas/stub-api/test-support/harness.ts @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: Apache-2.0 +import { afterEach, beforeEach } from 'vitest'; +import { + startStub, + type RunningStub, + type StubOptions, +} from '../http/server.ts'; +import { countingSuffix, FakeClock } from './fake-clock.ts'; +import { StubClient } from './stub-client.ts'; + +export const LAUNCH_CODE = 'test-launch-code'; + +/** A stub on a free port, with a clock and a client that belong to it. */ +export interface TestStub { + stub: RunningStub; + clock: FakeClock; + client: StubClient; +} + +/** Starts a stub whose clock and run ids the test controls. */ +export async function openStub(options: StubOptions = {}): Promise { + const clock = new FakeClock(); + const stub = await startStub({ + port: 0, + launchCode: LAUNCH_CODE, + clock, + runSuffix: countingSuffix(), + ...options, + }); + return { stub, clock, client: new StubClient(stub.port) }; +} + +/** Trades the launch code for a token and keeps it on the client. */ +export async function signIn({ client }: TestStub): Promise { + const reply = await client.post('/api/session', { code: LAUNCH_CODE }); + client.token = (reply.body as { token: string }).token; +} + +/** + * Gives each test in the enclosing `describe` its own stub, signed in unless + * `signedIn` is false, and stops it afterwards. + */ +export function useStub( + options: StubOptions = {}, + signedIn = true, +): () => TestStub { + let current: TestStub | undefined; + beforeEach(async () => { + current = await openStub(options); + if (signedIn) await signIn(current); + }); + afterEach(async () => { + await current?.stub.close(); + }); + return () => { + if (!current) throw new Error('useStub: no test is running'); + return current; + }; +} diff --git a/workflow-canvas/stub-api/test-support/stub-client.ts b/workflow-canvas/stub-api/test-support/stub-client.ts new file mode 100644 index 00000000..3c7f64ac --- /dev/null +++ b/workflow-canvas/stub-api/test-support/stub-client.ts @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: Apache-2.0 +import { request, type IncomingHttpHeaders } from 'node:http'; + +export interface Reply { + status: number; + headers: IncomingHttpHeaders; + /** The parsed JSON body, or `undefined` if there was none. */ + body: unknown; +} + +export interface CallOptions { + body?: unknown; + /** A body sent as it is, for text that is not JSON. */ + rawBody?: string | Buffer; + /** Replaces a default header; `undefined` removes it. */ + headers?: Record; +} + +/** + * Calls the stub like a well-behaved client, with the server's own `Origin`, + * a JSON content type and the bearer token. A test may override each per call. + */ +export class StubClient { + token: string | undefined; + private readonly port: number; + + constructor(port: number) { + this.port = port; + } + + get(path: string, options?: CallOptions): Promise { + return this.call('GET', path, options); + } + + put(path: string, body: unknown, options?: CallOptions): Promise { + return this.call('PUT', path, { ...options, body }); + } + + post(path: string, body: unknown, options?: CallOptions): Promise { + return this.call('POST', path, { ...options, body }); + } + + call( + method: string, + path: string, + options: CallOptions = {}, + ): Promise { + const payload = + options.rawBody ?? + (options.body === undefined ? undefined : JSON.stringify(options.body)); + const headers = { + ...this.defaultHeaders(method), + ...options.headers, + }; + const defined = Object.entries(headers).filter( + (entry): entry is [string, string] => entry[1] !== undefined, + ); + return new Promise((resolve, reject) => { + const req = request( + { + host: '127.0.0.1', + port: this.port, + method, + path, + headers: Object.fromEntries(defined), + }, + (res) => { + const chunks: Buffer[] = []; + res.on('data', (chunk: Buffer) => chunks.push(chunk)); + res.on('end', () => { + const text = Buffer.concat(chunks).toString('utf8'); + const body = text === '' ? undefined : JSON.parse(text); + resolve({ + status: res.statusCode ?? 0, + headers: res.headers, + body, + }); + }); + }, + ); + req.on('error', reject); + req.end(payload); + }); + } + + private defaultHeaders(method: string): Record { + const withBody = method !== 'GET'; + return { + origin: withBody ? `http://127.0.0.1:${this.port}` : undefined, + 'content-type': withBody ? 'application/json' : undefined, + authorization: this.token ? `Bearer ${this.token}` : undefined, + }; + } +} diff --git a/workflow-canvas/stub-api/test-support/workflows.ts b/workflow-canvas/stub-api/test-support/workflows.ts new file mode 100644 index 00000000..de9a7ee7 --- /dev/null +++ b/workflow-canvas/stub-api/test-support/workflows.ts @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: Apache-2.0 + +/** A valid `hello` workflow with one stage. */ +export function oneStageWorkflow(stage = 'a', agent = 'b'): string { + return `name: hello\nintent: x\nstages:\n - name: ${stage}\n agent: ${agent}\n`; +} diff --git a/workflow-canvas/stub-api/together.test.ts b/workflow-canvas/stub-api/together.test.ts new file mode 100644 index 00000000..88e9d3a8 --- /dev/null +++ b/workflow-canvas/stub-api/together.test.ts @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: Apache-2.0 +import { spawn, type ChildProcess } from 'node:child_process'; +import { once } from 'node:events'; +import { describe, expect, it, vi } from 'vitest'; +import { + environmentOf, + runTogether, + type Command, + type TogetherDeps, +} from './together.ts'; + +const node = (script: string, env?: NodeJS.ProcessEnv): Command => ({ + file: process.execPath, + args: ['-e', script], + env, +}); +// Runs until it is stopped, and exits cleanly when asked to. +const FOREVER = `setInterval(() => {}, 1 << 30); +process.on('SIGTERM', () => process.exit(0));`; + +describe('runTogether', () => { + it('stops the others when one exits, and reports its code', async () => { + const { done } = runTogether([node('process.exit(3)'), node(FOREVER)]); + expect(await done).toBe(3); + }); + + it('reports 0 when every command succeeds', async () => { + const { done } = runTogether([node(''), node('')]); + expect(await done).toBe(0); + }); + + it('stops every command on stop, and that is not a failure', async () => { + const { stop, done } = runTogether([node(FOREVER), node(FOREVER)]); + stop(); + expect(await done).toBe(0); + }); + + it('counts a command that dies of another signal as a failure', async () => { + const { done } = runTogether([ + node(`process.kill(process.pid, 'SIGKILL')`), + node(FOREVER), + ]); + expect(await done).toBe(1); + }); + + it('counts a command that cannot start as a failure', async () => { + const { done } = runTogether([ + { file: '/no/such/command', args: [] }, + node(FOREVER), + ]); + expect(await done).toBe(1); + }); + + it('gives each command its own environment', async () => { + const check = `process.exit(process.env.TOGETHER_X === 'on' ? 0 : 7)`; + const { done } = runTogether([node(check, { TOGETHER_X: 'on' })]); + expect(await done).toBe(0); + }); +}); + +describe('runTogether escalation', () => { + // Ignores SIGTERM, and tells its parent over IPC once it does. It exits when + // the IPC channel closes, which happens when its parent dies, so a test run + // that is killed cannot leave it behind. + const IGNORES_TERM = `process.on('SIGTERM', () => {}); +process.on('disconnect', () => process.exit(0)); +process.send?.('ready'); setInterval(() => {}, 1 << 30);`; + + /** Deps whose timer waits for the test to fire it, and that record children. */ + function manualDeps() { + const children: ChildProcess[] = []; + const timers: Array<{ run: () => void; ms: number; cancel: () => void }> = + []; + const deps: TogetherDeps = { + graceMs: 1234, + // An IPC channel lets a child tell the test that it is ready. + spawn: ((file: string, args: string[], options: object) => { + const child = spawn(file, args, { + ...options, + stdio: ['inherit', 'inherit', 'inherit', 'ipc'], + }); + children.push(child); + return child; + }) as typeof spawn, + schedule: (run, ms) => { + const cancel = vi.fn(); + timers.push({ run, ms, cancel }); + return cancel; + }, + }; + return { deps, children, timers }; + } + + it('kills a command that ignores SIGTERM once the grace time is over', async () => { + const { deps, children, timers } = manualDeps(); + const { stop, done } = runTogether([node(IGNORES_TERM)], deps); + await once(children[0]!, 'message'); + + stop(); + stop(); + + expect(timers).toHaveLength(1); + expect(timers[0]?.ms).toBe(1234); + expect(children[0]?.signalCode).toBeNull(); + timers[0]?.run(); + await done; + expect(children[0]?.signalCode).toBe('SIGKILL'); + }); + + it('does not kill commands that exit on SIGTERM, and drops the timer', async () => { + const { deps, timers } = manualDeps(); + const { stop, done } = runTogether([node(FOREVER), node(FOREVER)], deps); + + stop(); + await done; + + expect(timers).toHaveLength(1); + expect(timers[0]?.cancel).toHaveBeenCalledTimes(1); + }); + + it('starts no timer once every command has exited', async () => { + const { deps, timers } = manualDeps(); + const { stop, done } = runTogether([node('')], deps); + await done; + timers.length = 0; + + stop(); + + expect(timers).toHaveLength(0); + }); +}); + +describe('the environment of a command', () => { + it('is the base environment with the variables of the command on top', () => { + const command = { file: 'x', args: [], env: { A: '', B: 'new' } }; + expect(environmentOf(command, { A: 'old', B: 'old', C: 'kept' })).toEqual({ + A: '', + B: 'new', + C: 'kept', + }); + }); +}); diff --git a/workflow-canvas/stub-api/together.ts b/workflow-canvas/stub-api/together.ts new file mode 100644 index 00000000..ce1bab54 --- /dev/null +++ b/workflow-canvas/stub-api/together.ts @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: Apache-2.0 +import { spawn, type ChildProcess } from 'node:child_process'; + +/** A command to run beside the others, with the environment it gets. */ +export interface Command { + file: string; + args: string[]; + env?: NodeJS.ProcessEnv; +} + +/** Commands that run together: `stop` ends them all, `done` is the exit code. */ +export interface Together { + stop: () => void; + done: Promise; +} + +/** The parts of `runTogether` that a test replaces. */ +export interface TogetherDeps { + spawn: typeof spawn; + /** Runs `run` once after `ms`, and returns a function that cancels it. */ + schedule: (run: () => void, ms: number) => () => void; + /** How long a command may take to exit after SIGTERM before SIGKILL. */ + graceMs: number; +} + +const STOP_SIGNALS: NodeJS.Signals[] = ['SIGINT', 'SIGTERM']; + +const realDeps: TogetherDeps = { + spawn, + schedule: (run, ms) => { + const timer = setTimeout(run, ms).unref(); + return () => clearTimeout(timer); + }, + graceMs: 5000, +}; + +/** The environment a command runs with: the base, then its own variables. */ +export function environmentOf( + command: Command, + base: NodeJS.ProcessEnv = process.env, +): NodeJS.ProcessEnv { + return { ...base, ...command.env }; +} + +/** The exit code of a child. Being stopped by a stop signal is not a failure. */ +function exitCodeOf( + code: number | null, + signal: NodeJS.Signals | null, +): number { + if (code !== null) return code; + return signal && STOP_SIGNALS.includes(signal) ? 0 : 1; +} + +function isRunning(child: ChildProcess): boolean { + return child.exitCode === null && child.signalCode === null; +} + +/** + * Runs the commands with the terminal's output. When one exits, or `stop` is + * called, the rest get SIGTERM, and SIGKILL after `graceMs` if they are still + * running. `done` settles once every command has exited, with the code of the + * first one to exit, or 0 after a `stop`. + */ +export function runTogether( + commands: Command[], + deps: Partial = {}, +): Together { + const { spawn, schedule, graceMs } = { ...realDeps, ...deps }; + const children = commands.map((command) => + spawn(command.file, command.args, { + stdio: 'inherit', + env: environmentOf(command), + }), + ); + let firstCode: number | undefined; + let cancelKill: (() => void) | undefined; + + const stop = (): void => { + const running = children.filter(isRunning); + if (running.length === 0) return; + for (const child of running) child.kill('SIGTERM'); + cancelKill ??= schedule(() => { + for (const child of children.filter(isRunning)) child.kill('SIGKILL'); + }, graceMs); + }; + const done = Promise.all( + children.map( + (child) => + new Promise((resolve) => { + child.once('error', () => { + firstCode ??= 1; + stop(); + resolve(); + }); + child.once('close', (code, signal) => { + firstCode ??= exitCodeOf(code, signal); + stop(); + resolve(); + }); + }), + ), + ).then(() => { + cancelKill?.(); + return firstCode ?? 0; + }); + + return { stop, done }; +} diff --git a/workflow-canvas/stub-api/web-servers.ts b/workflow-canvas/stub-api/web-servers.ts new file mode 100644 index 00000000..5ba0c6b9 --- /dev/null +++ b/workflow-canvas/stub-api/web-servers.ts @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: Apache-2.0 +import type { PlaywrightTestConfig } from '@playwright/test'; +import { LOOPBACK, originOf, portOf } from './address.ts'; +import type { ApiTarget } from './target.ts'; + +/** The servers Playwright starts. This module reads no environment. */ +export type WebServer = Extract< + PlaywrightTestConfig['webServer'], + { command: string } +>; + +const APP_PORT = 4173; +export const APP_URL = originOf(APP_PORT); +/** A real server must be started with this launch code. */ +export const STUB_LAUNCH_CODE = 'e2e-launch-code'; + +const appServer: WebServer = { + name: 'app', + command: `npm run build && npx vite preview --host ${LOOPBACK} --port ${APP_PORT} --strictPort`, + url: APP_URL, + reuseExistingServer: false, + timeout: 120_000, +}; + +// `--with-dev-server` lets a test open the page again with the same launch +// code, as `dev:stub` does for a person who reloads the page. +const stubServer = (target: ApiTarget): WebServer => ({ + name: 'stub-api', + command: 'npm run stub -- --with-dev-server', + env: { + STUB_PORT: String(portOf(target.origin)), + STUB_LAUNCH_CODE, + }, + // The stub answers 401 here until a session exists, which counts as up. + url: `${target.origin}/api/workflow`, + reuseExistingServer: false, + timeout: 30_000, +}); + +/** + * The servers a run depends on, started before and stopped after it. The app + * is served from a production build. The stub API starts after the app build, + * because its launch code works for 60 seconds. It starts only when it is the + * target; a real server is started by whoever set `WC_API_TARGET`. + */ +export function webServersFor(target: ApiTarget): WebServer[] { + return target.isStub ? [appServer, stubServer(target)] : [appServer]; +} diff --git a/workflow-canvas/tsconfig.node.json b/workflow-canvas/tsconfig.node.json index 730668e0..29e4795f 100644 --- a/workflow-canvas/tsconfig.node.json +++ b/workflow-canvas/tsconfig.node.json @@ -2,7 +2,14 @@ "extends": "./tsconfig.json", "compilerOptions": { "lib": ["ES2023"], - "types": ["node"] + "types": ["node"], + "allowImportingTsExtensions": true, + "erasableSyntaxOnly": true }, - "include": ["vite.config.ts", "playwright.*.ts", "api/**/*.ts"] + "include": [ + "vite.config.ts", + "playwright.*.ts", + "api/**/*.ts", + "stub-api/**/*.ts" + ] } diff --git a/workflow-canvas/vite.config.ts b/workflow-canvas/vite.config.ts index a5bde417..8feb54e0 100644 --- a/workflow-canvas/vite.config.ts +++ b/workflow-canvas/vite.config.ts @@ -1,16 +1,62 @@ // SPDX-License-Identifier: Apache-2.0 import react from '@vitejs/plugin-react'; import { defineConfig } from 'vitest/config'; +import { DEV_PAGE_PORT, LOOPBACK } from './stub-api/address.ts'; +import { apiProxy } from './stub-api/dev-proxy.ts'; +import { apiTargetOrExit } from './stub-api/target.ts'; -export default defineConfig({ - plugins: [react()], - // Relative asset paths so the built app works from any mount point. - base: './', - server: { host: '127.0.0.1', port: 5173, strictPort: true }, - test: { - environment: 'jsdom', - include: ['src/**/*.test.{ts,tsx}', 'api/**/*.test.ts'], - setupFiles: ['src/test-support/setup.ts'], - css: false, - }, +/** + * Where `/api` goes, or `undefined` for a command that serves no page. A bad + * `WC_API_TARGET` then stops only `dev` and `preview`: `build` and Vitest + * (mode `test`) never use the proxy, so they never read the variable. The unit + * tests of the Playwright config set the variable themselves, so a bad value in + * the shell does not reach them either. + */ +function proxyTarget(command: string, mode: string): string | undefined { + return command === 'serve' && mode !== 'test' + ? apiTargetOrExit().origin + : undefined; +} + +// The page always calls the relative `/api`. Only the target changes: the stub +// by default, or the server named by `WC_API_TARGET`. +export default defineConfig(({ command, mode }) => { + const apiOrigin = proxyTarget(command, mode); + return { + plugins: [react()], + // Relative asset paths so the built app works from any mount point. + base: './', + server: { + host: LOOPBACK, + port: DEV_PAGE_PORT, + strictPort: true, + // The page and the API share one origin in the browser. The proxy swaps + // the page's `Origin` for the API's own and passes any other through. + proxy: apiOrigin ? apiProxy(apiOrigin) : undefined, + }, + test: { + // The UI runs in jsdom with the browser setup file. The API contract and + // the stub run in plain Node, so a new file there needs no pragma. + projects: [ + { + extends: true, + test: { + name: 'ui', + environment: 'jsdom', + include: ['src/**/*.test.{ts,tsx}'], + setupFiles: ['src/test-support/setup.ts'], + css: false, + }, + }, + { + extends: true, + test: { + name: 'node', + environment: 'node', + include: ['api/**/*.test.ts', 'stub-api/**/*.test.ts'], + }, + }, + ], + }, + }; });