diff --git a/.dockerignore b/.dockerignore index 4fac5e2..298fb01 100644 --- a/.dockerignore +++ b/.dockerignore @@ -29,6 +29,15 @@ packages/adapters/*/* !packages/mcp/ !packages/mcp/src/ !packages/mcp/src/** +!packages/sdk/ +!packages/sdk/src/ +!packages/sdk/src/** +!packages/portal/ +!packages/portal/src/ +!packages/portal/src/** +!packages/server/ +!packages/server/src/ +!packages/server/src/** !packages/ci/ !packages/ci/dist/ !packages/ci/dist/** diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6e23743..3f1ddcb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -244,6 +244,8 @@ jobs: run: docker build -f Containerfile --target cli -t adrkit-cli:ci . - name: Build the dedicated MCP target run: docker build -f Containerfile --target mcp -t adrkit-mcp:ci . + - name: Build the dedicated read-only server target + run: docker build -f Containerfile --target server -t adrkit-server:ci . - name: Build the dedicated governing-decisions Action target run: docker build -f Containerfile --target ci -t adrkit-action:ci . - name: Build the dedicated queue Action target @@ -255,6 +257,8 @@ jobs: 'test -f /opt/adrkit/adr.js && test ! -e /opt/adrkit/adrkit-mcp.js && test ! -e /opt/adrkit/ci.js' docker run --rm --entrypoint sh adrkit-mcp:ci -c \ 'test -f /opt/adrkit/adrkit-mcp.js && test ! -e /opt/adrkit/adr.js && test ! -e /opt/adrkit/ci.js' + docker run --rm --entrypoint sh adrkit-server:ci -c \ + 'test -f /opt/adrkit/server.js && test ! -e /opt/adrkit/adr.js && test ! -e /opt/adrkit/adrkit-mcp.js' docker run --rm --entrypoint sh adrkit-action:ci -c \ 'test -f /opt/adrkit/ci.js && test ! -e /opt/adrkit/adr.js && test ! -e /opt/adrkit/queue-action.js' docker run --rm --entrypoint sh adrkit-queue-action:ci -c \ @@ -274,6 +278,35 @@ jobs: adrkit:ci lint --dir docs/adr node scripts/smoke-container.mjs adrkit-mcp:ci node scripts/smoke-container.mjs adrkit:ci mcp + # ADR-0055 clauses 12 to 14: the server keeps every part of the MCP posture but + # `--network none` (it listens): a read-only root file system and a `:ro` corpus + # mount. It binds 127.0.0.1 unless told otherwise, so the smoke sets the host and + # publishes the port on the runner's loopback only. + - name: Smoke test the read-only server through the all-in-one image + run: | + set -euo pipefail + corpora="$RUNNER_TEMP/corpora" + mkdir -p "$corpora/adrkit/docs" + cp -R docs/adr "$corpora/adrkit/docs/adr" + commit="$(git rev-parse HEAD)" + printf '{"commit":"%s"}\n' "$commit" > "$corpora/adrkit/.adrkit-corpus.json" + docker run -d --name adrkit-server --read-only -p 127.0.0.1:18080:8080 \ + -v "$corpora:/corpora:ro" \ + -e ADRKIT_SERVER_ROOT=/corpora -e ADRKIT_SERVER_CORPORA=adrkit \ + -e ADRKIT_SERVER_HOST=0.0.0.0 \ + adrkit:ci server + trap 'docker logs adrkit-server; docker rm -f adrkit-server >/dev/null' EXIT + for _ in $(seq 1 30); do + curl -fsS http://127.0.0.1:18080/healthz && break + sleep 1 + done + base=http://127.0.0.1:18080 + curl -fsS "$base/api/v1/corpora" \ + | jq -e --arg commit "$commit" '.data == [{corpus: "adrkit", commit: $commit}]' + curl -fsS "$base/api/v1/corpora/adrkit/records" | jq -e --arg commit "$commit" '.commit == $commit and (.data | length) > 0' + test "$(curl -s -o /dev/null -w '%{http_code}' -X POST "$base/api/v1/corpora/adrkit/records")" = 405 + curl -fsS -D - -o /dev/null "$base/portal/adrkit/" | grep -i "^content-security-policy:.*frame-ancestors 'none'" + docker logs adrkit-server 2>&1 | grep -F 'This server authenticates no one' - name: Exercise the governing-decisions Action event boundary run: | set -euo pipefail diff --git a/AGENTS.md b/AGENTS.md index 85e72cd..94a06a4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1168,8 +1168,9 @@ GitHub release. Things that are load-bearing: -- **Only the all-in-one `adrkit` target is published.** The `cli`, `mcp`, `ci`, - and `queue-action` targets exist for local isolation, SBOM, and policy checks; +- **Only the all-in-one `adrkit` target is published.** The `cli`, `mcp`, + `server`, `ci`, and `queue-action` targets exist for local isolation, SBOM, + and policy checks; each final stage contains only its own executable. Publishing five packages would multiply visibility, retention, and rollback operations without adding behavior. @@ -1192,6 +1193,38 @@ This surface is at **rung 1** of ADR-0014: contract tests, local Docker/Podman build and runtime smoke, and CI construction. No reference-repository or external/community validation has been recorded. +## The read-only REST server (`packages/server`) + +`@adrkit/server` (private) is the image's `server` selector, under +[ADR-0055](./docs/adr/0055-serve-a-read-only-rest-api-over-the-sdk-in-the-oci-image-and-reach-mcp-clients-th.md) +(**accepted**). **It authenticates no one.** `node:http` over `@adrkit/sdk` +and `@adrkit/portal`'s pure builder, and nothing else (`check-deps` admits +exactly those two). **Rung 1**: unit, contract, and side-effect-denial tests +plus a local Podman smoke; the APIM path is unmeasured. Load-bearing: + +- **The route table is the server.** `src/routes.ts` is data; the dispatcher + matches it and `/openapi.json` is generated from it. Every row is `GET`; + any other method is 405 before routing and no body is read. `operationId`s + are APIM tool names, so renaming one breaks remote agents. +- **The commit comes from the sidecar, never from git.** `.adrkit-corpus.json` + is the ADR-0055/0056 contract. Missing or invalid with the directory present + is 503; a directory briefly absent (the two-move swap) or a failed or moved + load keeps the last handle. A load is kept only if the sidecar names the + same commit after it. +- **`governing` passes `readMarkers: false`, always.** Removing it turns a + query parameter into a file read; a test plants a marker file to catch it. +- **Errors come from `messages.ts` only.** The catch-all answers `internal` + with fixed text; a sentinel test covers thrown errors, sidecars, and + directory names. No access log is written, because a request line carries + caller-supplied paths. +- **The portal is per corpus at `/portal/{corpus}/`.** `/` redirects + relatively when one corpus is configured and lists them otherwise; links stay + relative so a gateway base path survives. Portal files carry the page's CSP + plus `frame-ancestors 'none'`. +- **Configuration is `ADRKIT_SERVER_CORPORA`** (`key[=dir],...` or JSON), + confined under `ADRKIT_SERVER_ROOT` by `realpath`, at start and before every + load. As PID 1 the binary handles SIGTERM itself. + ## Derived surfaces stay in lockstep three different ways [ADR-0040](./docs/adr/0040-keep-derived-surfaces-in-lockstep-with-three-mechanisms-matched-to-three-classes.md) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3833be7..8366581 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,6 +63,23 @@ Until `1.0.0`, minor releases may include breaking changes listed the id), and a log-qualified `supersededBy` names clause 4 in its existing failure. +- **A read-only REST server in the OCI image, `adrkit-container server`.** + This server authenticates no one: run it on localhost or behind a gateway + that authenticates every request. It serves `GET` and `HEAD` only, under + `/api/v1/corpora/{corpus}`: `records`, `records/{id}`, `graph?kinds=`, + `queue?asOf=`, `issues`, and `governing?path=`, plus `/api/v1/corpora`, + `/openapi.json`, `/healthz`, and the decision portal at `/` + ([ADR-0055](docs/adr/0055-serve-a-read-only-rest-api-over-the-sdk-in-the-oci-image-and-reach-mcp-clients-th.md)). + Every answer is `{ corpus, commit, data }`, with the commit read from the + `.adrkit-corpus.json` file the populator writes next to the records; a corpus + without one is not served. Configure it with `ADRKIT_SERVER_CORPORA` + (`key[=dir],...` or a JSON object), `ADRKIT_SERVER_ROOT`, + `ADRKIT_SERVER_HOST` (default `127.0.0.1`), and `ADRKIT_SERVER_PORT` + (default `8080`); see [Container usage](README.md#the-read-only-rest-server). + It runs no `git`, spawns no process, and opens no outbound connection. + `governing` matches `affects` only: it never reads the file at the path, so + it has no `@adr` marker evidence. + ## [0.19.0] - 2026-10-10 This release ships the decision portal: a third, build-only nested Action, diff --git a/Containerfile b/Containerfile index ec913ec..8213e4c 100644 --- a/Containerfile +++ b/Containerfile @@ -17,6 +17,7 @@ COPY packages/ci/package.json ./packages/ci/package.json COPY packages/catalog-envelope/package.json ./packages/catalog-envelope/package.json COPY packages/sdk/package.json ./packages/sdk/package.json COPY packages/portal/package.json ./packages/portal/package.json +COPY packages/server/package.json ./packages/server/package.json COPY packages/adapters/agent-plugin/package.json ./packages/adapters/agent-plugin/package.json COPY packages/adapters/catalog-backstage/package.json ./packages/adapters/catalog-backstage/package.json COPY packages/adapters/spec-kit/package.json ./packages/adapters/spec-kit/package.json @@ -24,6 +25,9 @@ COPY packages/core/src ./packages/core/src COPY packages/evaluator/src ./packages/evaluator/src COPY packages/cli/src ./packages/cli/src COPY packages/mcp/src ./packages/mcp/src +COPY packages/sdk/src ./packages/sdk/src +COPY packages/portal/src ./packages/portal/src +COPY packages/server/src ./packages/server/src COPY packages/ci/dist ./packages/ci/dist RUN bun install --frozen-lockfile --production --ignore-scripts @@ -35,11 +39,15 @@ RUN mkdir -p /out \ && bun build packages/mcp/src/bin.ts \ --target=node \ --conditions bun \ - --outfile=/out/adrkit-mcp.js + --outfile=/out/adrkit-mcp.js \ + && bun build packages/server/src/bin.ts \ + --target=node \ + --conditions bun \ + --outfile=/out/server.js FROM node:${NODE_VERSION}@${NODE_DIGEST} AS runtime LABEL org.opencontainers.image.title="adrkit" \ - org.opencontainers.image.description="Containerized adrkit CLI, MCP server, and CI entry points" \ + org.opencontainers.image.description="Containerized adrkit CLI, MCP server, read-only REST server, and CI entry points" \ org.opencontainers.image.source="https://github.com/mbeacom/adrkit" \ org.opencontainers.image.licenses="Apache-2.0" @@ -58,6 +66,11 @@ FROM runtime AS mcp COPY --from=build --chown=node:node /out/adrkit-mcp.js /opt/adrkit/adrkit-mcp.js ENTRYPOINT ["node", "/opt/adrkit/adrkit-mcp.js"] +FROM runtime AS server +COPY --from=build --chown=node:node /out/server.js /opt/adrkit/server.js +EXPOSE 8080 +ENTRYPOINT ["node", "/opt/adrkit/server.js"] + FROM runtime AS ci COPY --from=build --chown=node:node /opt/adrkit/packages/ci/dist/index.js /opt/adrkit/ci.js ENTRYPOINT ["node", "/opt/adrkit/ci.js"] @@ -69,6 +82,7 @@ ENTRYPOINT ["node", "/opt/adrkit/queue-action.js"] FROM runtime AS adrkit COPY --from=build --chown=node:node /out/adr.js /opt/adrkit/adr.js COPY --from=build --chown=node:node /out/adrkit-mcp.js /opt/adrkit/adrkit-mcp.js +COPY --from=build --chown=node:node /out/server.js /opt/adrkit/server.js COPY --from=build --chown=node:node /opt/adrkit/packages/ci/dist/index.js /opt/adrkit/ci.js COPY --from=build --chown=node:node /opt/adrkit/packages/ci/dist/queue-action.js /opt/adrkit/queue-action.js COPY --chmod=0755 scripts/container-entrypoint.sh /usr/local/bin/adrkit-container diff --git a/README.md b/README.md index 96f510b..3c9aa00 100644 --- a/README.md +++ b/README.md @@ -125,15 +125,16 @@ bind-mount label (for example, `:Z`). The default image treats an unrecognized selector as an `adr` subcommand. Explicit selectors are `cli`/`adr`/`adrkit`, `mcp`/`adrkit-mcp`, -`ci`/`adrkit-ci`, and `queue-action`/`adrkit-queue-action`. The default +`server`/`adrkit-server`, `ci`/`adrkit-ci`, and +`queue-action`/`adrkit-queue-action`. The default `--help` describes these selectors; `cli --help` opens the CLI command reference. The container also reserves `-h`, `container-help`, and `--container-help`; CLI help subcommands such as `help lint` otherwise pass through unchanged. Build the same source locally with Docker or Podman. Purpose-specific `cli`, -`mcp`, `ci`, and `queue-action` targets are isolated for local policy and SBOM -inspection; the registry publishes only the all-in-one `adrkit` target: +`mcp`, `server`, `ci`, and `queue-action` targets are isolated for local policy +and SBOM inspection; the registry publishes only the all-in-one `adrkit` target: ```sh docker build -f Containerfile -t adrkit:local . @@ -157,6 +158,56 @@ GitHub Marketplace listing. That root form is available beginning with GitHub lists only root Action metadata, so the queue Action stays at its nested path. +### The read-only REST server + +**This server authenticates no one.** Run it on localhost, or behind a gateway +that authenticates every request. Anyone who can reach its port can read every +corpus it serves, private repositories included. + +The `server` selector serves a read-only REST API and the decision portal over +one or more corpora +([ADR-0055](docs/adr/0055-serve-a-read-only-rest-api-over-the-sdk-in-the-oci-image-and-reach-mcp-clients-th.md)). +It answers `GET` and `HEAD` only, and every other method gets `405`. Every data +response is `{ corpus, commit, data }`. It runs no `git`, spawns no process, and +opens no outbound connection. It binds `127.0.0.1` unless `ADRKIT_SERVER_HOST` +says otherwise; inside a container that address cannot be reached from outside, +so publishing a port also takes `ADRKIT_SERVER_HOST=0.0.0.0`, and the server +then logs a warning. Routes, under `/api/v1/corpora/{corpus}`: `records`, +`records/{id}`, `graph?kinds=`, `queue?asOf=`, `issues`, and `governing?path=`. +`governing` matches a path against `affects` only and never reads the file, so +it has no `@adr` marker evidence. Also served: `/api/v1/corpora`, +`/openapi.json`, `/healthz`, and the portal at `/` (one corpus) or +`/portal/{corpus}/`. + +Each corpus is a directory something else writes: the repository's records under +`docs/adr` (or another `adrDir`) and, at its top, `.adrkit-corpus.json`, a JSON +object holding at least `{ "commit": "<40 hex>" }`. A corpus without a valid +sidecar is not served (`503`). When the sidecar's commit changes, the server +loads the new tree and swaps to it. Configure the corpora with environment +variables: + +| Variable | Default | Meaning | +| --- | --- | --- | +| `ADRKIT_SERVER_CORPORA` | (required) | `key[=dir],...`, or a JSON object such as `{"adrkit":"adrkit","platform":{"dir":"repos/platform","adrDir":"decisions"}}`. A bare `key` means `/`. Keys match `^[a-z0-9][a-z0-9-]{0,63}$` and are the `{corpus}` path segment. | +| `ADRKIT_SERVER_ROOT` | working directory | Every corpus directory must resolve inside it, symbolic links included. | +| `ADRKIT_SERVER_HOST` | `127.0.0.1` | Address to bind. | +| `ADRKIT_SERVER_PORT` | `8080` | Port to bind. | + +To serve a checkout of your own repository on your machine only, write the +sidecar, then mount the checkout read-only: + +```sh +printf '{"commit":"%s"}\n' "$(git rev-parse HEAD)" > .adrkit-corpus.json +docker run --rm --read-only -p 127.0.0.1:8080:8080 \ + -v "$PWD:/workspace:ro" \ + -e ADRKIT_SERVER_HOST=0.0.0.0 -e ADRKIT_SERVER_CORPORA=adrkit=. \ + ghcr.io/mbeacom/adrkit:vX.Y.Z server +``` + +The `-p 127.0.0.1:` prefix keeps the published port on the host's loopback. +This server has no `--network none` mode, because it listens for requests, but +it never needs a writable mount. + ## What it looks like `adr queue` emits the review backlog as a deterministic, read-only projection of diff --git a/bun.lock b/bun.lock index 6e14f57..3bf6bef 100644 --- a/bun.lock +++ b/bun.lock @@ -131,6 +131,17 @@ "@types/bun": "latest", }, }, + "packages/server": { + "name": "@adrkit/server", + "version": "0.0.0", + "dependencies": { + "@adrkit/portal": "workspace:*", + "@adrkit/sdk": "workspace:*", + }, + "devDependencies": { + "@types/bun": "latest", + }, + }, }, "packages": { "@actions/core": ["@actions/core@3.0.1", "", { "dependencies": { "@actions/exec": "^3.0.0", "@actions/http-client": "^4.0.0" } }, "sha512-a6d/Nwahm9fliVGRhdhofo40HjHQasUPusmc7vBfyky+7Z+P2A1J68zyFVaNcEclc/Se+eO595oAr5nwEIoIUA=="], @@ -163,6 +174,8 @@ "@adrkit/sdk": ["@adrkit/sdk@workspace:packages/sdk"], + "@adrkit/server": ["@adrkit/server@workspace:packages/server"], + "@adrkit/spec-kit": ["@adrkit/spec-kit@workspace:packages/adapters/spec-kit"], "@modelcontextprotocol/client": ["@modelcontextprotocol/client@2.3.1", "", { "dependencies": { "@modelcontextprotocol/core": "2.3.1", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.8", "jose": "^6.1.3", "pkce-challenge": "^5.0.0", "zod": "^4.2.0" } }, "sha512-mIGZXpHsjnZ6lD+gD/WCMpR5k8yVQQ8nNFH1N0Srf7AvnwTUMYD6pvTY8ng2+He5FfV7YMZLmrjL7cLa/cc3dQ=="], diff --git a/packages/cli/test/node-compatibility.test.ts b/packages/cli/test/node-compatibility.test.ts index 4442506..dfe03dc 100644 --- a/packages/cli/test/node-compatibility.test.ts +++ b/packages/cli/test/node-compatibility.test.ts @@ -30,6 +30,11 @@ const SHIPPED_ROOTS = [ // ADR-0016 clause 2: observed failing with `export const planted = () => Bun.version;` // appended to packages/portal/src/data/build.ts; the retained input is that line, removed. resolve(process.cwd(), 'packages/portal/src'), + // `@adrkit/server` is private, but the image runs it on Node 24 as /opt/adrkit/server.js + // (ADR-0055 clauses 10 and 12). + // ADR-0016 clause 2: observed failing with `export const planted = () => Bun.version;` + // appended to packages/server/src/validate.ts; the retained input is that line, removed. + resolve(process.cwd(), 'packages/server/src'), ]; /** Strip comments so the rule is about code, not about prose describing the rule. */ diff --git a/packages/server/package.json b/packages/server/package.json new file mode 100644 index 0000000..1d9a065 --- /dev/null +++ b/packages/server/package.json @@ -0,0 +1,34 @@ +{ + "name": "@adrkit/server", + "version": "0.0.0", + "private": true, + "description": "A read-only REST API over @adrkit/sdk, with the decision portal, shipped as the `server` selector of the adrkit OCI image (ADR-0055). Private; bundled into the image, never published.", + "type": "module", + "license": "Apache-2.0", + "homepage": "https://adrkit.dev", + "repository": { + "type": "git", + "url": "git+https://github.com/mbeacom/adrkit.git", + "directory": "packages/server" + }, + "bugs": { + "url": "https://github.com/mbeacom/adrkit/issues" + }, + "//boundary": "ADR-0055 clause 10: node:http and no framework. The only runtime dependencies are @adrkit/sdk (the consumer contract, ADR-0031) and the private @adrkit/portal (its pure builder and page assets only). It never imports @adrkit/core or @adrkit/cli, spawns no process, runs no git, and opens no outbound connection; scripts/check-deps.ts admits exactly those two. Neither declares an exports map, so the import specifiers are the subpaths `@adrkit/sdk/src/index.ts`, `@adrkit/portal/src/data/build.ts`, and `@adrkit/portal/src/page/assets.ts`.", + "//release": "Private and unpublished. It reaches users only as /opt/adrkit/server.js in the all-in-one OCI image (ADR-0055 clause 12), which rides the lockstep release (ADR-0032).", + "engines": { + "node": ">=22" + }, + "scripts": { + "build": "bun run typecheck", + "lint": "bun run typecheck", + "typecheck": "tsc --noEmit --customConditions bun --project ../../tsconfig.json" + }, + "dependencies": { + "@adrkit/portal": "workspace:*", + "@adrkit/sdk": "workspace:*" + }, + "devDependencies": { + "@types/bun": "latest" + } +} diff --git a/packages/server/src/bin.ts b/packages/server/src/bin.ts new file mode 100644 index 0000000..db9d825 --- /dev/null +++ b/packages/server/src/bin.ts @@ -0,0 +1,57 @@ +/** + * `adrkit-server`: the entry point of the image's `server` selector + * ([ADR-0055](../../../docs/adr/0055-serve-a-read-only-rest-api-over-the-sdk-in-the-oci-image-and-reach-mcp-clients-th.md) + * clauses 12 to 14). + * + * **This server authenticates no one.** It binds `127.0.0.1` unless `ADRKIT_SERVER_HOST` + * says otherwise, and on any non-loopback bind it prints one fixed warning saying so. + */ + +import { CONFIG_MESSAGES, ConfigError, isLoopbackHost, loadServerConfig } from './config.ts'; +import { NON_LOOPBACK_WARNING, USAGE } from './messages.ts'; +import { createAdrkitServer } from './server.ts'; + +async function main(argv: readonly string[]): Promise { + if (argv.length > 0) { + const help = argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h'); + (help ? process.stdout : process.stderr).write(USAGE); + process.exitCode = help ? 0 : 2; + return; + } + + let config; + try { + config = await loadServerConfig(process.env, process.cwd()); + } catch (error) { + process.stderr.write(`adrkit-server: ${error instanceof ConfigError ? error.message : CONFIG_MESSAGES['corpora-malformed']}\n`); + process.exitCode = 2; + return; + } + + const server = createAdrkitServer({ config }); + server.on('error', () => { + process.stderr.write('adrkit-server: could not listen on the configured host and port.\n'); + process.exit(1); + }); + + // As PID 1 in a container, Node ignores SIGTERM and SIGINT unless it handles them. + for (const signal of ['SIGTERM', 'SIGINT'] as const) { + process.once(signal, () => { + server.close(() => process.exit(0)); + server.closeAllConnections(); + setTimeout(() => process.exit(0), 5_000).unref(); + }); + } + + // `[::1]` is how an IPv6 address is written in a URL; Node's listen wants it bare. + const bindHost = config.host.startsWith('[') && config.host.endsWith(']') ? config.host.slice(1, -1) : config.host; + server.listen(config.port, bindHost, () => { + const address = server.address(); + const port = typeof address === 'object' && address !== null ? address.port : config.port; + const host = config.host.includes(':') && !config.host.startsWith('[') ? `[${config.host}]` : config.host; + process.stdout.write(`adrkit-server: listening on http://${host}:${port} (${config.corpora.length} ${config.corpora.length === 1 ? 'corpus' : 'corpora'})\n`); + if (!isLoopbackHost(config.host)) process.stderr.write(`${NON_LOOPBACK_WARNING}\n`); + }); +} + +await main(process.argv.slice(2)); diff --git a/packages/server/src/config.ts b/packages/server/src/config.ts new file mode 100644 index 0000000..7a11d5e --- /dev/null +++ b/packages/server/src/config.ts @@ -0,0 +1,265 @@ +/** + * The server's configuration + * ([ADR-0055](../../../docs/adr/0055-serve-a-read-only-rest-api-over-the-sdk-in-the-oci-image-and-reach-mcp-clients-th.md) + * clauses 6 and 14), read from the environment once at start. + * + * | Variable | Default | Meaning | + * | ----------------------- | ------------ | ------------------------------------------------------------------ | + * | `ADRKIT_SERVER_CORPORA` | (required) | The corpora, as `key[=dir],...` or as a JSON object (below). | + * | `ADRKIT_SERVER_ROOT` | process cwd | The root every corpus directory must stay inside. | + * | `ADRKIT_SERVER_HOST` | `127.0.0.1` | Address to bind. A non-loopback value logs one fixed warning. | + * | `ADRKIT_SERVER_PORT` | `8080` | Port to bind. | + * + * `ADRKIT_SERVER_CORPORA` takes either form; both reach the same validated shape: + * + * - **List:** `adrkit,platform=repos/platform`. A bare `key` means `/`; `key=dir` + * names the directory, relative to the root or absolute inside it. + * - **JSON:** `{"adrkit":"adrkit","platform":{"dir":"repos/platform","adrDir":"decisions"}}`. + * A value is the directory, or an object with `dir` (default `/`) and `adrDir`. + * No other key is accepted. + * + * A key is the `{corpus}` path segment and matches `^[a-z0-9][a-z0-9-]{0,63}$`. A corpus + * directory is the tree a populator writes: it holds `.adrkit-corpus.json` at its top and the + * records under `adrDir` (default `docs/adr`), so record paths read as they do in the + * repository. Each directory is `realpath`-resolved at start (when it exists yet) and must stay + * inside the root; the corpus holder checks that again before every load. + */ + +import { realpath } from 'node:fs/promises'; +import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; + +export const CORPUS_KEY = /^[a-z0-9][a-z0-9-]{0,63}$/; +export const DEFAULT_ADR_DIR = 'docs/adr'; +export const DEFAULT_HOST = '127.0.0.1'; +export const DEFAULT_PORT = 8080; + +/** One configured corpus. */ +export interface CorpusConfig { + /** The `{corpus}` path segment. */ + readonly key: string; + /** Absolute path of the corpus directory: its `realpath` at start, or the lexical path if it did not exist yet. */ + readonly dir: string; + /** Records directory inside {@link dir}, POSIX, no leading `./` or trailing `/`. */ + readonly adrDir: string; +} + +export interface ServerConfig { + /** `realpath` of the confinement root. */ + readonly root: string; + /** Sorted by key, in code-unit order. Never empty. */ + readonly corpora: readonly CorpusConfig[]; + readonly host: string; + readonly port: number; +} + +/** Fixed startup messages, looked up by code. No path or value from the environment is echoed. */ +export const CONFIG_MESSAGES = Object.freeze({ + 'corpora-missing': 'ADRKIT_SERVER_CORPORA is not set. Name at least one corpus, e.g. ADRKIT_SERVER_CORPORA=adrkit=.', + 'corpora-malformed': 'ADRKIT_SERVER_CORPORA is malformed. Use key[=dir],... or a JSON object of key to dir or {"dir","adrDir"}.', + 'corpus-key-invalid': 'A corpus key in ADRKIT_SERVER_CORPORA does not match ^[a-z0-9][a-z0-9-]{0,63}$.', + 'corpus-key-duplicate': 'A corpus key appears twice in ADRKIT_SERVER_CORPORA.', + 'corpus-dir-invalid': 'A corpus directory in ADRKIT_SERVER_CORPORA is empty, too long, or contains a control character.', + 'adr-dir-invalid': 'An adrDir in ADRKIT_SERVER_CORPORA must be a relative path with no ".." segment and no control character.', + 'corpus-escapes-root': 'A corpus directory resolves outside ADRKIT_SERVER_ROOT.', + 'root-unreadable': 'ADRKIT_SERVER_ROOT does not exist or cannot be resolved.', + 'port-invalid': 'ADRKIT_SERVER_PORT must be an integer from 0 to 65535.', + 'host-invalid': 'ADRKIT_SERVER_HOST must be a non-empty address with no whitespace or control character.', +}); + +export type ConfigErrorCode = keyof typeof CONFIG_MESSAGES; + +export class ConfigError extends Error { + readonly code: ConfigErrorCode; + /** The offending corpus key, only when it already passed the key grammar. */ + readonly key: string | undefined; + constructor(code: ConfigErrorCode, key?: string) { + super(key === undefined ? CONFIG_MESSAGES[code] : `${CONFIG_MESSAGES[code]} (corpus "${key}")`); + this.name = 'ConfigError'; + this.code = code; + this.key = key; + } +} + +interface CorpusSpec { + readonly key: string; + readonly dir: string | undefined; + readonly adrDir: string; +} + +const CONTROL = /[\u0000-\u001f\u007f-\u009f]/; + +/** Parse `ADRKIT_SERVER_CORPORA` into specs, sorted by key. Pure. */ +export function parseCorporaSpec(value: string | undefined): CorpusSpec[] { + const text = (value ?? '').trim(); + if (text.length === 0) throw new ConfigError('corpora-missing'); + const raw = text.startsWith('{') ? specsFromJson(text) : specsFromList(text); + if (raw.length === 0) throw new ConfigError('corpora-missing'); + const seen = new Set(); + const specs: CorpusSpec[] = []; + for (const entry of raw) { + if (!CORPUS_KEY.test(entry.key)) throw new ConfigError('corpus-key-invalid'); + if (seen.has(entry.key)) throw new ConfigError('corpus-key-duplicate', entry.key); + seen.add(entry.key); + if (entry.dir !== undefined && (entry.dir.length === 0 || entry.dir.length > 4096 || CONTROL.test(entry.dir))) { + throw new ConfigError('corpus-dir-invalid', entry.key); + } + specs.push({ key: entry.key, dir: entry.dir, adrDir: normalizeAdrDir(entry.adrDir ?? DEFAULT_ADR_DIR, entry.key) }); + } + return specs.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0)); +} + +interface RawSpec { + key: string; + dir: string | undefined; + adrDir: string | undefined; +} + +function specsFromList(text: string): RawSpec[] { + return text.split(',').map((item) => { + const entry = item.trim(); + if (entry.length === 0) throw new ConfigError('corpora-malformed'); + const at = entry.indexOf('='); + if (at === -1) return { key: entry, dir: undefined, adrDir: undefined }; + const dir = entry.slice(at + 1).trim(); + if (dir.length === 0) throw new ConfigError('corpora-malformed'); + return { key: entry.slice(0, at).trim(), dir, adrDir: undefined }; + }); +} + +function specsFromJson(text: string): RawSpec[] { + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + throw new ConfigError('corpora-malformed'); + } + if (!isPlainObject(parsed)) throw new ConfigError('corpora-malformed'); + return Object.entries(parsed).map(([key, value]): RawSpec => { + if (typeof value === 'string') return { key, dir: value, adrDir: undefined }; + if (!isPlainObject(value)) throw new ConfigError('corpora-malformed'); + for (const field of Object.keys(value)) { + if (field !== 'dir' && field !== 'adrDir') throw new ConfigError('corpora-malformed'); + } + const { dir, adrDir } = value; + if ((dir !== undefined && typeof dir !== 'string') || (adrDir !== undefined && typeof adrDir !== 'string')) { + throw new ConfigError('corpora-malformed'); + } + return { key, dir, adrDir }; + }); +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function normalizeAdrDir(value: string, key: string): string { + const trimmed = value.replace(/^(?:\.\/)+/, '').replace(/\/+$/, ''); + const normalized = trimmed.length === 0 ? '.' : trimmed; + if ( + normalized.length > 1024 || + CONTROL.test(normalized) || + normalized.startsWith('/') || + normalized.includes('\\') || + normalized.split('/').some((segment) => segment === '..' || segment === '') + ) { + throw new ConfigError('adr-dir-invalid', key); + } + return normalized; +} + +/** Whether `candidate` is `root` or below it. Both must already be absolute. */ +export function isInside(root: string, candidate: string): boolean { + const rel = relative(root, candidate); + return rel === '' || (rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel)); +} + +/** `ADRKIT_SERVER_PORT`, or the default. */ +export function parsePort(value: string | undefined): number { + if (value === undefined || value.trim() === '') return DEFAULT_PORT; + if (!/^\d{1,5}$/.test(value.trim())) throw new ConfigError('port-invalid'); + const port = Number(value.trim()); + if (port > 65535) throw new ConfigError('port-invalid'); + return port; +} + +/** `ADRKIT_SERVER_HOST`, or the loopback default. */ +export function parseHost(value: string | undefined): string { + if (value === undefined) return DEFAULT_HOST; + const host = value.trim(); + if (host.length === 0 || host.length > 255 || /[\s]/.test(host) || CONTROL.test(host)) { + throw new ConfigError('host-invalid'); + } + return host; +} + +/** + * Whether `host` names only the local machine. Anything else, `0.0.0.0` and `::` included, + * is a non-loopback bind and gets the clause 14 warning. + */ +export function isLoopbackHost(host: string): boolean { + const bare = host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host; + return ( + bare.toLowerCase() === 'localhost' || + /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(bare) || + bare === '::1' || + /^::ffff:127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/i.test(bare) + ); +} + +/** + * Read and validate the whole configuration. The only I/O is `realpath`: of the root, which + * must exist, and of each corpus directory that exists yet. A directory a populator has not + * written yet is accepted on its lexical path, which must already be inside the root. + */ +export async function loadServerConfig(env: Readonly>, cwd: string): Promise { + const specs = parseCorporaSpec(env.ADRKIT_SERVER_CORPORA); + const host = parseHost(env.ADRKIT_SERVER_HOST); + const port = parsePort(env.ADRKIT_SERVER_PORT); + + let root: string; + try { + root = await realpath(resolve(cwd, env.ADRKIT_SERVER_ROOT ?? '.')); + } catch { + throw new ConfigError('root-unreadable'); + } + + const corpora: CorpusConfig[] = []; + for (const spec of specs) { + const lexical = resolve(root, spec.dir ?? spec.key); + if (!isInside(root, lexical)) throw new ConfigError('corpus-escapes-root', spec.key); + let dir: string; + try { + dir = await realpathOfExistingPrefix(lexical); + } catch { + throw new ConfigError('corpus-escapes-root', spec.key); + } + if (!isInside(root, dir)) throw new ConfigError('corpus-escapes-root', spec.key); + corpora.push({ key: spec.key, dir, adrDir: spec.adrDir }); + } + return { root, corpora, host, port }; +} + +/** + * `realpath` of `path`, or, when it does not exist yet, `realpath` of its nearest existing + * ancestor joined with the rest. A directory not yet written below a symlink that leaves the + * root therefore still resolves outside it. + */ +async function realpathOfExistingPrefix(path: string): Promise { + const missing: string[] = []; + let current = path; + for (;;) { + try { + return join(await realpath(current), ...missing); + } catch (error) { + const parent = dirname(current); + if (!isNotFound(error) || parent === current) throw error; + missing.unshift(basename(current)); + current = parent; + } + } +} + +export function isNotFound(error: unknown): boolean { + const code = typeof error === 'object' && error !== null && 'code' in error ? (error as { code: unknown }).code : undefined; + return code === 'ENOENT' || code === 'ENOTDIR'; +} diff --git a/packages/server/src/corpus.ts b/packages/server/src/corpus.ts new file mode 100644 index 0000000..390d772 --- /dev/null +++ b/packages/server/src/corpus.ts @@ -0,0 +1,170 @@ +/** + * One configured corpus: its sidecar, its loaded handle, and the swap between handles + * ([ADR-0055](../../../docs/adr/0055-serve-a-read-only-rest-api-over-the-sdk-in-the-oci-image-and-reach-mcp-clients-th.md) + * clauses 4 and 5). + * + * **The sidecar contract, worded as in ADR-0055 clause 4 and ADR-0056 clause 4:** + * `.adrkit-corpus.json` is a JSON object holding at least `{ "commit": "<40 hex>" }`, where + * `commit` is the full 40-hex SHA of the tree written; the populator is its only writer, + * writes it last, into a tree written aside and swapped in whole; a field added later is + * additive. + * + * Every request reads the sidecar (one small file). The handle is reopened only when the + * commit changes, with `openDecisions` alone, and kept only if the sidecar still names the + * same commit after the load; a request already holding the old handle finishes on it. + * + * Two states keep serving the last good handle, both named by ADR-0056 clause 4's two-move + * swap: the corpus directory briefly absent between the moves, and a load that failed or saw + * the commit move. A directory that is present with a missing or invalid sidecar is not + * served: clause 4 forbids an answer without a commit, and a stale one would be a lie about + * the tree on disk. A corpus directory, or the records directory inside it, that now + * resolves outside the root is not served either, old handle or not. + */ + +import { readFile, realpath, stat } from 'node:fs/promises'; +import { join } from 'node:path'; +import type { DecisionSet } from '@adrkit/sdk/src/index.ts'; +import { type CorpusConfig, isInside, isNotFound } from './config.ts'; + +export const SIDECAR_NAME = '.adrkit-corpus.json'; +export const SIDECAR_COMMIT = /^[0-9a-f]{40}$/; +/** A sidecar is a few dozen bytes; anything past this is not one. */ +const SIDECAR_MAX_BYTES = 64 * 1024; + +export type SidecarRead = + | { readonly state: 'valid'; readonly commit: string } + | { readonly state: 'missing' } + | { readonly state: 'invalid' } + | { readonly state: 'directory-absent' }; + +/** Read and validate `/.adrkit-corpus.json`. Never throws. */ +export async function readSidecar(dir: string): Promise { + const path = join(dir, SIDECAR_NAME); + let text: string; + try { + const info = await stat(path); + if (!info.isFile() || info.size > SIDECAR_MAX_BYTES) return { state: 'invalid' }; + text = await readFile(path, 'utf8'); + } catch (error) { + if (!isNotFound(error)) return { state: 'invalid' }; + try { + await stat(dir); + return { state: 'missing' }; + } catch (dirError) { + return isNotFound(dirError) ? { state: 'directory-absent' } : { state: 'missing' }; + } + } + return parseSidecar(text); +} + +/** Pure: the sidecar's commit, when `text` is a valid sidecar. */ +export function parseSidecar(text: string): SidecarRead { + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + return { state: 'invalid' }; + } + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return { state: 'invalid' }; + const commit = (parsed as { commit?: unknown }).commit; + if (typeof commit !== 'string' || !SIDECAR_COMMIT.test(commit)) return { state: 'invalid' }; + return { state: 'valid', commit }; +} + +/** The handle methods the server calls. `governing` is called with `readMarkers: false` only. */ +export type CorpusHandle = Pick; + +/** `openDecisions`, or a stand-in in tests. */ +export type CorpusOpener = (options: { cwd: string; dir: string }) => Promise; + +/** A corpus loaded at one commit. Immutable once built, except its per-`asOf` portal cache. */ +export interface LoadedCorpus { + readonly key: string; + readonly commit: string; + readonly adrDir: string; + readonly handle: CorpusHandle; + /** Serialized `portal-data.json` by `asOf`; a function of commit and `asOf` (clause 9). */ + readonly portal: Map; +} + +export type UnavailableCode = 'sidecar-missing' | 'sidecar-invalid' | 'corpus-unreadable' | 'corpus-escapes-root' | 'corpus-changing'; + +export type CorpusState = { readonly ok: true; readonly corpus: LoadedCorpus } | { readonly ok: false; readonly code: UnavailableCode }; + +export class CorpusHolder { + readonly key: string; + readonly #dir: string; + readonly #adrDir: string; + readonly #root: string; + readonly #open: CorpusOpener; + #loaded: LoadedCorpus | undefined; + #pending: { readonly commit: string; readonly promise: Promise } | undefined; + + constructor(config: CorpusConfig, root: string, open: CorpusOpener) { + this.key = config.key; + this.#dir = config.dir; + this.#adrDir = config.adrDir; + this.#root = root; + this.#open = open; + } + + /** The corpus as of its current sidecar, loading it when the commit changed. Never throws. */ + async current(): Promise { + const placement = await this.#placement(); + if (placement === 'escapes') return { ok: false, code: 'corpus-escapes-root' }; + const sidecar: SidecarRead = placement === 'absent' ? { state: 'directory-absent' } : await readSidecar(this.#dir); + if (sidecar.state !== 'valid') { + if (sidecar.state === 'directory-absent' && this.#loaded) return { ok: true, corpus: this.#loaded }; + return { ok: false, code: sidecar.state === 'invalid' ? 'sidecar-invalid' : 'sidecar-missing' }; + } + if (this.#loaded?.commit === sidecar.commit) return { ok: true, corpus: this.#loaded }; + return this.#load(sidecar.commit); + } + + /** Re-check confinement before every read: a directory swapped for a symlink out of the root is refused. */ + async #placement(): Promise<'inside' | 'absent' | 'escapes'> { + try { + return isInside(this.#root, await realpath(this.#dir)) ? 'inside' : 'escapes'; + } catch (error) { + return isNotFound(error) ? 'absent' : 'escapes'; + } + } + + async #recordsPlacement(): Promise<'inside' | 'absent' | 'escapes'> { + try { + return isInside(this.#root, await realpath(join(this.#dir, this.#adrDir))) ? 'inside' : 'escapes'; + } catch (error) { + return isNotFound(error) ? 'absent' : 'escapes'; + } + } + + #load(commit: string): Promise { + if (this.#pending?.commit === commit) return this.#pending.promise; + const promise = this.#loadOnce(commit).finally(() => { + if (this.#pending?.promise === promise) this.#pending = undefined; + }); + this.#pending = { commit, promise }; + return promise; + } + + async #loadOnce(commit: string): Promise { + const fallback = (code: UnavailableCode): CorpusState => + this.#loaded ? { ok: true, corpus: this.#loaded } : { ok: false, code }; + // `openDecisions` follows the records directory wherever it points, so it is confined + // too, right before every open: an in-root corpus whose `docs/adr` is a symlink out of + // the root must not serve the outside records. + if ((await this.#recordsPlacement()) === 'escapes') return { ok: false, code: 'corpus-escapes-root' }; + let handle: CorpusHandle; + try { + handle = await this.#open({ cwd: this.#dir, dir: this.#adrDir }); + } catch { + return fallback('corpus-unreadable'); + } + const after = await readSidecar(this.#dir); + if (after.state !== 'valid' || after.commit !== commit) return fallback('corpus-changing'); + if (this.#loaded?.commit !== commit) { + this.#loaded = Object.freeze({ key: this.key, commit, adrDir: this.#adrDir, handle, portal: new Map() }); + } + return { ok: true, corpus: this.#loaded }; + } +} diff --git a/packages/server/src/messages.ts b/packages/server/src/messages.ts new file mode 100644 index 0000000..07315ea --- /dev/null +++ b/packages/server/src/messages.ts @@ -0,0 +1,57 @@ +/** + * Every error the server answers with, as fixed text looked up by code + * ([ADR-0055](../../../docs/adr/0055-serve-a-read-only-rest-api-over-the-sdk-in-the-oci-image-and-reach-mcp-clients-th.md) + * clause 8). No stderr, exception message, stack, or file system path reaches a response: + * an error body is built from this table and nothing else. + */ + +export const ERRORS = Object.freeze({ + 'not-found': { status: 404, message: 'No route matches this path.' }, + 'method-not-allowed': { status: 405, message: 'This server is read-only. Only GET and HEAD are supported.' }, + 'corpus-not-found': { status: 404, message: 'No corpus is configured under this key.' }, + 'record-not-found': { status: 404, message: 'No record with this id loaded from the corpus.' }, + 'invalid-record-id': { status: 400, message: 'The record id must be four or more digits or a ULID.' }, + 'missing-path': { status: 400, message: 'The path query parameter is required.' }, + 'invalid-path': { + status: 400, + message: 'The path must be relative, at most 1024 characters, with no ".." segment, no leading "-", and no control character.', + }, + 'invalid-kinds': { status: 400, message: 'kinds must be a comma-separated subset of supersedes, relatesTo, and conflictsWith.' }, + 'invalid-as-of': { status: 400, message: 'asOf must be a real UTC calendar date in YYYY-MM-DD form.' }, + 'invalid-query': { status: 400, message: 'A query parameter was given more than once.' }, + 'sidecar-missing': { + status: 503, + message: 'This corpus has no .adrkit-corpus.json naming its commit, so it is not served.', + }, + 'sidecar-invalid': { + status: 503, + message: 'This corpus has an .adrkit-corpus.json that is not a JSON object with a 40-hex commit, so it is not served.', + }, + 'corpus-unreadable': { status: 503, message: 'This corpus could not be loaded, so it is not served.' }, + 'corpus-escapes-root': { status: 503, message: 'This corpus directory resolves outside the configured root, so it is not served.' }, + 'corpus-changing': { status: 503, message: 'This corpus changed while it was loading. Retry shortly.' }, + internal: { status: 500, message: 'The server could not answer this request.' }, +} as const satisfies Record); + +export type ErrorCode = keyof typeof ERRORS; + +/** `adrkit-server --help`. */ +export const USAGE = `usage: adrkit-server + +A read-only REST API and decision portal over one or more adrkit corpora (ADR-0055). +This server authenticates no one. Run it on localhost or behind a gateway that does. + +environment: + ADRKIT_SERVER_CORPORA required. key[=dir],... or a JSON object, e.g. + adrkit,platform=repos/platform + {"adrkit":"adrkit","platform":{"dir":"repos/platform","adrDir":"decisions"}} + A bare key means /. Each directory holds + .adrkit-corpus.json ({"commit":"<40 hex>"}) and its + records under adrDir (default docs/adr). + ADRKIT_SERVER_ROOT every corpus directory must stay inside it (default: cwd) + ADRKIT_SERVER_HOST address to bind (default 127.0.0.1) + ADRKIT_SERVER_PORT port to bind (default 8080) +`; + +export const NON_LOOPBACK_WARNING = + 'adrkit-server: warning: This server authenticates no one, and it is bound to a non-loopback address. Anyone who can reach this port can read every configured corpus. Put an authenticating gateway in front of it.'; diff --git a/packages/server/src/openapi.ts b/packages/server/src/openapi.ts new file mode 100644 index 0000000..68d764e --- /dev/null +++ b/packages/server/src/openapi.ts @@ -0,0 +1,74 @@ +/** + * `/openapi.json`, generated from the route table + * ([ADR-0055](../../../docs/adr/0055-serve-a-read-only-rest-api-over-the-sdk-in-the-oci-image-and-reach-mcp-clients-th.md) + * clause 11). Pure. Every operation is a `get`; there is no other method to describe. + * + * OpenAPI 3.0.3 rather than 3.1, because 3.0 is the version APIM's importer documents fully. + */ + +import { ROUTES, type RouteDefinition, type RouteKind } from './routes.ts'; + +/** The `/api/v1` contract's version. Changes additively (ADR-0055 trade-offs). */ +export const API_VERSION = '1.0.0'; + +const MEDIA_TYPES: Readonly> = Object.freeze({ + json: 'application/json', + html: 'text/html', + css: 'text/css', + javascript: 'text/javascript', +}); + +const ERROR_SCHEMA = { + type: 'object', + required: ['error'], + properties: { + error: { + type: 'object', + required: ['code', 'message'], + properties: { code: { type: 'string' }, message: { type: 'string' } }, + }, + }, +}; + +function operationOf(route: RouteDefinition) { + const responses: Record = {}; + for (const [status, description] of Object.entries(route.responses)) { + if (status === '200') { + responses[status] = { description, content: { [MEDIA_TYPES[route.kind]]: { schema: route.kind === 'json' ? { type: 'object' } : { type: 'string' } } } }; + } else if (status === '302') { + responses[status] = { description }; + } else { + responses[status] = { description, content: { 'application/json': { schema: ERROR_SCHEMA } } }; + } + } + return { + operationId: route.operationId, + summary: route.summary, + parameters: route.parameters.map((parameter) => ({ + name: parameter.name, + in: parameter.in, + required: parameter.required, + description: parameter.description, + schema: parameter.schema, + })), + responses, + }; +} + +/** The OpenAPI document for {@link ROUTES}. */ +export function buildOpenApi(routes: readonly RouteDefinition[] = ROUTES) { + const paths: Record> = {}; + for (const route of routes) { + paths[route.path] = { ...(paths[route.path] ?? {}), get: operationOf(route) }; + } + return { + openapi: '3.0.3', + info: { + title: 'adrkit server', + version: API_VERSION, + description: + 'Read-only REST API over an adrkit decision corpus (ADR-0055). This server authenticates no one: run it on localhost or behind a gateway that does. Only GET and HEAD are served.', + }, + paths, + }; +} diff --git a/packages/server/src/routes.ts b/packages/server/src/routes.ts new file mode 100644 index 0000000..10c03a5 --- /dev/null +++ b/packages/server/src/routes.ts @@ -0,0 +1,224 @@ +/** + * The route table: every path this server answers, as data + * ([ADR-0055](../../../docs/adr/0055-serve-a-read-only-rest-api-over-the-sdk-in-the-oci-image-and-reach-mcp-clients-th.md) + * clauses 1, 2, 9, and 11). + * + * The dispatcher matches against this table and nothing else, and `/openapi.json` is generated + * from it, so the document cannot describe a route the server does not have or miss one it + * does. Every row's `method` is `GET`; `HEAD` is answered for each as `GET` without a body, and + * any other method is refused before routing. `test/methods.test.ts` fails on a row with any + * other method. + * + * **`operationId`s are a contract.** APIM's "REST API as MCP server" names its tools after + * them (clause 15), so renaming one is a break for remote agents. + */ + +import { CORPUS_KEY } from './config.ts'; +import { RECORD_ID } from './validate.ts'; + +export type RouteKind = 'json' | 'html' | 'css' | 'javascript'; + +export interface RouteParameter { + readonly name: string; + readonly in: 'path' | 'query'; + readonly required: boolean; + readonly description: string; + readonly schema: Readonly>; +} + +export interface RouteDefinition { + readonly operationId: string; + readonly method: 'GET'; + /** OpenAPI path template. A `{name}` segment matches one non-empty path segment. */ + readonly path: string; + readonly summary: string; + readonly kind: RouteKind; + readonly parameters: readonly RouteParameter[]; + /** Status code to description, for the OpenAPI document. */ + readonly responses: Readonly>; +} + +const corpusParameter: RouteParameter = { + name: 'corpus', + in: 'path', + required: true, + description: 'A configured corpus key.', + schema: { type: 'string', pattern: CORPUS_KEY.source }, +}; + +const ENVELOPE = 'The data, as { corpus, commit, data }, with commit from the corpus .adrkit-corpus.json.'; +const CORPUS_ERRORS = { + '404': 'No corpus is configured under this key.', + '503': 'The corpus has no valid .adrkit-corpus.json, could not be loaded, or resolves outside the root.', +} as const; + +/** The page bundle's files below `/portal/{corpus}/`, beside `index.html` and `portal-data.json`. */ +export const PORTAL_ASSET_ROUTES: Readonly> = Object.freeze({ + 'app.css': { operationId: 'getPortalStylesheet', kind: 'css', summary: "The decision portal's stylesheet." }, + 'page/app.mjs': { operationId: 'getPortalApp', kind: 'javascript', summary: "The decision portal's page module." }, + 'vendor/board-layout.mjs': { operationId: 'getPortalBoardLayout', kind: 'javascript', summary: "The decision portal's graph layout module." }, + 'vendor/board-view.mjs': { operationId: 'getPortalBoardView', kind: 'javascript', summary: "The decision portal's graph view module." }, +}); + +function corpusRoute(operationId: string, suffix: string, summary: string, extra: Partial = {}): RouteDefinition { + return { + operationId, + method: 'GET', + path: `/api/v1/corpora/{corpus}/${suffix}`, + summary, + kind: 'json', + parameters: [corpusParameter], + responses: { '200': ENVELOPE, ...CORPUS_ERRORS }, + ...extra, + }; +} + +export const ROUTES: readonly RouteDefinition[] = Object.freeze([ + { + operationId: 'getHealth', + method: 'GET', + path: '/healthz', + summary: 'Liveness. The one route whose answer is not tied to a commit.', + kind: 'json', + parameters: [], + responses: { '200': 'The process is serving.' }, + }, + { + operationId: 'getOpenApi', + method: 'GET', + path: '/openapi.json', + summary: "This server's OpenAPI document, generated from its route table.", + kind: 'json', + parameters: [], + responses: { '200': 'The OpenAPI document.' }, + }, + { + operationId: 'listCorpora', + method: 'GET', + path: '/api/v1/corpora', + summary: 'List the configured decision corpora and the commit each is served at.', + kind: 'json', + parameters: [], + responses: { '200': 'The configured keys, each with its commit, or null when it is not being served.' }, + }, + corpusRoute('listRecords', 'records', 'List the architecture decision records of one corpus, without their bodies.'), + corpusRoute('getRecord', 'records/{id}', 'Get one architecture decision record by id, with its raw Markdown body.', { + parameters: [ + corpusParameter, + { name: 'id', in: 'path', required: true, description: 'A record id: four or more digits, or a ULID.', schema: { type: 'string', pattern: RECORD_ID.source } }, + ], + responses: { '200': ENVELOPE, '400': 'The id is not a record id.', '404': 'No such corpus, or no such record in it.', '503': CORPUS_ERRORS['503'] }, + }), + corpusRoute('getGraph', 'graph', 'Get the decision relationship graph of one corpus. Supersession edges only unless kinds says otherwise.', { + parameters: [ + corpusParameter, + { + name: 'kinds', + in: 'query', + required: false, + description: 'A comma-separated subset of supersedes, relatesTo, and conflictsWith. Default: supersedes.', + schema: { type: 'string' }, + }, + ], + responses: { '200': ENVELOPE, '400': 'kinds is empty, names an unknown kind, or is repeated.', ...CORPUS_ERRORS }, + }), + corpusRoute('getQueue', 'queue', 'Get the architecture review queue of one corpus and the SLA state of each proposal.', { + parameters: [ + corpusParameter, + { + name: 'asOf', + in: 'query', + required: false, + description: 'UTC calendar date the SLA states are computed against, YYYY-MM-DD. Default: today (UTC), echoed as asOf.', + schema: { type: 'string', format: 'date' }, + }, + ], + responses: { '200': 'The queue, as { corpus, commit, asOf, data }.', '400': 'asOf is not a real calendar date, or is repeated.', ...CORPUS_ERRORS }, + }), + corpusRoute('getIssues', 'issues', 'Get the corpus findings of one corpus (the lint view).'), + corpusRoute('getGoverning', 'governing', 'Get the decisions whose affects matchers reach an explicitly supplied repository path.', { + parameters: [ + corpusParameter, + { + name: 'path', + in: 'query', + required: true, + description: 'A repository-relative path, at most 1024 characters. Matched against affects only; never read.', + schema: { type: 'string', maxLength: 1024 }, + }, + ], + responses: { '200': `${ENVELOPE} Resolution is withheld, with the corpus issues, when the corpus has errors.`, '400': 'path is missing, invalid, or repeated.', ...CORPUS_ERRORS }, + }), + { + operationId: 'getPortal', + method: 'GET', + path: '/', + summary: 'The decision portal: a redirect to the one corpus portal, or a list of them when several are configured.', + kind: 'html', + parameters: [], + responses: { '200': 'A list of corpus portals.', '302': 'A redirect to the only corpus portal.' }, + }, + { + operationId: 'getCorpusPortal', + method: 'GET', + path: '/portal/{corpus}/', + summary: 'The decision portal page of one corpus (ADR-0054).', + kind: 'html', + parameters: [corpusParameter], + responses: { '200': 'The page.', '404': CORPUS_ERRORS['404'] }, + }, + { + operationId: 'getCorpusPortalData', + method: 'GET', + path: '/portal/{corpus}/portal-data.json', + summary: "The portal's portal-data.json, built from the same handle and commit as the API, with no path table.", + kind: 'json', + parameters: [corpusParameter], + responses: { '200': 'portal-data.json version 1.', ...CORPUS_ERRORS }, + }, + ...Object.entries(PORTAL_ASSET_ROUTES).map( + ([asset, route]): RouteDefinition => ({ + operationId: route.operationId, + method: 'GET', + path: `/portal/{corpus}/${asset}`, + summary: route.summary, + kind: route.kind, + parameters: [corpusParameter], + responses: { '200': 'The file.', '404': CORPUS_ERRORS['404'] }, + }), + ), +]); + +export interface RouteMatch { + readonly route: RouteDefinition; + /** Raw (still percent-encoded) path segments by parameter name. */ + readonly params: Readonly>; +} + +const COMPILED = ROUTES.map((route) => ({ route, segments: route.path.split('/') })); + +/** Match a URL pathname against the table. Literal segments match exactly; `{name}` matches one non-empty segment. */ +export function matchRoute(pathname: string): RouteMatch | undefined { + const segments = pathname.split('/'); + for (const { route, segments: template } of COMPILED) { + if (template.length !== segments.length) continue; + const params: Record = {}; + let matched = true; + for (let index = 0; index < template.length; index++) { + const expected = template[index]!; + const actual = segments[index]!; + if (expected.startsWith('{') && expected.endsWith('}')) { + if (actual.length === 0) { + matched = false; + break; + } + params[expected.slice(1, -1)] = actual; + } else if (expected !== actual) { + matched = false; + break; + } + } + if (matched) return { route, params }; + } + return undefined; +} diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts new file mode 100644 index 0000000..52ec678 --- /dev/null +++ b/packages/server/src/server.ts @@ -0,0 +1,359 @@ +/** + * The read-only HTTP server + * ([ADR-0055](../../../docs/adr/0055-serve-a-read-only-rest-api-over-the-sdk-in-the-oci-image-and-reach-mcp-clients-th.md)). + * + * `node:http` and nothing else (clause 10). The handler: + * + * 1. refuses every method but `GET` and `HEAD` with 405 and `Allow: GET, HEAD`, before + * routing, and never reads a request body (clause 2); + * 2. matches the path against the route table, the same table `/openapi.json` is built from; + * 3. answers each data route with one SDK call and a serialization, as + * `{ corpus, commit, data }` in canonical JSON, so the same commit and parameters always + * give the same bytes (clause 3); + * 4. answers every error from the fixed table in `messages.ts`, whatever was thrown (clause 8). + * + * It writes no access log: a request line would carry caller-supplied paths, and clause 8 + * keeps those out of the log as well as out of responses. + */ + +import { createHash } from 'node:crypto'; +import { createServer as createHttpServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'; +import { openDecisions } from '@adrkit/sdk/src/index.ts'; +import { buildPortalData, canonicalJson, serializePortalData } from '@adrkit/portal/src/data/build.ts'; +import { PORTAL_ASSETS } from '@adrkit/portal/src/page/assets.ts'; +import type { ServerConfig } from './config.ts'; +import { CORPUS_KEY } from './config.ts'; +import { CorpusHolder, type CorpusOpener, type LoadedCorpus } from './corpus.ts'; +import { ERRORS, type ErrorCode } from './messages.ts'; +import { buildOpenApi } from './openapi.ts'; +import { matchRoute, PORTAL_ASSET_ROUTES, type RouteDefinition } from './routes.ts'; +import { edgeOf, governingOf, issueOf, queueOf, recordOf, recordSummaryOf } from './shapes.ts'; +import { parseAsOf, parseKinds, RECORD_ID, utcDate, validateGoverningPath } from './validate.ts'; + +/** The CSP of the portal's own meta policy, plus `frame-ancestors 'none'`, which a meta policy cannot carry (clause 9). */ +export const PORTAL_CSP = + "default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self'; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'"; +/** Everything else: nothing may load, and nothing may frame it. */ +export const API_CSP = "default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'"; + +const CONTENT_TYPES = Object.freeze({ + json: 'application/json; charset=utf-8', + html: 'text/html; charset=utf-8', + css: 'text/css; charset=utf-8', + javascript: 'text/javascript; charset=utf-8', +}); + +export interface ServerOptions { + readonly config: Pick; + /** Clock for the default queue and portal `asOf`. Tests pass a fixed one. */ + readonly now?: () => Date; + /** Corpus loader. Defaults to `openDecisions`. */ + readonly open?: CorpusOpener; +} + +interface Reply { + readonly status: number; + readonly kind: keyof typeof CONTENT_TYPES; + readonly body: string; + readonly csp: string; + readonly etag?: string; + readonly location?: string; +} + +interface RequestContext { + readonly route: RouteDefinition; + readonly params: Readonly>; + readonly query: URLSearchParams; + readonly pathname: string; +} + +class Refusal { + constructor(readonly code: ErrorCode) {} +} + +const OPENAPI_BODY = `${canonicalJson(buildOpenApi())}\n`; +const ASSET_BODIES = new Map(PORTAL_ASSETS.map((asset) => [asset.path, asset.contents])); + +function etagOf(...parts: string[]): string { + return `"${createHash('sha256').update(parts.join('\n')).digest('hex').slice(0, 40)}"`; +} + +function errorReply(code: ErrorCode): Reply { + const { status, message } = ERRORS[code]; + return { status, kind: 'json', csp: API_CSP, body: `${canonicalJson({ error: { code, message } })}\n` }; +} + +function jsonReply(value: unknown, etag?: string): Reply { + return { status: 200, kind: 'json', csp: API_CSP, body: `${canonicalJson(value)}\n`, etag }; +} + +/** Read one query parameter: absent, or exactly once. Unknown parameters are ignored. */ +function single(query: URLSearchParams, name: string): string | undefined { + const values = query.getAll(name); + if (values.length > 1) throw new Refusal('invalid-query'); + return values[0]; +} + +/** Build the request handler. Exported for tests that want no socket. */ +export function createRequestHandler(options: ServerOptions): (request: IncomingMessage, response: ServerResponse) => void { + const now = options.now ?? (() => new Date()); + const open: CorpusOpener = options.open ?? ((args) => openDecisions(args)); + const holders = new Map(options.config.corpora.map((corpus) => [corpus.key, new CorpusHolder(corpus, options.config.root, open)])); + const keys = [...holders.keys()]; + + async function loaded(ctx: RequestContext): Promise { + const key = ctx.params.corpus ?? ''; + const holder = CORPUS_KEY.test(key) ? holders.get(key) : undefined; + if (!holder) throw new Refusal('corpus-not-found'); + const state = await holder.current(); + if (!state.ok) throw new Refusal(state.code); + return state.corpus; + } + + /** The configured corpus key in the path, without loading it (the static portal files need no commit). */ + function knownKey(ctx: RequestContext): string { + const key = ctx.params.corpus ?? ''; + if (!CORPUS_KEY.test(key) || !holders.has(key)) throw new Refusal('corpus-not-found'); + return key; + } + + function envelope(ctx: RequestContext, corpus: LoadedCorpus, data: unknown, canonicalQuery = '', extra: Record = {}): Reply { + return jsonReply( + { corpus: corpus.key, commit: corpus.commit, ...extra, data }, + etagOf(ctx.route.operationId, ctx.pathname, canonicalQuery, corpus.commit), + ); + } + + async function handle(ctx: RequestContext): Promise { + switch (ctx.route.operationId) { + case 'getHealth': + return jsonReply({ status: 'ok' }); + case 'getOpenApi': + return { status: 200, kind: 'json', csp: API_CSP, body: OPENAPI_BODY, etag: etagOf(OPENAPI_BODY) }; + case 'listCorpora': { + const data = []; + for (const [key, holder] of holders) { + const state = await holder.current(); + data.push({ corpus: key, commit: state.ok ? state.corpus.commit : null }); + } + return jsonReply({ corpus: null, commit: null, data }); + } + case 'listRecords': { + const corpus = await loaded(ctx); + return envelope(ctx, corpus, corpus.handle.records.map(recordSummaryOf)); + } + case 'getRecord': { + const id = ctx.params.id ?? ''; + if (!RECORD_ID.test(id)) throw new Refusal('invalid-record-id'); + const corpus = await loaded(ctx); + const record = corpus.handle.get(id); + if (!record) throw new Refusal('record-not-found'); + return envelope(ctx, corpus, recordOf(record)); + } + case 'getGraph': { + const raw = single(ctx.query, 'kinds'); + let kinds: readonly ('supersedes' | 'relatesTo' | 'conflictsWith')[] | undefined; + if (raw !== undefined) { + const parsed = parseKinds(raw); + if (!parsed.ok) throw new Refusal('invalid-kinds'); + kinds = parsed.value; + } + const corpus = await loaded(ctx); + const graph = sdkCall(() => corpus.handle.graph(kinds === undefined ? {} : { kinds }), 'invalid-kinds'); + return envelope( + ctx, + corpus, + { nodes: graph.nodes.map(recordSummaryOf), edges: graph.edges.map(edgeOf) }, + kinds === undefined ? '' : `kinds=${kinds.join(',')}`, + ); + } + case 'getQueue': { + const raw = single(ctx.query, 'asOf'); + let asOf = utcDate(now()); + if (raw !== undefined) { + const parsed = parseAsOf(raw); + if (!parsed.ok) throw new Refusal('invalid-as-of'); + asOf = parsed.value; + } + const corpus = await loaded(ctx); + const view = sdkCall(() => corpus.handle.queue({ asOf }), 'invalid-as-of'); + return envelope(ctx, corpus, queueOf(view), `asOf=${view.asOf}`, { asOf: view.asOf }); + } + case 'getIssues': { + const corpus = await loaded(ctx); + return envelope(ctx, corpus, corpus.handle.issues.map(issueOf)); + } + case 'getGoverning': { + const raw = single(ctx.query, 'path'); + if (raw === undefined || raw.length === 0) throw new Refusal('missing-path'); + const path = validateGoverningPath(raw); + if (!path.ok) throw new Refusal('invalid-path'); + const corpus = await loaded(ctx); + // Clause 7: never read the file. The server holds the corpus, not the source tree. + const answer = await corpus.handle.governing(path.value, { readMarkers: false }); + const hasErrors = corpus.handle.issues.some((issue) => issue.severity === 'error'); + return envelope(ctx, corpus, governingOf(answer, hasErrors), `path=${path.value}`); + } + case 'getPortal': + return portalIndex(); + case 'getCorpusPortal': { + knownKey(ctx); + return staticFile('index.html', 'html'); + } + case 'getCorpusPortalData': { + const corpus = await loaded(ctx); + const asOf = utcDate(now()); + let body = corpus.portal.get(asOf); + if (body === undefined) { + body = serializePortalData( + buildPortalData(corpus.handle, { dir: corpus.adrDir, commit: corpus.commit, asOf, pathTable: { state: 'absent' } }), + ); + corpus.portal.clear(); + corpus.portal.set(asOf, body); + } + return { + status: 200, + kind: 'json', + csp: PORTAL_CSP, + body, + etag: etagOf(ctx.route.operationId, ctx.pathname, `asOf=${asOf}`, corpus.commit), + }; + } + default: { + const asset = Object.entries(PORTAL_ASSET_ROUTES).find(([, route]) => route.operationId === ctx.route.operationId); + if (!asset) throw new Refusal('not-found'); + knownKey(ctx); + return staticFile(asset[0], asset[1].kind as Reply['kind']); + } + } + } + + function staticFile(path: string, kind: Reply['kind']): Reply { + const body = ASSET_BODIES.get(path); + if (body === undefined) throw new Refusal('not-found'); + return { status: 200, kind, csp: PORTAL_CSP, body, etag: etagOf('asset', path, body) }; + } + + /** `/`: the one corpus portal, by a relative redirect so a gateway's base path survives; or a list of them. */ + function portalIndex(): Reply { + if (keys.length === 1) { + return { status: 302, kind: 'html', csp: PORTAL_CSP, body: '', location: `./portal/${keys[0]}/` }; + } + const items = keys.map((key) => `
  • ${key}
  • `).join('\n'); + const body = ` + + + + + +Decision portals + + +

    Decision portals

    +
      +${items} +
    +

    Read-only. This server changes nothing.

    + + +`; + return { status: 200, kind: 'html', csp: PORTAL_CSP, body, etag: etagOf('index', body) }; + } + + return (request, response) => { + respond(request, response).catch(() => { + // Only a socket that failed mid-write reaches here; nothing about it is reported. + response.destroy(); + }); + }; + + async function respond(request: IncomingMessage, response: ServerResponse): Promise { + const method = request.method ?? ''; + const head = method === 'HEAD'; + let reply: Reply; + const extraHeaders: Record = {}; + if (method !== 'GET' && !head) { + // Clause 2: refused before routing; the body is never read, and the connection is + // closed so an unread body cannot be taken for the next request. + reply = errorReply('method-not-allowed'); + extraHeaders.Allow = 'GET, HEAD'; + extraHeaders.Connection = 'close'; + } else { + reply = await route(request); + } + write(response, reply, head, request.headers['if-none-match'], extraHeaders); + } + + async function route(request: IncomingMessage): Promise { + try { + let url: URL; + try { + url = new URL(request.url ?? '/', 'http://adrkit.invalid'); + } catch { + return errorReply('not-found'); + } + const match = matchRoute(url.pathname); + if (!match) return errorReply('not-found'); + return await handle({ route: match.route, params: match.params, query: url.searchParams, pathname: url.pathname }); + } catch (error) { + return errorReply(error instanceof Refusal ? error.code : 'internal'); + } + } +} + +/** An SDK call whose `RangeError` is the caller's bad value, not a server fault. */ +function sdkCall(call: () => T, code: ErrorCode): T { + try { + return call(); + } catch (error) { + if (error instanceof RangeError) throw new Refusal(code); + throw error; + } +} + +function write(response: ServerResponse, reply: Reply, head: boolean, ifNoneMatch: string | undefined, extra: Record): void { + const headers: Record = { + 'Content-Type': CONTENT_TYPES[reply.kind], + 'Content-Security-Policy': reply.csp, + 'X-Content-Type-Options': 'nosniff', + 'Referrer-Policy': 'no-referrer', + 'Cache-Control': 'no-cache', + ...extra, + }; + if (reply.location !== undefined) headers.Location = reply.location; + if (reply.etag !== undefined) { + // The validator covers the bytes as well as the commit and canonical query, so a body + // that changes at the same commit (a server or SDK upgrade) never answers 304. + const etag = etagOf(reply.etag, reply.body); + headers.ETag = etag; + if (reply.status === 200 && ifNoneMatch !== undefined && noneMatchHits(ifNoneMatch, etag)) { + response.writeHead(304, headers); + response.end(); + return; + } + } + const body = Buffer.from(reply.body, 'utf8'); + headers['Content-Length'] = String(body.byteLength); + response.writeHead(reply.status, headers); + response.end(head ? undefined : body); +} + +/** + * RFC 9110 section 13.1.2 for GET and HEAD: `*` matches any current representation, and + * each listed tag is compared weakly (a `W/` prefix on either side is ignored). + */ +export function noneMatchHits(header: string, etag: string): boolean { + if (header.trim() === '*') return true; + const opaque = (tag: string): string => tag.trim().replace(/^W\//, ''); + const ours = opaque(etag); + return header.split(',').some((tag) => opaque(tag) === ours); +} + +/** An `http.Server` over {@link createRequestHandler}, not yet listening. */ +export function createAdrkitServer(options: ServerOptions): Server { + const server = createHttpServer(createRequestHandler(options)); + // No request may hold a socket open indefinitely. + server.requestTimeout = 30_000; + server.headersTimeout = 10_000; + return server; +} diff --git a/packages/server/src/shapes.ts b/packages/server/src/shapes.ts new file mode 100644 index 0000000..85c561f --- /dev/null +++ b/packages/server/src/shapes.ts @@ -0,0 +1,110 @@ +/** + * The response shapes of `/api/v1`, projected field by field from the SDK's + * ([ADR-0055](../../../docs/adr/0055-serve-a-read-only-rest-api-over-the-sdk-in-the-oci-image-and-reach-mcp-clients-th.md) + * clauses 1, 3, and 7). + * + * Projected rather than passed through, for two reasons. The canonical serializer refuses + * `undefined`, and the SDK leaves optional fields absent; here "no value" is always `null`, so + * every object of one kind has the same keys. And a field the SDK adds later does not reach + * this API until it is added here, so the route shapes change only by an edit to this file. + * Every string is repository text, served as data. + */ + +import type { + CorpusIssue, + DecisionEdge, + DecisionRecord, + GovernanceEvidence, + GoverningDecision, + PathGovernance, + QueueEntry, + QueueView, +} from '@adrkit/sdk/src/index.ts'; + +export function recordSummaryOf(record: DecisionRecord) { + return { + id: record.id, + title: record.title, + status: record.status, + standing: record.standing, + date: record.date, + path: record.path, + tags: [...record.tags], + supersedes: [...record.supersedes], + supersededBy: record.supersededBy ?? null, + }; +} + +/** `records/{id}`: the summary plus the raw, unrendered Markdown body. */ +export function recordOf(record: DecisionRecord) { + return { ...recordSummaryOf(record), body: record.body }; +} + +export function issueOf(issue: CorpusIssue) { + return { code: issue.code, severity: issue.severity, message: issue.message, path: issue.path ?? null }; +} + +export function edgeOf(edge: DecisionEdge) { + return { from: edge.from, to: edge.to, kind: edge.kind }; +} + +export function queueOf(view: QueueView) { + return { entries: view.entries.map(queueEntryOf), issues: view.issues.map(issueOf) }; +} + +function queueEntryOf(entry: QueueEntry) { + return { + id: entry.id, + title: entry.title, + path: entry.path, + tier: entry.tier, + slaState: entry.slaState, + deadline: entry.deadline, + approvalCount: entry.approvalCount, + quorum: entry.quorum, + unresolvedObjectionCount: entry.unresolvedObjectionCount, + resolvedObjectionCount: entry.resolvedObjectionCount, + routingTargets: [...entry.routingTargets], + itemFindingCount: entry.itemFindingCount, + }; +} + +/** Fixed text for `governing` (clause 7). Never built from a request or an error. */ +export const GOVERNING_TEXT = Object.freeze({ + evidence: + 'This server holds the corpus, not the source tree, so it never reads the file at this path: inbound @adr markers are not scanned, and evidence is affects matches only. Run adr explain in a checkout for marker evidence.', + withheld: + 'Resolution was withheld because the corpus has errors: a record that failed to load may be the one that governs this path. The corpus issues are listed; this is not an "ungoverned" answer.', +}); + +/** + * `governing`'s data. `resolution` says whether the groups are an answer or were withheld, + * so an empty `governing` list never reads as "ungoverned" when the corpus has errors. + */ +export function governingOf(answer: PathGovernance, corpusHasErrors: boolean) { + return { + path: answer.path, + governing: answer.governing.map(governingDecisionOf), + activeProposals: answer.activeProposals.map(governingDecisionOf), + history: answer.history.map(governingDecisionOf), + issues: answer.issues.map(issueOf), + evidence: { markers: 'not-read', note: GOVERNING_TEXT.evidence }, + resolution: corpusHasErrors ? { state: 'withheld', note: GOVERNING_TEXT.withheld } : { state: 'resolved', note: null }, + }; +} + +function governingDecisionOf(decision: GoverningDecision) { + return { + id: decision.id, + title: decision.title, + status: decision.status, + standing: decision.standing, + matchedBy: decision.matchedBy.map(evidenceOf), + }; +} + +function evidenceOf(evidence: GovernanceEvidence) { + return evidence.kind === 'affects' + ? { kind: 'affects', type: evidence.type, pattern: evidence.pattern, line: null } + : { kind: 'marker', type: null, pattern: null, line: evidence.line }; +} diff --git a/packages/server/src/validate.ts b/packages/server/src/validate.ts new file mode 100644 index 0000000..7f10102 --- /dev/null +++ b/packages/server/src/validate.ts @@ -0,0 +1,65 @@ +/** + * Validation of everything a request carries, before any SDK call + * ([ADR-0055](../../../docs/adr/0055-serve-a-read-only-rest-api-over-the-sdk-in-the-oci-image-and-reach-mcp-clients-th.md) + * clauses 7 and 8). Pure: no clock (the caller passes `now`), file, or environment. + */ + +import type { DecisionEdgeKind } from '@adrkit/sdk/src/index.ts'; + +/** + * A record id: the schema's `id` grammar (`packages/core/src/schema/adr.schema.ts`, four or + * more digits or a ULID), with the digit form capped at 64 so a path segment cannot be long. + */ +export const RECORD_ID = /^(?:[0-9]{4,64}|[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26})$/; + +/** `adr graph --kind`'s vocabulary, in the canonical order a query is normalized to. */ +export const EDGE_KINDS: readonly DecisionEdgeKind[] = Object.freeze(['supersedes', 'relatesTo', 'conflictsWith']); + +const CALENDAR_DATE = /^\d{4}-\d{2}-\d{2}$/; +const CONTROL = /[\u0000-\u001f\u007f-\u009f]/; +const MAX_PATH = 1024; + +export type Validated = { readonly ok: true; readonly value: T } | { readonly ok: false }; + +const invalid = { ok: false } as const; + +/** + * A caller-supplied path for `governing`: relative, no `..` segment, no leading `-`, no control + * character, at most 1024 characters. It is never read (`readMarkers: false`); it is only + * matched against `affects`. + */ +export function validateGoverningPath(path: string): Validated { + if (path.length === 0 || path.length > MAX_PATH) return invalid; + if (CONTROL.test(path) || path.startsWith('-')) return invalid; + if (path.startsWith('/') || path.startsWith('\\') || /^[A-Za-z]:/.test(path)) return invalid; + if (path.split(/[\\/]/).some((segment) => segment === '..')) return invalid; + return { ok: true, value: path }; +} + +/** `kinds`: a comma-separated, non-empty subset of the three edge kinds, returned de-duplicated in canonical order. */ +export function parseKinds(raw: string): Validated { + const items = raw.split(','); + const wanted = new Set(); + for (const item of items) { + if (!(EDGE_KINDS as readonly string[]).includes(item)) return invalid; + wanted.add(item); + } + return { ok: true, value: EDGE_KINDS.filter((kind) => wanted.has(kind)) }; +} + +/** + * `asOf`: a real UTC calendar date in `YYYY-MM-DD` form. The same rule as core's as-of + * resolver, which `DecisionSet.queue` applies: the ISO date must round-trip through `Date`. + * (`Date.UTC` would map years 0 to 99 onto 1900 to 1999 and refuse dates the SDK accepts.) + */ +export function parseAsOf(raw: string): Validated { + if (!CALENDAR_DATE.test(raw)) return invalid; + const probe = new Date(`${raw}T00:00:00Z`); + if (!Number.isFinite(probe.getTime()) || probe.toISOString().slice(0, 10) !== raw) return invalid; + return { ok: true, value: raw }; +} + +/** The UTC calendar date of `now`, `YYYY-MM-DD`. */ +export function utcDate(now: Date): string { + return now.toISOString().slice(0, 10); +} diff --git a/packages/server/test/bin.test.ts b/packages/server/test/bin.test.ts new file mode 100644 index 0000000..fecab31 --- /dev/null +++ b/packages/server/test/bin.test.ts @@ -0,0 +1,88 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { join, resolve } from 'node:path'; +import { CONFIG_MESSAGES } from '../src/config.ts'; +import { NON_LOOPBACK_WARNING, USAGE } from '../src/messages.ts'; +import { removeScratch, scratch, writeCorpus } from './helpers.ts'; + +/** ADR-0055 clause 14: bind loopback by default; warn, once and in fixed words, on anything else. */ + +const BIN = resolve(import.meta.dir, '../src/bin.ts'); +const NAME = 'bin'; +const children: Array> = []; +afterEach(async () => { + for (const child of children.splice(0)) { + child.kill('SIGTERM'); + await child.exited; + } + await removeScratch(NAME); +}); + +/** Start the binary and wait for its listening line. */ +async function launch(env: Record): Promise<{ line: string; stderr: () => Promise; child: ReturnType }> { + const child = Bun.spawn([process.execPath, BIN], { + env: { PATH: process.env.PATH ?? '', ADRKIT_SERVER_PORT: '0', ...env }, + stdout: 'pipe', + stderr: 'pipe', + }); + children.push(child); + const reader = (child.stdout as ReadableStream).getReader(); + let line = ''; + while (!line.includes('\n')) { + const { value, done } = await reader.read(); + if (done) break; + line += new TextDecoder().decode(value); + } + reader.releaseLock(); + return { line: line.trim(), child, stderr: () => new Response(child.stderr as ReadableStream).text() }; +} + +describe('adrkit-server', () => { + test('binds 127.0.0.1 by default and prints no warning', async () => { + const root = await scratch(NAME); + await writeCorpus(root, 'demo'); + const { line, child, stderr } = await launch({ ADRKIT_SERVER_ROOT: root, ADRKIT_SERVER_CORPORA: 'demo' }); + expect(line).toMatch(/^adrkit-server: listening on http:\/\/127\.0\.0\.1:\d+ \(1 corpus\)$/); + const port = /:(\d+) /.exec(line)![1]; + expect((await fetch(`http://127.0.0.1:${port}/healthz`)).status).toBe(200); + child.kill('SIGTERM'); + expect(await child.exited).toBe(0); + expect(await stderr()).toBe(''); + }); + + // ADR-0016 clause 2: observed failing with the `isLoopbackHost` guard in src/bin.ts + // inverted (the warning printed on loopback binds only). The retained input is the + // non-loopback bind below. + test('a non-loopback bind prints the fixed warning once', async () => { + const root = await scratch(NAME); + await writeCorpus(root, 'demo'); + const { line, child, stderr } = await launch({ ADRKIT_SERVER_ROOT: root, ADRKIT_SERVER_CORPORA: 'demo', ADRKIT_SERVER_HOST: '0.0.0.0' }); + expect(line).toMatch(/^adrkit-server: listening on http:\/\/0\.0\.0\.0:\d+/); + child.kill('SIGTERM'); + await child.exited; + expect(await stderr()).toBe(`${NON_LOOPBACK_WARNING}\n`); + expect(NON_LOOPBACK_WARNING).toContain('This server authenticates no one'); + }); + + test('a bad configuration exits 2 with fixed text and no path', async () => { + const root = await scratch(NAME); + const child = Bun.spawn([process.execPath, BIN], { + env: { PATH: process.env.PATH ?? '', ADRKIT_SERVER_ROOT: root, ADRKIT_SERVER_CORPORA: `a=${join('..', 'escape-SENTINEL')}` }, + stdout: 'pipe', + stderr: 'pipe', + }); + expect(await child.exited).toBe(2); + const stderr = await new Response(child.stderr).text(); + expect(stderr).toBe(`adrkit-server: ${CONFIG_MESSAGES['corpus-escapes-root']} (corpus "a")\n`); + expect(stderr).not.toContain('SENTINEL'); + expect(stderr).not.toContain(root); + }); + + test('--help prints the usage; any other argument is a usage error', async () => { + const help = Bun.spawn([process.execPath, BIN, '--help'], { stdout: 'pipe', stderr: 'pipe' }); + expect(await help.exited).toBe(0); + expect(await new Response(help.stdout).text()).toBe(USAGE); + const bad = Bun.spawn([process.execPath, BIN, 'serve'], { stdout: 'pipe', stderr: 'pipe' }); + expect(await bad.exited).toBe(2); + expect(await new Response(bad.stderr).text()).toBe(USAGE); + }); +}); diff --git a/packages/server/test/config.test.ts b/packages/server/test/config.test.ts new file mode 100644 index 0000000..a71c5ba --- /dev/null +++ b/packages/server/test/config.test.ts @@ -0,0 +1,116 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { mkdir, symlink } from 'node:fs/promises'; +import { join } from 'node:path'; +import { ConfigError, isLoopbackHost, loadServerConfig, parseCorporaSpec, parseHost, parsePort } from '../src/config.ts'; +import { removeScratch, scratch } from './helpers.ts'; + +const NAME = 'config'; +afterEach(async () => { + await removeScratch(NAME); + await removeScratch(`${NAME}-outside`); +}); + +function codeOf(run: () => unknown): string | undefined { + try { + run(); + } catch (error) { + return error instanceof ConfigError ? error.code : 'not-a-config-error'; + } + return undefined; +} + +async function asyncCodeOf(run: () => Promise): Promise { + try { + await run(); + } catch (error) { + return error instanceof ConfigError ? error.code : 'not-a-config-error'; + } + return undefined; +} + +describe('ADRKIT_SERVER_CORPORA', () => { + test('the list form: bare keys and key=dir, sorted by key', () => { + expect(parseCorporaSpec(' zeta , alpha=repos/alpha ')).toEqual([ + { key: 'alpha', dir: 'repos/alpha', adrDir: 'docs/adr' }, + { key: 'zeta', dir: undefined, adrDir: 'docs/adr' }, + ]); + }); + + test('the JSON form: a string dir, or an object with dir and adrDir', () => { + expect(parseCorporaSpec('{"b":"repos/b","a":{"adrDir":"./decisions/"},"c":{"dir":"/srv/c"}}')).toEqual([ + { key: 'a', dir: undefined, adrDir: 'decisions' }, + { key: 'b', dir: 'repos/b', adrDir: 'docs/adr' }, + { key: 'c', dir: '/srv/c', adrDir: 'docs/adr' }, + ]); + }); + + test('refuses what it cannot read exactly', () => { + const cases: Array<[string | undefined, string]> = [ + [undefined, 'corpora-missing'], + [' ', 'corpora-missing'], + ['{}', 'corpora-missing'], + ['a,,b', 'corpora-malformed'], + ['a=', 'corpora-malformed'], + ['{not json', 'corpora-malformed'], + ['{"a":1}', 'corpora-malformed'], + ['{"a":{"dir":"x","extra":true}}', 'corpora-malformed'], + ['{"a":{"dir":7}}', 'corpora-malformed'], + ['Upper', 'corpus-key-invalid'], + ['-lead', 'corpus-key-invalid'], + [`k${'x'.repeat(64)}`, 'corpus-key-invalid'], + ['a/b', 'corpus-key-invalid'], + ['a,a=x', 'corpus-key-duplicate'], + ['{"a":{"adrDir":"../up"}}', 'adr-dir-invalid'], + ['{"a":{"adrDir":"/abs"}}', 'adr-dir-invalid'], + ['{"a":"x\\u0000y"}', 'corpus-dir-invalid'], + ]; + for (const [value, code] of cases) expect([value, codeOf(() => parseCorporaSpec(value))]).toEqual([value, code]); + }); +}); + +describe('host and port', () => { + test('defaults are loopback 127.0.0.1 and 8080', () => { + expect(parseHost(undefined)).toBe('127.0.0.1'); + expect(parsePort(undefined)).toBe(8080); + expect(parsePort('0')).toBe(0); + for (const value of ['-1', '65536', '80a', '1e3']) expect(codeOf(() => parsePort(value))).toBe('port-invalid'); + for (const value of ['', ' ', 'a b']) expect(codeOf(() => parseHost(value))).toBe('host-invalid'); + }); + + test('only loopback addresses are loopback', () => { + for (const host of ['127.0.0.1', '127.1.2.3', 'localhost', '::1', '[::1]', '::ffff:127.0.0.1']) expect([host, isLoopbackHost(host)]).toEqual([host, true]); + for (const host of ['0.0.0.0', '::', '10.0.0.5', '192.168.1.1', 'example.com', '128.0.0.1']) expect([host, isLoopbackHost(host)]).toEqual([host, false]); + }); +}); + +describe('confinement (clause 6)', () => { + test('a bare key resolves to /, realpath-resolved', async () => { + const root = await scratch(NAME); + await mkdir(join(root, 'demo')); + const config = await loadServerConfig({ ADRKIT_SERVER_ROOT: root, ADRKIT_SERVER_CORPORA: 'demo,later' }, '/'); + expect(config.root).toBe(root); + expect(config.corpora).toEqual([ + { key: 'demo', dir: join(root, 'demo'), adrDir: 'docs/adr' }, + { key: 'later', dir: join(root, 'later'), adrDir: 'docs/adr' }, + ]); + expect([config.host, config.port]).toEqual(['127.0.0.1', 8080]); + }); + + test('a directory outside the root, lexically or through a symlink, refuses to start', async () => { + const root = await scratch(NAME); + const outside = await scratch(`${NAME}-outside`); + await symlink(outside, join(root, 'link')); + for (const corpora of ['a=../x', `a=${outside}`, 'a=link', 'a=link/inner']) { + expect([corpora, await asyncCodeOf(() => loadServerConfig({ ADRKIT_SERVER_ROOT: root, ADRKIT_SERVER_CORPORA: corpora }, '/'))]).toEqual([ + corpora, + 'corpus-escapes-root', + ]); + } + }); + + test('a root that does not exist refuses to start', async () => { + expect(await asyncCodeOf(() => loadServerConfig({ ADRKIT_SERVER_ROOT: '/definitely/not/here', ADRKIT_SERVER_CORPORA: 'a' }, '/'))).toBe( + 'root-unreadable', + ); + }); +}); diff --git a/packages/server/test/error-text.test.ts b/packages/server/test/error-text.test.ts new file mode 100644 index 0000000..6117868 --- /dev/null +++ b/packages/server/test/error-text.test.ts @@ -0,0 +1,116 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { join } from 'node:path'; +import type { CorpusHandle, CorpusOpener } from '../src/corpus.ts'; +import { ERRORS } from '../src/messages.ts'; +import { ROUTES } from '../src/routes.ts'; +import { removeScratch, scratch, start, writeCorpus, writeText, type Running } from './helpers.ts'; + +/** + * ADR-0055 clause 8: errors are fixed text. A sentinel is planted in a thrown error (from the + * loader and from every handle method), in an invalid sidecar, and in a corpus directory name, + * and must appear in no response body or header — as + * `packages/adapters/agent-plugin/test/error-text.test.ts` does for the plugin. + * + * ADR-0016 clause 2: observed failing with the catch-all in `route()` (src/server.ts) changed + * to answer `{ error: { code: 'internal', message: String(error) } }`: the sentinel then + * appeared in the 500 bodies below. The retained input is the throwing handle. + */ + +const SENTINEL = 'SENTINEL-7f3a-stack-and-stderr'; +const NAME = 'error-text'; +const running: Running[] = []; +afterEach(async () => { + for (const server of running.splice(0)) await server.close(); + await removeScratch(NAME); +}); + +function boom(): never { + const error = new Error(`${SENTINEL} at /secret/path/${SENTINEL}`); + error.stack = `Error: ${SENTINEL}\n at handler (/opt/${SENTINEL}/server.js:1:1)`; + throw error; +} + +const throwingHandle: CorpusHandle = { + get records(): never { + return boom(); + }, + get issues(): never { + return boom(); + }, + get: () => boom(), + queue: () => boom(), + graph: () => boom(), + governing: async () => boom(), +}; + +/** Every concrete GET path the table has, plus error-provoking variants. */ +function paths(key: string): string[] { + const concrete = ROUTES.map((route) => route.path.replace('{corpus}', key).replace('{id}', '0002')); + return [ + ...concrete, + `/api/v1/corpora/${key}/governing?path=src/a.ts`, + `/api/v1/corpora/${key}/graph?kinds=${SENTINEL}`, + `/api/v1/corpora/${key}/queue?asOf=${SENTINEL}`, + `/api/v1/corpora/${key}/governing?path=/${SENTINEL}`, + `/api/v1/corpora/${key}/records/${SENTINEL}`, + `/api/v1/corpora/${SENTINEL.toLowerCase()}/records`, + `/${SENTINEL}`, + ]; +} + +async function assertNoSentinel(url: string, key: string): Promise> { + const statuses = new Set(); + for (const path of paths(key)) { + for (const method of ['GET', 'POST']) { + const response = await fetch(url + path, { method, redirect: 'manual' }); + statuses.add(response.status); + const text = await response.text(); + const headers = [...response.headers.entries()].map(([name, value]) => `${name}: ${value}`).join('\n'); + expect({ path, method, leaked: text.includes(SENTINEL) || headers.includes(SENTINEL) }).toEqual({ path, method, leaked: false }); + if (response.status >= 400) { + const body = JSON.parse(text) as { error: { code: keyof typeof ERRORS; message: string } }; + expect(body.error.message).toBe(ERRORS[body.error.code].message); + } + } + } + return statuses; +} + +describe('no exception, stack, or path text reaches a response', () => { + test('a handle whose every method throws answers fixed 500s', async () => { + const root = await scratch(NAME); + await writeCorpus(root, 'demo'); + const open: CorpusOpener = async () => throwingHandle; + const server = await start({ ADRKIT_SERVER_ROOT: root, ADRKIT_SERVER_CORPORA: 'demo' }, { open }); + running.push(server); + const statuses = await assertNoSentinel(server.url, 'demo'); + expect(statuses.has(500)).toBe(true); + }); + + test('a loader that throws answers a fixed 503', async () => { + const root = await scratch(NAME); + await writeCorpus(root, 'demo'); + const open: CorpusOpener = async () => boom(); + const server = await start({ ADRKIT_SERVER_ROOT: root, ADRKIT_SERVER_CORPORA: 'demo' }, { open }); + running.push(server); + const statuses = await assertNoSentinel(server.url, 'demo'); + expect(statuses.has(503)).toBe(true); + }); + + test('a sidecar and a directory name carrying the sentinel stay out of every response', async () => { + const root = await scratch(NAME); + const dir = await writeCorpus(root, `dir-${SENTINEL}`, null); + await writeText(join(dir, '.adrkit-corpus.json'), JSON.stringify({ commit: SENTINEL })); + const server = await start({ ADRKIT_SERVER_ROOT: root, ADRKIT_SERVER_CORPORA: `demo=dir-${SENTINEL}` }); + running.push(server); + const statuses = await assertNoSentinel(server.url, 'demo'); + expect(statuses.has(503)).toBe(true); + }); + + test('a corpus directory that does not exist under the root stays out of every response', async () => { + const root = await scratch(NAME); + const server = await start({ ADRKIT_SERVER_ROOT: root, ADRKIT_SERVER_CORPORA: `demo=missing-${SENTINEL}` }); + running.push(server); + await assertNoSentinel(server.url, 'demo'); + }); +}); diff --git a/packages/server/test/helpers.ts b/packages/server/test/helpers.ts new file mode 100644 index 0000000..5bedab8 --- /dev/null +++ b/packages/server/test/helpers.ts @@ -0,0 +1,100 @@ +import { mkdir, realpath, rm, writeFile } from 'node:fs/promises'; +import type { AddressInfo } from 'node:net'; +import { dirname, join, resolve } from 'node:path'; +import { loadServerConfig, type ServerConfig } from '../src/config.ts'; +import { createAdrkitServer, type ServerOptions } from '../src/server.ts'; +import { acceptedRecordMarkdown, recordMarkdown, supersededRecordMarkdown } from '../../core/test/helpers.ts'; + +export const COMMIT_A = 'a'.repeat(40); +export const COMMIT_B = 'b'.repeat(40); +export const FIXED_NOW = () => new Date('2026-10-10T12:00:00Z'); + +const OUTPUT = resolve(process.cwd(), '.test-output', 'server'); + +export async function writeText(path: string, content: string): Promise { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, content, 'utf8'); +} + +/** A fresh, realpath'd scratch root for one test. */ +export async function scratch(name: string): Promise { + const path = join(OUTPUT, name); + await rm(path, { recursive: true, force: true }); + await mkdir(path, { recursive: true }); + return realpath(path); +} + +export async function removeScratch(name: string): Promise { + await rm(join(OUTPUT, name), { recursive: true, force: true }); +} + +/** + * A healthy corpus under `/`: an accepted record governing `src/**`, one it + * superseded, a proposed record in review that relates to the accepted one, and a sidecar. + */ +export async function writeCorpus(root: string, key: string, commit: string | null = COMMIT_A): Promise { + const dir = join(root, key); + const adr = join(dir, 'docs/adr'); + await writeText( + join(adr, '0001-use-a-decision-corpus.md'), + supersededRecordMarkdown('0001', '0002', 'Use a decision corpus'), + ); + await writeText( + join(adr, '0002-govern-source.md'), + acceptedRecordMarkdown('0002', 'Govern the source tree').replace( + 'affects: []', + 'affects:\n - type: path\n pattern: "src/**"', + ).replace('relatesTo: []', 'relatesTo: []\nsupersedes:\n - "0001"'), + ); + await writeText( + join(adr, '0003-review-a-proposal.md'), + recordMarkdown('0003', 'Review a proposal') + .replace('status: draft', 'status: proposed') + .replace('deciders: []', 'deciders: ["@reviewer"]') + .replace('relatesTo: []', 'relatesTo:\n - "0002"\nreviewBy: 2026-10-20'), + ); + if (commit !== null) await writeSidecar(dir, commit); + return dir; +} + +export async function writeSidecar(dir: string, commit: string): Promise { + await writeText(join(dir, '.adrkit-corpus.json'), `${JSON.stringify({ commit })}\n`); +} + +export interface Running { + readonly url: string; + readonly config: ServerConfig; + close(): Promise; +} + +/** Load the configuration as the binary does and listen on an ephemeral loopback port. */ +export async function start( + env: Record, + extra: Partial> = {}, +): Promise { + const config = await loadServerConfig(env, process.cwd()); + const server = createAdrkitServer({ config, now: FIXED_NOW, ...extra }); + await new Promise((done) => server.listen(0, '127.0.0.1', done)); + const { port } = server.address() as AddressInfo; + return { + url: `http://127.0.0.1:${port}`, + config, + close: () => + new Promise((done) => { + server.close(() => done()); + server.closeAllConnections(); + }), + }; +} + +export async function getJson(url: string): Promise<{ status: number; body: any; text: string; headers: Headers }> { + const response = await fetch(url); + const text = await response.text(); + let body: unknown = undefined; + try { + body = JSON.parse(text); + } catch { + /* not JSON */ + } + return { status: response.status, body, text, headers: response.headers }; +} diff --git a/packages/server/test/methods.test.ts b/packages/server/test/methods.test.ts new file mode 100644 index 0000000..a888c3a --- /dev/null +++ b/packages/server/test/methods.test.ts @@ -0,0 +1,93 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { request } from 'node:http'; +import { ROUTES, type RouteDefinition } from '../src/routes.ts'; +import { removeScratch, scratch, start, writeCorpus, type Running } from './helpers.ts'; + +/** + * ADR-0055 clause 2, first half: no write route. Only `GET` (and `HEAD`, answered as `GET`) + * is routed; anything else is 405 with `Allow: GET, HEAD`, and no request body is read. + */ + +const NAME = 'methods'; +const running: Running[] = []; +afterEach(async () => { + for (const server of running.splice(0)) await server.close(); + await removeScratch(NAME); +}); + +/** The route-table check, as a function so the plant below can run it against a bad table. */ +function nonGetRoutes(routes: readonly RouteDefinition[]): string[] { + return routes.filter((route) => route.method !== 'GET').map((route) => `${route.method} ${route.path}`); +} + +describe('the route table holds GET routes only', () => { + test('every row is GET', () => { + expect(ROUTES.length).toBeGreaterThan(10); + expect(nonGetRoutes(ROUTES)).toEqual([]); + }); + + // ADR-0016 clause 2: observed failing against a planted row. With a row whose `method` is + // `'POST'` (cast past the type) appended to ROUTES in src/routes.ts, `every row is GET` + // failed. The retained input is a planted row, checked here against the same function. + test('the check catches a planted write route', () => { + const planted = [...ROUTES, { ...ROUTES[0]!, method: 'POST' } as unknown as RouteDefinition]; + expect(nonGetRoutes(planted)).toEqual(['POST /healthz']); + }); +}); + +/** Send a raw request with a body and report what came back, without the client reading our body for us. */ +function send(url: string, method: string, body: string): Promise<{ status: number; allow: string | undefined; text: string }> { + return new Promise((resolve, reject) => { + const req = request(url, { method, headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) } }, (res) => { + let text = ''; + res.setEncoding('utf8'); + res.on('data', (chunk) => (text += chunk)); + res.on('end', () => resolve({ status: res.statusCode ?? 0, allow: res.headers.allow, text })); + }); + req.on('error', reject); + req.end(body); + }); +} + +describe('live method guard', () => { + test('every other method is 405 with Allow: GET, HEAD on every route and on unknown paths', async () => { + const root = await scratch(NAME); + await writeCorpus(root, 'demo'); + const server = await start({ ADRKIT_SERVER_ROOT: root, ADRKIT_SERVER_CORPORA: 'demo' }); + running.push(server); + const paths = ['/', '/healthz', '/api/v1/corpora', '/api/v1/corpora/demo/records', '/api/v1/corpora/demo/records/0002', '/nope']; + for (const method of ['POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS']) { + for (const path of paths) { + const { status, allow, text } = await send(server.url + path, method, '{"status":"accepted"}'); + expect([method, path, status, allow]).toEqual([method, path, 405, 'GET, HEAD']); + expect(JSON.parse(text).error.code).toBe('method-not-allowed'); + } + } + }); + + test('HEAD answers the GET headers with no body', async () => { + const root = await scratch(NAME); + await writeCorpus(root, 'demo'); + const server = await start({ ADRKIT_SERVER_ROOT: root, ADRKIT_SERVER_CORPORA: 'demo' }); + running.push(server); + for (const path of ['/api/v1/corpora/demo/records', '/portal/demo/', '/nope']) { + const get = await fetch(server.url + path); + const head = await fetch(server.url + path, { method: 'HEAD' }); + expect([path, head.status]).toEqual([path, get.status]); + expect(head.headers.get('content-length')).toBe(get.headers.get('content-length')); + expect(head.headers.get('etag')).toBe(get.headers.get('etag')); + expect(await head.text()).toBe(''); + } + }); + + test('a GET that carries a body is answered without reading it, and nothing changes on disk', async () => { + const root = await scratch(NAME); + await writeCorpus(root, 'demo'); + const server = await start({ ADRKIT_SERVER_ROOT: root, ADRKIT_SERVER_CORPORA: 'demo' }); + running.push(server); + const { status } = await send(`${server.url}/api/v1/corpora/demo/records`, 'GET', '{"status":"accepted"}'); + expect(status).toBe(200); + const after = await (await fetch(`${server.url}/api/v1/corpora/demo/records/0003`)).json(); + expect((after as { data: { status: string } }).data.status).toBe('proposed'); + }); +}); diff --git a/packages/server/test/openapi.test.ts b/packages/server/test/openapi.test.ts new file mode 100644 index 0000000..cef16f5 --- /dev/null +++ b/packages/server/test/openapi.test.ts @@ -0,0 +1,101 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { PORTAL_ASSETS } from '@adrkit/portal/src/page/assets.ts'; +import { buildOpenApi } from '../src/openapi.ts'; +import { matchRoute, PORTAL_ASSET_ROUTES, ROUTES, type RouteDefinition } from '../src/routes.ts'; +import { removeScratch, scratch, start, writeCorpus, type Running } from './helpers.ts'; + +/** ADR-0055 clause 11: the document and the route table agree; `operationId`s are stable. */ + +const NAME = 'openapi'; +const running: Running[] = []; +afterEach(async () => { + for (const server of running.splice(0)) await server.close(); + await removeScratch(NAME); +}); + +/** + * The operationIds remote agents see as tool names through APIM (clause 15). The first nine + * are the names adrkit-azure's placeholder document already wires; renaming any is a break. + */ +const STABLE_IDS = [ + 'listCorpora', + 'listRecords', + 'getRecord', + 'getGraph', + 'getQueue', + 'getIssues', + 'getGoverning', + 'getOpenApi', + 'getPortal', + 'getHealth', + 'getCorpusPortal', + 'getCorpusPortalData', + 'getPortalStylesheet', + 'getPortalApp', + 'getPortalBoardLayout', + 'getPortalBoardView', +]; + +type Doc = ReturnType; + +/** Every disagreement between a document and a route table, as strings. */ +function disagreements(doc: Doc, routes: readonly RouteDefinition[]): string[] { + const problems: string[] = []; + const documented = new Map(); + for (const [path, item] of Object.entries(doc.paths)) { + for (const [method, operation] of Object.entries(item)) { + if (method !== 'get') problems.push(`non-get operation ${method} ${path}`); + documented.set(`${path} ${(operation as { operationId: string }).operationId}`, method); + } + } + for (const route of routes) { + if (!documented.delete(`${route.path} ${route.operationId}`)) problems.push(`undocumented route ${route.operationId} ${route.path}`); + } + for (const key of documented.keys()) problems.push(`documented route the table lacks: ${key}`); + return problems; +} + +describe('the OpenAPI document', () => { + test('describes exactly the route table, as get operations only', () => { + expect(disagreements(buildOpenApi(), ROUTES)).toEqual([]); + }); + + // ADR-0016 clause 2: observed failing with the served document in src/server.ts built from + // `buildOpenApi(ROUTES.slice(1))` (one route dropped): `the served document is the + // generated one` failed. The retained input is the slice, checked here against the same + // function. + test('the agreement check catches a dropped or extra operation', () => { + expect(disagreements(buildOpenApi(ROUTES.slice(1)), ROUTES)).toEqual(['undocumented route getHealth /healthz']); + const extra = buildOpenApi([...ROUTES, { ...ROUTES[0]!, operationId: 'postThing', path: '/thing' }]); + expect(disagreements(extra, ROUTES)).toEqual(['documented route the table lacks: /thing postThing']); + }); + + test('operationIds are unique and the stable set is exactly the table', () => { + const ids = ROUTES.map((route) => route.operationId); + expect(new Set(ids).size).toBe(ids.length); + expect([...ids].sort()).toEqual([...STABLE_IDS].sort()); + }); + + test('every page asset of the portal bundle has a route', () => { + const served = new Set(['index.html', ...Object.keys(PORTAL_ASSET_ROUTES)]); + expect(PORTAL_ASSETS.map((asset) => asset.path).filter((path) => !served.has(path))).toEqual([]); + }); + + test('each template matches its own path and no other route shadows it', () => { + for (const route of ROUTES) { + const concrete = route.path.replace('{corpus}', 'demo').replace('{id}', '0001'); + expect([concrete, matchRoute(concrete)?.route.operationId]).toEqual([concrete, route.operationId]); + } + }); + + test('the served document is the generated one, byte for byte', async () => { + const root = await scratch(NAME); + await writeCorpus(root, 'demo'); + const server = await start({ ADRKIT_SERVER_ROOT: root, ADRKIT_SERVER_CORPORA: 'demo' }); + running.push(server); + const served = (await (await fetch(`${server.url}/openapi.json`)).json()) as Doc; + expect(disagreements(served, ROUTES)).toEqual([]); + expect(served).toEqual(JSON.parse(JSON.stringify(buildOpenApi()))); + expect(served.openapi).toBe('3.0.3'); + }); +}); diff --git a/packages/server/test/portal.test.ts b/packages/server/test/portal.test.ts new file mode 100644 index 0000000..ef4bfae --- /dev/null +++ b/packages/server/test/portal.test.ts @@ -0,0 +1,88 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { PORTAL_ASSETS } from '@adrkit/portal/src/page/assets.ts'; +import { PORTAL_CSP } from '../src/server.ts'; +import { COMMIT_A, getJson, removeScratch, scratch, start, writeCorpus, type Running } from './helpers.ts'; + +/** ADR-0055 clause 9: the portal bundle and its data file, from the same handle and commit as the API. */ + +const NAME = 'portal'; +const running: Running[] = []; +afterEach(async () => { + for (const server of running.splice(0)) await server.close(); + await removeScratch(NAME); +}); + +async function serve(corpora = 'demo'): Promise { + const root = await scratch(NAME); + for (const key of corpora.split(',')) await writeCorpus(root, key); + const server = await start({ ADRKIT_SERVER_ROOT: root, ADRKIT_SERVER_CORPORA: corpora }); + running.push(server); + return server; +} + +describe('the portal', () => { + // ADR-0016 clause 2: observed failing with `frame-ancestors 'none'` removed from PORTAL_CSP + // in src/server.ts. The retained input is the directive, asserted below on every portal file. + test("every portal file carries the page's CSP plus frame-ancestors 'none', nosniff, and no-referrer", async () => { + const { url } = await serve(); + for (const path of ['/portal/demo/', '/portal/demo/portal-data.json', ...PORTAL_ASSETS.filter((a) => a.path !== 'index.html').map((a) => `/portal/demo/${a.path}`)]) { + const response = await fetch(url + path); + expect([path, response.status]).toEqual([path, 200]); + const csp = response.headers.get('content-security-policy') ?? ''; + expect(csp).toBe(PORTAL_CSP); + expect(csp).toContain("frame-ancestors 'none'"); + expect(csp).toContain("default-src 'none'"); + expect(response.headers.get('x-content-type-options')).toBe('nosniff'); + expect(response.headers.get('referrer-policy')).toBe('no-referrer'); + } + }); + + test('serves the bundle files verbatim, with their content types', async () => { + const { url } = await serve(); + const types: Record = { 'index.html': 'text/html', 'app.css': 'text/css' }; + for (const asset of PORTAL_ASSETS) { + const response = await fetch(`${url}/portal/demo/${asset.path === 'index.html' ? '' : asset.path}`); + expect(await response.text()).toBe(asset.contents); + expect(response.headers.get('content-type')).toStartWith(types[asset.path] ?? 'text/javascript'); + } + }); + + test('portal-data.json is built at the sidecar commit with an absent path table', async () => { + const { url } = await serve(); + const { body, headers } = await getJson(`${url}/portal/demo/portal-data.json`); + expect(headers.get('content-type')).toStartWith('application/json'); + expect(body.version).toBe('1'); + expect(body.commit).toBe(COMMIT_A); + expect(body.dir).toBe('docs/adr'); + expect(body.asOf).toBe('2026-10-10'); + expect(body.pathTable).toEqual({ state: 'absent' }); + expect(body.records.map((record: { id: string }) => record.id)).toEqual(['0001', '0002', '0003']); + const records = await getJson(`${url}/api/v1/corpora/demo/records`); + expect(records.body.commit).toBe(body.commit); + }); + + test('/ redirects relatively to the only corpus portal', async () => { + const { url } = await serve(); + const response = await fetch(`${url}/`, { redirect: 'manual' }); + expect(response.status).toBe(302); + expect(response.headers.get('location')).toBe('./portal/demo/'); + }); + + test('/ lists the corpus portals, with relative links, when several are configured', async () => { + const { url } = await serve('beta,alpha'); + const response = await fetch(`${url}/`, { redirect: 'manual' }); + expect(response.status).toBe(200); + expect(response.headers.get('content-security-policy')).toBe(PORTAL_CSP); + const html = await response.text(); + expect(html).toContain('alpha'); + expect(html.indexOf('alpha')).toBeLessThan(html.indexOf('beta')); + expect(html).not.toMatch(/ { + const { url } = await serve(); + for (const path of ['/portal/nope/', '/portal/nope/app.css', '/portal/nope/portal-data.json']) { + expect([path, (await fetch(url + path)).status]).toEqual([path, 404]); + } + }); +}); diff --git a/packages/server/test/review-fixes.test.ts b/packages/server/test/review-fixes.test.ts new file mode 100644 index 0000000..246e818 --- /dev/null +++ b/packages/server/test/review-fixes.test.ts @@ -0,0 +1,189 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { createServer } from 'node:net'; +import { rm, symlink } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; +import { openDecisions } from '@adrkit/sdk/src/index.ts'; +import { CorpusHolder, type CorpusOpener } from '../src/corpus.ts'; +import { parseAsOf } from '../src/validate.ts'; +import { getJson, removeScratch, scratch, start, writeCorpus, type Running } from './helpers.ts'; + +/** + * Fixes for review findings on PR #314. ADR-0016 clause 2: each test below was observed + * failing against the code before its fix (noted per test); the retained inputs are the + * fixtures here. + */ + +const NAME = 'review-fixes'; +const running: Running[] = []; +const children: Array> = []; +afterEach(async () => { + for (const server of running.splice(0)) await server.close(); + for (const child of children.splice(0)) { + child.kill('SIGTERM'); + await child.exited; + } + await removeScratch(NAME); + await removeScratch(`${NAME}-outside`); + await removeScratch(`${NAME}-bundle`); +}); + +describe('the records directory is confined too', () => { + // Observed failing before the fix: the holder confined only the corpus directory, so a + // `docs/adr` symlink out of the root loaded and served the outside records. + test('a docs/adr symlink out of the root is refused, on first open and on reopen', async () => { + const root = await scratch(NAME); + const outside = await scratch(`${NAME}-outside`); + const dir = await writeCorpus(root, 'demo'); + await writeCorpus(outside, 'elsewhere'); + await rm(join(dir, 'docs/adr'), { recursive: true }); + await symlink(join(outside, 'elsewhere/docs/adr'), join(dir, 'docs/adr')); + const holder = new CorpusHolder({ key: 'demo', dir, adrDir: 'docs/adr' }, root, (args) => openDecisions(args)); + expect(await holder.current()).toEqual({ ok: false, code: 'corpus-escapes-root' }); + + const server = await start({ ADRKIT_SERVER_ROOT: root, ADRKIT_SERVER_CORPORA: 'demo' }); + running.push(server); + const { status, body } = await getJson(`${server.url}/api/v1/corpora/demo/records`); + expect([status, body.error.code]).toEqual([503, 'corpus-escapes-root']); + }); + + test('a records directory swapped for an escaping symlink after a good load is refused on reopen', async () => { + const root = await scratch(NAME); + const outside = await scratch(`${NAME}-outside`); + const dir = await writeCorpus(root, 'demo'); + await writeCorpus(outside, 'elsewhere'); + const holder = new CorpusHolder({ key: 'demo', dir, adrDir: 'docs/adr' }, root, (args) => openDecisions(args)); + expect((await holder.current()).ok).toBe(true); + await rm(join(dir, 'docs/adr'), { recursive: true }); + await symlink(join(outside, 'elsewhere/docs/adr'), join(dir, 'docs/adr')); + await Bun.write(join(dir, '.adrkit-corpus.json'), JSON.stringify({ commit: 'b'.repeat(40) })); + expect(await holder.current()).toEqual({ ok: false, code: 'corpus-escapes-root' }); + }); +}); + +describe('ETag covers the body', () => { + // Observed failing before the fix: the ETag hashed only route, query, and commit, so a + // different body at the same commit (an upgrade) kept the validator and answered 304. + test('a different body at the same commit changes the ETag and never answers 304', async () => { + const root = await scratch(NAME); + await writeCorpus(root, 'demo'); + const env = { ADRKIT_SERVER_ROOT: root, ADRKIT_SERVER_CORPORA: 'demo' }; + const before = await start(env); + running.push(before); + const renamed: CorpusOpener = async (args) => { + const handle = await openDecisions(args); + const records = handle.records.map((record) => ({ ...record, title: `${record.title} (upgraded)` })); + return { ...handle, records, get: handle.get, queue: handle.queue, graph: handle.graph, governing: handle.governing, issues: handle.issues }; + }; + const after = await start(env, { open: renamed }); + running.push(after); + const a = await fetch(`${before.url}/api/v1/corpora/demo/records`); + const b = await fetch(`${after.url}/api/v1/corpora/demo/records`); + expect(a.headers.get('etag')).not.toBe(b.headers.get('etag')); + const stale = await fetch(`${after.url}/api/v1/corpora/demo/records`, { headers: { 'If-None-Match': a.headers.get('etag')! } }); + expect(stale.status).toBe(200); + }); +}); + +describe('If-None-Match', () => { + // Observed failing before the fix: only exact strong tags matched, so W/"…" and * answered 200. + test('weak comparison, *, lists, and a mismatch', async () => { + const root = await scratch(NAME); + await writeCorpus(root, 'demo'); + const server = await start({ ADRKIT_SERVER_ROOT: root, ADRKIT_SERVER_CORPORA: 'demo' }); + running.push(server); + const url = `${server.url}/api/v1/corpora/demo/records`; + const etag = (await fetch(url)).headers.get('etag')!; + const cases: Array<[string, number]> = [ + [etag, 304], + [`W/${etag}`, 304], + ['*', 304], + [`"nope", W/${etag}`, 304], + [`"nope",${etag}`, 304], + ['"nope"', 200], + ['W/"nope", "other"', 200], + ]; + for (const [header, status] of cases) { + for (const method of ['GET', 'HEAD']) { + const response = await fetch(url, { method, headers: { 'If-None-Match': header } }); + expect([header, method, response.status]).toEqual([header, method, status]); + if (status === 304) expect(response.headers.get('etag')).toBe(etag); + } + } + }); + + test('* does not turn an error into a 304', async () => { + const root = await scratch(NAME); + await writeCorpus(root, 'demo'); + const server = await start({ ADRKIT_SERVER_ROOT: root, ADRKIT_SERVER_CORPORA: 'demo' }); + running.push(server); + expect((await fetch(`${server.url}/api/v1/corpora/demo/records/9999`, { headers: { 'If-None-Match': '*' } })).status).toBe(404); + }); +}); + +describe('asOf years 0000 to 0099', () => { + // Observed failing before the fix: Date.UTC maps years 0-99 to 1900-1999, so these were + // refused although DecisionSet.queue accepts them. + test('parseAsOf accepts them exactly as the SDK does', async () => { + for (const value of ['0000-01-01', '0001-02-28', '0050-06-15', '0099-12-31', '0004-02-29']) { + expect([value, parseAsOf(value).ok]).toEqual([value, true]); + } + for (const value of ['0001-02-29', '0099-13-01', '0000-00-10']) expect([value, parseAsOf(value).ok]).toEqual([value, false]); + }); + + test('the queue route serves them', async () => { + const root = await scratch(NAME); + await writeCorpus(root, 'demo'); + const server = await start({ ADRKIT_SERVER_ROOT: root, ADRKIT_SERVER_CORPORA: 'demo' }); + running.push(server); + const { status, body } = await getJson(`${server.url}/api/v1/corpora/demo/queue?asOf=0050-06-15`); + expect([status, body.asOf]).toEqual([200, '0050-06-15']); + }); +}); + +async function ipv6LoopbackAvailable(): Promise { + return new Promise((done) => { + const probe = createServer(); + probe.once('error', () => done(false)); + probe.listen(0, '::1', () => probe.close(() => done(true))); + }); +} + +describe('a bracketed IPv6 host', () => { + // Observed failing before the fix: `[::1]` passed config but Node's `listen` refused it + // (ENOTFOUND), and the binary exited "could not listen". Bun's listen accepts the brackets, + // so this runs the bundled server under Node, as the image does. + test('[::1] binds ::1 under Node and prints the bracketed URL', async () => { + if (!(await ipv6LoopbackAvailable())) { + console.warn('skipped: IPv6 loopback (::1) is not available on this machine'); + return; + } + const root = await scratch(NAME); + await writeCorpus(root, 'demo'); + const out_ = await scratch(`${NAME}-bundle`); + const built = await Bun.build({ entrypoints: [resolve(import.meta.dir, '../src/bin.ts')], target: 'node', conditions: ['bun'], outdir: out_, naming: 'server.js' }); + expect(built.success).toBe(true); + const child = Bun.spawn(['node', join(out_, 'server.js')], { + env: { PATH: process.env.PATH ?? '', ADRKIT_SERVER_ROOT: root, ADRKIT_SERVER_CORPORA: 'demo', ADRKIT_SERVER_PORT: '0', ADRKIT_SERVER_HOST: '[::1]' }, + stdout: 'pipe', + stderr: 'pipe', + }); + children.push(child); + const out = await Promise.race([ + (async () => { + const reader = (child.stdout as ReadableStream).getReader(); + let text = ''; + while (!text.includes('\n')) { + const { value, done } = await reader.read(); + if (done) break; + text += new TextDecoder().decode(value); + } + reader.releaseLock(); + return text; + })(), + child.exited.then(() => ''), + ]); + const match = /^adrkit-server: listening on http:\/\/\[::1\]:(\d+) /.exec(out); + expect(match).not.toBeNull(); + expect((await fetch(`http://[::1]:${match![1]}/healthz`)).status).toBe(200); + }); +}); diff --git a/packages/server/test/routes.test.ts b/packages/server/test/routes.test.ts new file mode 100644 index 0000000..a678abf --- /dev/null +++ b/packages/server/test/routes.test.ts @@ -0,0 +1,241 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { join } from 'node:path'; +import { COMMIT_A, getJson, removeScratch, scratch, start, writeCorpus, writeText, type Running } from './helpers.ts'; +import { GOVERNING_TEXT } from '../src/shapes.ts'; + +const NAME = 'routes'; +const running: Running[] = []; +afterEach(async () => { + for (const server of running.splice(0)) await server.close(); + await removeScratch(NAME); +}); + +async function serve(): Promise { + const root = await scratch(NAME); + await writeCorpus(root, 'demo'); + const server = await start({ ADRKIT_SERVER_ROOT: root, ADRKIT_SERVER_CORPORA: 'demo' }); + running.push(server); + return server; +} + +describe('data routes answer { corpus, commit, data } from one SDK call', () => { + test('listCorpora names each key with its commit', async () => { + const { url } = await serve(); + const { status, body } = await getJson(`${url}/api/v1/corpora`); + expect(status).toBe(200); + expect(body).toEqual({ corpus: null, commit: null, data: [{ corpus: 'demo', commit: COMMIT_A }] }); + }); + + test('listRecords lists every record without its body; getRecord adds the body', async () => { + const { url } = await serve(); + const list = await getJson(`${url}/api/v1/corpora/demo/records`); + expect(list.status).toBe(200); + expect(list.body.corpus).toBe('demo'); + expect(list.body.commit).toBe(COMMIT_A); + expect(list.body.data.map((record: { id: string }) => record.id)).toEqual(['0001', '0002', '0003']); + expect(list.body.data[0]).toEqual({ + id: '0001', + title: 'Use a decision corpus', + status: 'superseded', + standing: 'history', + date: '2026-07-18', + path: 'docs/adr/0001-use-a-decision-corpus.md', + tags: [], + supersedes: [], + supersededBy: '0002', + }); + expect('body' in list.body.data[0]).toBe(false); + + const one = await getJson(`${url}/api/v1/corpora/demo/records/0002`); + expect(one.status).toBe(200); + expect(one.body.data.id).toBe('0002'); + expect(one.body.data.body).toContain('# ADR-0002: Govern the source tree'); + }); + + test('getRecord: a malformed id is 400, an unknown one 404, an unknown corpus 404', async () => { + const { url } = await serve(); + expect((await getJson(`${url}/api/v1/corpora/demo/records/12`)).body.error.code).toBe('invalid-record-id'); + expect((await getJson(`${url}/api/v1/corpora/demo/records/..%2F..%2Fetc`)).status).toBe(400); + expect((await getJson(`${url}/api/v1/corpora/demo/records/9999`)).body.error.code).toBe('record-not-found'); + const unknown = await getJson(`${url}/api/v1/corpora/nope/records`); + expect([unknown.status, unknown.body.error.code]).toEqual([404, 'corpus-not-found']); + expect((await getJson(`${url}/api/v1/corpora/Bad_Key/records`)).status).toBe(404); + }); + + test('getGraph defaults to supersession edges and takes kinds', async () => { + const { url } = await serve(); + const plain = await getJson(`${url}/api/v1/corpora/demo/graph`); + expect(plain.body.data.edges).toEqual([{ from: '0002', to: '0001', kind: 'supersedes' }]); + expect(plain.body.data.nodes.map((node: { id: string }) => node.id)).toEqual(['0001', '0002', '0003']); + const all = await getJson(`${url}/api/v1/corpora/demo/graph?kinds=relatesTo,supersedes`); + expect(all.body.data.edges).toEqual([ + { from: '0002', to: '0001', kind: 'supersedes' }, + { from: '0003', to: '0002', kind: 'relatesTo' }, + ]); + }); + + test('getGraph refuses an empty, unknown, or repeated kinds with fixed text', async () => { + const { url } = await serve(); + for (const query of ['kinds=', 'kinds=supersedes,', 'kinds=bogus', 'kinds=supersedes&kinds=relatesTo']) { + const { status, body } = await getJson(`${url}/api/v1/corpora/demo/graph?${query}`); + expect([query, status]).toEqual([query, 400]); + expect(['invalid-kinds', 'invalid-query']).toContain(body.error.code); + } + }); + + test('getQueue echoes asOf, defaulting to the UTC date of the clock', async () => { + const { url } = await serve(); + const today = await getJson(`${url}/api/v1/corpora/demo/queue`); + expect(today.body.asOf).toBe('2026-10-10'); + expect(today.body.data.entries.map((entry: { id: string }) => entry.id)).toEqual(['0003']); + expect(today.body.data.entries[0].routingTargets).toEqual(['@reviewer']); + const later = await getJson(`${url}/api/v1/corpora/demo/queue?asOf=2026-11-01`); + expect(later.body.asOf).toBe('2026-11-01'); + expect(later.body.data.entries[0].slaState).not.toBe(today.body.data.entries[0].slaState); + }); + + test('getQueue refuses an impossible or non-calendar asOf', async () => { + const { url } = await serve(); + for (const value of ['2026-02-30', '2026-13-01', '2026-10-10T00:00:00Z', 'today', '']) { + const { status, body } = await getJson(`${url}/api/v1/corpora/demo/queue?asOf=${encodeURIComponent(value)}`); + expect([value, status, body.error.code]).toEqual([value, 400, 'invalid-as-of']); + } + }); + + test('getIssues is the corpus findings list', async () => { + const { url } = await serve(); + const { status, body } = await getJson(`${url}/api/v1/corpora/demo/issues`); + expect(status).toBe(200); + expect(Array.isArray(body.data)).toBe(true); + expect(body.data.filter((issue: { severity: string }) => issue.severity === 'error')).toEqual([]); + }); +}); + +describe('getGoverning', () => { + test('resolves affects matches only, and says so in a fixed field', async () => { + const { url } = await serve(); + const { status, body } = await getJson(`${url}/api/v1/corpora/demo/governing?path=src/app.ts`); + expect(status).toBe(200); + expect(body.data.governing.map((decision: { id: string }) => decision.id)).toEqual(['0002']); + expect(body.data.governing[0].matchedBy).toEqual([{ kind: 'affects', type: 'path', pattern: 'src/**', line: null }]); + expect(body.data.evidence).toEqual({ markers: 'not-read', note: GOVERNING_TEXT.evidence }); + expect(body.data.resolution).toEqual({ state: 'resolved', note: null }); + }); + + // ADR-0016 clause 2: observed failing with `{ readMarkers: false }` removed from the + // getGoverning call in src/server.ts — the SDK then read the planted file and reported the + // marker as evidence. The retained input is the planted marker file below. + test('never reads the file at the path, even when one exists with an @adr marker', async () => { + const root = await scratch(NAME); + const dir = await writeCorpus(root, 'demo'); + await writeText(join(dir, 'lib/marked.ts'), '// @adr 0002\nexport const x = 1;\n'); + const server = await start({ ADRKIT_SERVER_ROOT: root, ADRKIT_SERVER_CORPORA: 'demo' }); + running.push(server); + const { body } = await getJson(`${server.url}/api/v1/corpora/demo/governing?path=lib/marked.ts`); + expect(body.data.governing).toEqual([]); + expect(body.data.issues).toEqual([]); + }); + + test('withholds resolution, with the corpus issues, when the corpus has errors', async () => { + const root = await scratch(NAME); + const dir = await writeCorpus(root, 'demo'); + await writeText(join(dir, 'docs/adr/0004-broken.md'), '---\nid: "0004"\n: not yaml\n---\n'); + const server = await start({ ADRKIT_SERVER_ROOT: root, ADRKIT_SERVER_CORPORA: 'demo' }); + running.push(server); + const { status, body } = await getJson(`${server.url}/api/v1/corpora/demo/governing?path=src/app.ts`); + expect(status).toBe(200); + expect(body.data.governing).toEqual([]); + expect(body.data.resolution).toEqual({ state: 'withheld', note: GOVERNING_TEXT.withheld }); + expect(body.data.issues.some((issue: { severity: string }) => issue.severity === 'error')).toBe(true); + }); + + test('refuses a missing, absolute, parent-escaping, option-like, control, or overlong path', async () => { + const { url } = await serve(); + const cases: Array<[string, string]> = [ + ['', 'missing-path'], + ['path=', 'missing-path'], + ['path=/etc/passwd', 'invalid-path'], + ['path=src/../../etc', 'invalid-path'], + ['path=..', 'invalid-path'], + ['path=-rf', 'invalid-path'], + ['path=C:/x', 'invalid-path'], + [`path=${encodeURIComponent('a\u0000b')}`, 'invalid-path'], + [`path=${encodeURIComponent('a\nb')}`, 'invalid-path'], + [`path=${'a'.repeat(1025)}`, 'invalid-path'], + ['path=a&path=b', 'invalid-query'], + ]; + for (const [query, code] of cases) { + const { status, body } = await getJson(`${url}/api/v1/corpora/demo/governing?${query}`); + expect([query.slice(0, 40), status, body.error.code]).toEqual([query.slice(0, 40), 400, code]); + } + }); +}); + +describe('determinism and caching (clause 3)', () => { + test('two servers over the same corpus give byte-identical bodies and ETags', async () => { + const first = await serve(); + const second = await start({ ADRKIT_SERVER_ROOT: first.config.root, ADRKIT_SERVER_CORPORA: 'demo' }); + running.push(second); + for (const path of [ + '/api/v1/corpora', + '/api/v1/corpora/demo/records', + '/api/v1/corpora/demo/records/0002', + '/api/v1/corpora/demo/graph?kinds=conflictsWith,relatesTo', + '/api/v1/corpora/demo/queue', + '/api/v1/corpora/demo/issues', + '/api/v1/corpora/demo/governing?path=src/a.ts', + '/openapi.json', + '/portal/demo/portal-data.json', + ]) { + const a = await fetch(first.url + path); + const b = await fetch(second.url + path); + expect([path, await a.text()]).toEqual([path, await b.text()]); + expect([path, a.headers.get('etag')]).toEqual([path, b.headers.get('etag')]); + } + }); + + test('the ETag follows the canonical query, and If-None-Match answers 304', async () => { + const { url } = await serve(); + const a = await fetch(`${url}/api/v1/corpora/demo/graph?kinds=relatesTo,supersedes,relatesTo`); + const b = await fetch(`${url}/api/v1/corpora/demo/graph?kinds=supersedes,relatesTo&ignored=1`); + const c = await fetch(`${url}/api/v1/corpora/demo/graph`); + expect(a.headers.get('etag')).toBe(b.headers.get('etag')); + expect(a.headers.get('etag')).not.toBe(c.headers.get('etag')); + const cached = await fetch(`${url}/api/v1/corpora/demo/graph`, { headers: { 'If-None-Match': c.headers.get('etag')! } }); + expect(cached.status).toBe(304); + expect(await cached.text()).toBe(''); + }); + + test('responses carry no Date-like or host value in the body', async () => { + const { url } = await serve(); + const text = await (await fetch(`${url}/api/v1/corpora/demo/records`)).text(); + expect(text).not.toContain('127.0.0.1'); + expect(text).not.toMatch(/\d{2}:\d{2}:\d{2}/); + }); +}); + +describe('fixed routes', () => { + test('healthz answers ok', async () => { + const { url } = await serve(); + expect(await getJson(`${url}/healthz`)).toMatchObject({ status: 200, body: { status: 'ok' } }); + }); + + test('an unknown path is a fixed 404', async () => { + const { url } = await serve(); + for (const path of ['/nope', '/api/v1/corpora/', '/api/v1/corpora/demo', '/api/v2/corpora', '/portal/demo']) { + const { status, body } = await getJson(url + path); + expect([path, status, body.error.code]).toEqual([path, 404, 'not-found']); + } + }); + + test('every response carries nosniff, no-referrer, a CSP with frame-ancestors none, and no CORS header', async () => { + const { url } = await serve(); + for (const path of ['/healthz', '/api/v1/corpora/demo/records', '/nope', '/portal/demo/']) { + const response = await fetch(url + path); + expect(response.headers.get('x-content-type-options')).toBe('nosniff'); + expect(response.headers.get('referrer-policy')).toBe('no-referrer'); + expect(response.headers.get('content-security-policy')).toContain("frame-ancestors 'none'"); + expect(response.headers.get('access-control-allow-origin')).toBeNull(); + } + }); +}); diff --git a/packages/server/test/side-effect-denial-preload.mjs b/packages/server/test/side-effect-denial-preload.mjs new file mode 100644 index 0000000..6570b83 --- /dev/null +++ b/packages/server/test/side-effect-denial-preload.mjs @@ -0,0 +1,305 @@ +/** + * @adrkit/server — side-effect-denial preload (ADR-0055 clause 13). + * + * A variant of `packages/mcp/test/side-effect-denial-preload.mjs` that permits exactly one + * listener: the server's one port. A second `listen` is denied, as are outbound connections, + * file system mutation, and child processes. Everything else is the MCP preload unchanged. + * + * Loaded via `node --import ./side-effect-denial-preload.mjs` (or `bun --preload`). + * Patches — through `createRequire` plus `syncBuiltinESMExports()` so ESM imports of a + * builtin observe the patched function, never a stale facade — every enumerated + * filesystem-mutation, write-capable open, returned-FileHandle, child-process, + * cluster, worker, dlopen, and network/listen entry point to fail closed. Read-only + * filesystem APIs are deliberately left intact so the server can still load its corpus. + * + * Passing is BOUNDED executed-path evidence only — it does not prove the absence of raw + * native syscalls or of future, unenumerated runtime APIs. + */ + +import { createRequire } from 'node:module'; +import * as nodeModule from 'node:module'; +import { constants as FS } from 'node:fs'; +import { isAbsolute, relative, resolve, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const require = createRequire(import.meta.url); + +export class SideEffectDenied extends Error { + constructor(api) { + super(`side-effect denied: ${api}`); + this.name = 'SideEffectDenied'; + this.api = api; + } +} + +function syncEsm() { + if (typeof nodeModule.syncBuiltinESMExports === 'function') { + try { + nodeModule.syncBuiltinESMExports(); + } catch { + /* best-effort; a no-op when no ESM facade exists yet */ + } + } +} + +function denyAll(target, names, prefix) { + if (!target) return; + for (const name of names) { + if (typeof target[name] === 'function' || name in target) { + target[name] = function denied() { + throw new SideEffectDenied(`${prefix}.${name}`); + }; + } + } + syncEsm(); +} + +// ---- optional read boundary ------------------------------------------------- +const READ_ROOTS = (() => { + const encoded = process.env.ADRKIT_SERVER_TEST_READ_ROOTS; + if (!encoded) return []; + try { + const roots = JSON.parse(encoded); + return Array.isArray(roots) ? roots.map((root) => resolve(String(root))) : []; + } catch { + throw new Error('ADRKIT_SERVER_TEST_READ_ROOTS must be a JSON array'); + } +})(); + +function pathString(path) { + if (typeof path === 'number') return undefined; + if (path instanceof URL) return fileURLToPath(path); + if (Buffer.isBuffer(path)) return path.toString(); + return String(path); +} + +function isWithin(root, candidate) { + const rel = relative(root, candidate); + return rel === '' || (rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel)); +} + +function assertReadAllowed(api, path) { + if (READ_ROOTS.length === 0) return; + const raw = pathString(path); + if (raw === undefined) return; + const candidate = resolve(raw); + if (!READ_ROOTS.some((root) => isWithin(root, candidate))) { + throw new SideEffectDenied(`${api}:out-of-root`); + } +} + +function guardPathReads(target, names, prefix) { + if (!target || READ_ROOTS.length === 0) return; + for (const name of names) { + const original = target[name]; + if (typeof original !== 'function') continue; + target[name] = function guardedRead(path, ...rest) { + assertReadAllowed(`${prefix}.${name}`, path); + return original.call(this, path, ...rest); + }; + } + syncEsm(); +} + +// ---- write-capable open flag decoding ------------------------------------- +function flagPermitsWrite(flags) { + if (flags === undefined || flags === null) return false; // defaults to 'r' + if (typeof flags === 'string') return /[wa+]/.test(flags); + if (typeof flags === 'number') { + const mask = FS.O_WRONLY | FS.O_RDWR | FS.O_APPEND | FS.O_CREAT | FS.O_TRUNC; + return (flags & mask) !== 0; + } + return true; // unknown flag shape → fail closed +} + +const FILE_HANDLE_MUTATORS = [ + 'write', + 'writev', + 'writeFile', + 'appendFile', + 'truncate', + 'createWriteStream', + 'chmod', + 'chown', + 'utimes', + 'sync', + 'datasync', +]; + +function guardFileHandle(handle) { + for (const name of FILE_HANDLE_MUTATORS) { + if (typeof handle?.[name] === 'function') { + handle[name] = function denied() { + throw new SideEffectDenied(`FileHandle.${name}`); + }; + } + } + return handle; +} + +// ---- filesystem mutation --------------------------------------------------- +const FS_MUTATORS = [ + 'write', 'writeSync', 'writev', 'writevSync', + 'writeFile', 'writeFileSync', 'appendFile', 'appendFileSync', + 'createWriteStream', 'truncate', 'truncateSync', 'ftruncate', 'ftruncateSync', + 'rename', 'renameSync', 'copyFile', 'copyFileSync', 'cp', 'cpSync', + 'unlink', 'unlinkSync', 'rm', 'rmSync', 'rmdir', 'rmdirSync', + 'mkdir', 'mkdirSync', 'mkdtemp', 'mkdtempSync', 'link', 'linkSync', + 'symlink', 'symlinkSync', 'chmod', 'chmodSync', 'fchmod', 'fchmodSync', + 'lchmod', 'lchmodSync', 'chown', 'chownSync', 'fchown', 'fchownSync', + 'lchown', 'lchownSync', 'utimes', 'utimesSync', 'futimes', 'futimesSync', + 'lutimes', 'lutimesSync', +]; + +function patchFs() { + const fs = require('node:fs'); + denyAll(fs, FS_MUTATORS, 'fs'); + guardPathReads( + fs, + [ + 'access', 'accessSync', 'existsSync', 'lstat', 'lstatSync', 'stat', 'statSync', + 'realpath', 'realpathSync', 'readFile', 'readFileSync', 'readdir', 'readdirSync', + 'opendir', 'opendirSync', 'createReadStream', + ], + 'fs', + ); + + // Write-capable open only; a read open is allowed but its handle is not writable. + for (const openName of ['open', 'openSync']) { + const original = fs[openName]; + if (typeof original === 'function') { + fs[openName] = function guardedOpen(path, flags, ...rest) { + if (flagPermitsWrite(flags)) throw new SideEffectDenied(`fs.${openName}`); + assertReadAllowed(`fs.${openName}`, path); + return original.call(this, path, flags, ...rest); + }; + } + } + syncEsm(); + + const fsp = require('node:fs/promises'); + denyAll( + fsp, + [ + 'writeFile', 'appendFile', 'truncate', 'rename', 'copyFile', 'cp', 'unlink', 'rm', + 'rmdir', 'mkdir', 'mkdtemp', 'link', 'symlink', 'chmod', 'lchmod', 'chown', 'lchown', + 'utimes', 'lutimes', + ], + 'fs/promises', + ); + guardPathReads( + fsp, + ['access', 'lstat', 'stat', 'realpath', 'readFile', 'readdir', 'opendir'], + 'fs/promises', + ); + const originalOpen = fsp.open; + if (typeof originalOpen === 'function') { + fsp.open = async function guardedOpen(path, flags, ...rest) { + if (flagPermitsWrite(flags)) throw new SideEffectDenied('fs/promises.open'); + assertReadAllowed('fs/promises.open', path); + return guardFileHandle(await originalOpen.call(this, path, flags, ...rest)); + }; + } + syncEsm(); +} + +// ---- process / runtime escape --------------------------------------------- +function patchProcessEscape() { + const cp = require('node:child_process'); + denyAll( + cp, + ['spawn', 'spawnSync', 'exec', 'execSync', 'execFile', 'execFileSync', 'fork'], + 'child_process', + ); + + const cluster = require('node:cluster'); + denyAll(cluster, ['fork'], 'cluster'); + + const worker = require('node:worker_threads'); + if (worker && typeof worker.Worker === 'function') { + worker.Worker = class DeniedWorker { + constructor() { + throw new SideEffectDenied('worker_threads.Worker'); + } + }; + syncEsm(); + } + + if (typeof process.dlopen === 'function') { + process.dlopen = function denied() { + throw new SideEffectDenied('process.dlopen'); + }; + } +} + +// ---- network / listen ------------------------------------------------------ +function patchNetwork() { + globalThis.fetch = function denied() { + throw new SideEffectDenied('fetch'); + }; + + const net = require('node:net'); + denyAll(net, ['connect', 'createConnection'], 'net'); + + // One listener, and only one. `http.Server` inherits `net.Server`'s listen under Node; a + // re-entrant call from one wrapper into the other counts once. + let listens = 0; + let inListen = false; + function allowOneListen(proto, name) { + if (!proto || typeof proto.listen !== 'function') return; + const original = proto.listen; + proto.listen = function listenOnce(...args) { + if (inListen) return original.apply(this, args); + listens += 1; + if (listens > 1) throw new SideEffectDenied(`${name}:second-listener`); + inListen = true; + try { + return original.apply(this, args); + } finally { + inListen = false; + } + }; + } + allowOneListen(net?.Server?.prototype, 'net.Server.listen'); + + const http = require('node:http'); + denyAll(http, ['request', 'get'], 'http'); + allowOneListen(http?.Server?.prototype, 'http.Server.listen'); + const https = require('node:https'); + denyAll(https, ['request', 'get'], 'https'); + + const dgram = require('node:dgram'); + denyAll(dgram, ['createSocket'], 'dgram'); + syncEsm(); +} + +// ---- Bun companion --------------------------------------------------------- +// NOTE: `Bun.file(...).writer()` and `Bun.file(...).delete()` are deliberately NOT +// patched at runtime: `Bun.file()`/its `writer()` back the runtime's OWN stdout/stderr +// WriteStreams, so wrapping them breaks the very stdio channel this server speaks on. +// Per research §R10 they are covered by import discipline (this package's source never +// imports or references them), which check-deps and the static source audit enforce, +// not by a runtime patch that has no safe injection point. `Bun.write`/`Bun.spawn`/ +// `Bun.spawnSync` DO have safe patch points and are trapped here. +function patchBun() { + if (typeof globalThis.Bun === 'undefined') return; + const bun = globalThis.Bun; + try { + bun.write = function denied() { + throw new SideEffectDenied('Bun.write'); + }; + bun.spawn = function denied() { + throw new SideEffectDenied('Bun.spawn'); + }; + bun.spawnSync = function denied() { + throw new SideEffectDenied('Bun.spawnSync'); + }; + } catch { + /* Bun globals may be read-only in some builds; the Node traps remain authoritative. */ + } +} + +patchFs(); +patchProcessEscape(); +patchNetwork(); +patchBun(); diff --git a/packages/server/test/side-effect-denial.test.ts b/packages/server/test/side-effect-denial.test.ts new file mode 100644 index 0000000..7ddedfa --- /dev/null +++ b/packages/server/test/side-effect-denial.test.ts @@ -0,0 +1,184 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { readdir, readFile, stat, writeFile } from 'node:fs/promises'; +import { join, relative, resolve } from 'node:path'; +import { COMMIT_A, removeScratch, scratch, writeCorpus } from './helpers.ts'; + +/** + * ADR-0055 clause 13: one way in, no way out, nothing writable. The server runs under a + * variant of the MCP side-effect-denial preload that permits one listener and denies file + * system mutation, child processes, and outbound connections, and every route still answers. + * + * Passing is bounded executed-path evidence, as for the MCP preload: it does not prove the + * absence of raw native syscalls or of runtime APIs the preload does not enumerate. + */ + +const HERE = import.meta.dir; +const PRELOAD = join(HERE, 'side-effect-denial-preload.mjs'); +const BIN_SRC = resolve(HERE, '../src/bin.ts'); +const NAME = 'side-effect-denial'; + +const children: Array> = []; +afterEach(async () => { + for (const child of children.splice(0)) { + child.kill('SIGKILL'); + await child.exited; + } + await removeScratch(NAME); +}); + +const DRIVER = ` +import http from 'node:http'; +import net from 'node:net'; +import { writeFile, mkdir } from 'node:fs/promises'; +import { spawn, execSync } from 'node:child_process'; + +const results = {}; +async function check(name, fn) { + try { await fn(); results[name] = 'allowed'; } + catch (e) { results[name] = e && e.name === 'SideEffectDenied' ? 'denied' : ('other:' + (e && e.name)); } +} +const first = http.createServer(() => {}); +await check('first.listen', () => new Promise((done, fail) => { first.once('error', fail); first.listen(0, '127.0.0.1', done); })); +await check('second.listen', () => new Promise((done, fail) => { const s = http.createServer(() => {}); s.once('error', fail); s.listen(0, '127.0.0.1', done); })); +await check('net.second.listen', () => new Promise((done, fail) => { const s = net.createServer(); s.once('error', fail); s.listen(0, '127.0.0.1', done); })); +await check('net.connect', () => net.connect(1, '127.0.0.1')); +await check('http.request', () => http.request('http://127.0.0.1:1/')); +await check('fetch', () => fetch('http://127.0.0.1:1/')); +await check('writeFile', () => writeFile(process.argv[2] + '/x', 'y')); +await check('mkdir', () => mkdir(process.argv[2] + '/d')); +await check('spawn', () => spawn('true')); +await check('execSync', () => execSync('true')); +first.close(); +process.stdout.write(JSON.stringify(results)); +`; + +const EXPECTED_DRIVER = { + 'first.listen': 'allowed', + 'second.listen': 'denied', + 'net.second.listen': 'denied', + 'net.connect': 'denied', + 'http.request': 'denied', + fetch: 'denied', + writeFile: 'denied', + mkdir: 'denied', + spawn: 'denied', + execSync: 'denied', +}; + +async function snapshot(root: string): Promise { + const out: string[] = []; + for (const entry of await readdir(root, { recursive: true, withFileTypes: true })) { + const path = join(entry.parentPath, entry.name); + const info = await stat(path); + out.push(`${relative(root, path)} ${info.size} ${info.mtimeMs} ${entry.isFile() ? (await readFile(path, 'utf8')).length : ''}`); + } + return out.sort(); +} + +async function waitForListening(child: ReturnType): Promise { + const reader = (child.stdout as ReadableStream).getReader(); + let text = ''; + while (!text.includes('\n')) { + const { value, done } = await reader.read(); + if (done) break; + text += new TextDecoder().decode(value); + } + reader.releaseLock(); + const match = /listening on http:\/\/127\.0\.0\.1:(\d+)/.exec(text); + if (!match) throw new Error(`server did not start: ${text}${await new Response(child.stderr as ReadableStream).text()}`); + return Number(match[1]); +} + +/** Every route a client can call, with the status it must answer under the trap. */ +const CALLS: Array<[string, number]> = [ + ['/healthz', 200], + ['/openapi.json', 200], + ['/api/v1/corpora', 200], + ['/api/v1/corpora/demo/records', 200], + ['/api/v1/corpora/demo/records/0002', 200], + ['/api/v1/corpora/demo/graph?kinds=supersedes,relatesTo,conflictsWith', 200], + ['/api/v1/corpora/demo/queue', 200], + ['/api/v1/corpora/demo/issues', 200], + ['/api/v1/corpora/demo/governing?path=src/a.ts', 200], + ['/', 302], + ['/portal/demo/', 200], + ['/portal/demo/portal-data.json', 200], + ['/portal/demo/app.css', 200], + ['/portal/demo/page/app.mjs', 200], +]; + +async function exercise(command: string[], root: string, readRoots: string[]): Promise { + const before = await snapshot(root); + const child = Bun.spawn(command, { + stdout: 'pipe', + stderr: 'pipe', + env: { + PATH: process.env.PATH ?? '', + BUN_RUNTIME_TRANSPILER_CACHE_PATH: '0', + ADRKIT_SERVER_ROOT: root, + ADRKIT_SERVER_CORPORA: 'demo', + ADRKIT_SERVER_PORT: '0', + ADRKIT_SERVER_TEST_READ_ROOTS: JSON.stringify(readRoots), + }, + }); + children.push(child); + const port = await waitForListening(child); + for (const [path, status] of CALLS) { + const response = await fetch(`http://127.0.0.1:${port}${path}`, { redirect: 'manual' }); + const body = await response.text(); + expect([path, response.status]).toEqual([path, status]); + if (path === '/api/v1/corpora') expect(JSON.parse(body).data).toEqual([{ corpus: 'demo', commit: COMMIT_A }]); + } + const post = await fetch(`http://127.0.0.1:${port}/api/v1/corpora/demo/records`, { method: 'POST', body: '{}' }); + expect(post.status).toBe(405); + expect(await snapshot(root)).toEqual(before); +} + +describe('the one-listener preload fails closed on everything else', () => { + test('under Bun', async () => { + const dir = await scratch(NAME); + const driver = join(dir, 'driver.mjs'); + await writeFile(driver, DRIVER, 'utf8'); + const proc = Bun.spawn([process.execPath, '--preload', PRELOAD, driver, dir], { stdout: 'pipe', stderr: 'pipe' }); + const out = await new Response(proc.stdout).text(); + expect(await proc.exited).toBe(0); + expect(JSON.parse(out)).toEqual(EXPECTED_DRIVER); + }); + + test('under Node', async () => { + const dir = await scratch(NAME); + const driver = join(dir, 'driver.mjs'); + await writeFile(driver, DRIVER, 'utf8'); + const proc = Bun.spawn(['node', '--import', PRELOAD, driver, dir], { stdout: 'pipe', stderr: 'pipe' }); + const out = await new Response(proc.stdout).text(); + expect(await proc.exited).toBe(0); + expect(JSON.parse(out)).toEqual(EXPECTED_DRIVER); + }); +}); + +describe('every route answers under the trap, and nothing on disk changes', () => { + // ADR-0016 clause 2: observed failing with + // `(await import('node:child_process')).execSync('true');` added to the getHealth handler + // in src/server.ts: the trap denied it, the handler answered 500, and `/healthz` failed + // its expected 200 under both runtimes. The retained input is the route list above. + test('the source server under Bun', async () => { + const root = await scratch(NAME); + await writeCorpus(root, 'demo'); + await exercise([process.execPath, '--preload', PRELOAD, BIN_SRC], root, [root]); + }); + + test('the bundled server, as the image runs it, under Node', async () => { + const root = await scratch(NAME); + await writeCorpus(root, 'demo'); + const out = await scratch(`${NAME}-bundle`); + try { + const built = await Bun.build({ entrypoints: [BIN_SRC], target: 'node', conditions: ['bun'], outdir: out, naming: 'server.js' }); + expect(built.success).toBe(true); + // Node's ESM loader reads the entry module through the public fs API, so the bundle's + // directory is a read root too. + await exercise(['node', '--import', PRELOAD, join(out, 'server.js')], root, [root, out]); + } finally { + await removeScratch(`${NAME}-bundle`); + } + }); +}); diff --git a/packages/server/test/sidecar.test.ts b/packages/server/test/sidecar.test.ts new file mode 100644 index 0000000..6580a9a --- /dev/null +++ b/packages/server/test/sidecar.test.ts @@ -0,0 +1,195 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { rename, rm, symlink } from 'node:fs/promises'; +import { join } from 'node:path'; +import { openDecisions } from '@adrkit/sdk/src/index.ts'; +import { CorpusHolder, parseSidecar, type CorpusOpener } from '../src/corpus.ts'; +import { ERRORS } from '../src/messages.ts'; +import { COMMIT_A, COMMIT_B, getJson, removeScratch, scratch, start, writeCorpus, writeSidecar, writeText, type Running } from './helpers.ts'; + +const NAME = 'sidecar'; +const running: Running[] = []; +afterEach(async () => { + for (const server of running.splice(0)) await server.close(); + await removeScratch(NAME); +}); + +describe('parseSidecar (the ADR-0055 clause 4 / ADR-0056 clause 4 contract)', () => { + test('accepts a JSON object whose commit is 40 lowercase hex, extra fields included', () => { + expect(parseSidecar(JSON.stringify({ commit: COMMIT_A }))).toEqual({ state: 'valid', commit: COMMIT_A }); + expect(parseSidecar(JSON.stringify({ commit: COMMIT_A, syncedAt: 'later', ref: 'main' }))).toEqual({ state: 'valid', commit: COMMIT_A }); + }); + + test('refuses anything else', () => { + for (const text of [ + '', + 'not json', + 'null', + '[]', + JSON.stringify(COMMIT_A), + JSON.stringify({}), + JSON.stringify({ commit: COMMIT_A.toUpperCase() }), + JSON.stringify({ commit: COMMIT_A.slice(1) }), + JSON.stringify({ commit: 'c'.repeat(64) }), + JSON.stringify({ commit: ` ${COMMIT_A}` }), + JSON.stringify({ commit: 1 }), + ]) { + expect([text, parseSidecar(text).state]).toEqual([text, 'invalid']); + } + }); +}); + +describe('a corpus without a valid sidecar is not served (clause 4)', () => { + test('missing sidecar: every corpus route is a fixed 503', async () => { + const root = await scratch(NAME); + await writeCorpus(root, 'demo', null); + const server = await start({ ADRKIT_SERVER_ROOT: root, ADRKIT_SERVER_CORPORA: 'demo' }); + running.push(server); + for (const path of ['records', 'records/0001', 'graph', 'queue', 'issues', 'governing?path=src/a.ts']) { + const { status, body } = await getJson(`${server.url}/api/v1/corpora/demo/${path}`); + expect([path, status, body]).toEqual([path, 503, { error: { code: 'sidecar-missing', message: ERRORS['sidecar-missing'].message } }]); + } + expect((await getJson(`${server.url}/portal/demo/portal-data.json`)).status).toBe(503); + expect((await getJson(`${server.url}/api/v1/corpora`)).body.data).toEqual([{ corpus: 'demo', commit: null }]); + }); + + test('an invalid sidecar is a 503 too, never an answer without a commit', async () => { + const root = await scratch(NAME); + const dir = await writeCorpus(root, 'demo', null); + await writeText(join(dir, '.adrkit-corpus.json'), '{"commit":"HEAD"}'); + const server = await start({ ADRKIT_SERVER_ROOT: root, ADRKIT_SERVER_CORPORA: 'demo' }); + running.push(server); + const { status, body } = await getJson(`${server.url}/api/v1/corpora/demo/records`); + expect([status, body.error.code]).toEqual([503, 'sidecar-invalid']); + }); + + test('a directory the populator has not written yet is 503 until it appears', async () => { + const root = await scratch(NAME); + const server = await start({ ADRKIT_SERVER_ROOT: root, ADRKIT_SERVER_CORPORA: 'later' }); + running.push(server); + expect((await getJson(`${server.url}/api/v1/corpora/later/records`)).status).toBe(503); + await writeCorpus(root, 'later'); + expect((await getJson(`${server.url}/api/v1/corpora/later/records`)).body.commit).toBe(COMMIT_A); + }); +}); + +describe('a new sync becomes visible by swap (clause 5)', () => { + // ADR-0016 clause 2: observed failing with the commit comparison in + // CorpusHolder.current() replaced by `if (this.#loaded) return …` (the first handle kept + // forever): the second request answered COMMIT_A with two records. + test('a changed sidecar commit opens a fresh handle; the same commit never reopens', async () => { + const root = await scratch(NAME); + const dir = await writeCorpus(root, 'demo'); + let opens = 0; + const open: CorpusOpener = (args) => { + opens++; + return openDecisions(args); + }; + const server = await start({ ADRKIT_SERVER_ROOT: root, ADRKIT_SERVER_CORPORA: 'demo' }, { open }); + running.push(server); + const first = await getJson(`${server.url}/api/v1/corpora/demo/records`); + await getJson(`${server.url}/api/v1/corpora/demo/issues`); + expect([first.body.commit, first.body.data.length, opens]).toEqual([COMMIT_A, 3, 1]); + + await rm(join(dir, 'docs/adr/0003-review-a-proposal.md')); + // The populator writes the sidecar last; until then the old commit (and handle) stands. + expect((await getJson(`${server.url}/api/v1/corpora/demo/records`)).body.data.length).toBe(3); + await writeSidecar(dir, COMMIT_B); + const second = await getJson(`${server.url}/api/v1/corpora/demo/records`); + expect([second.body.commit, second.body.data.length, opens]).toEqual([COMMIT_B, 2, 2]); + }); + + test('a load during which the commit moved is not kept; the old handle keeps serving', async () => { + const root = await scratch(NAME); + const dir = await writeCorpus(root, 'demo'); + let moveDuringLoad = false; + const open: CorpusOpener = async (args) => { + const handle = await openDecisions(args); + if (moveDuringLoad) { + moveDuringLoad = false; + await writeSidecar(dir, 'c'.repeat(40)); + } + return handle; + }; + const holder = new CorpusHolder({ key: 'demo', dir, adrDir: 'docs/adr' }, root, open); + const first = await holder.current(); + expect(first.ok && first.corpus.commit).toBe(COMMIT_A); + + await writeSidecar(dir, COMMIT_B); + moveDuringLoad = true; + const during = await holder.current(); + expect(during.ok && during.corpus.commit).toBe(COMMIT_A); + const after = await holder.current(); + expect(after.ok && after.corpus.commit).toBe('c'.repeat(40)); + }); + + test('a first load during which the commit moved is a 503, not an answer at either commit', async () => { + const root = await scratch(NAME); + const dir = await writeCorpus(root, 'demo'); + const open: CorpusOpener = async (args) => { + const handle = await openDecisions(args); + await writeSidecar(dir, COMMIT_B); + return handle; + }; + const holder = new CorpusHolder({ key: 'demo', dir, adrDir: 'docs/adr' }, root, open); + expect(await holder.current()).toEqual({ ok: false, code: 'corpus-changing' }); + }); + + test('concurrent requests at a new commit share one load', async () => { + const root = await scratch(NAME); + const dir = await writeCorpus(root, 'demo'); + let opens = 0; + const holder = new CorpusHolder({ key: 'demo', dir, adrDir: 'docs/adr' }, root, (args) => { + opens++; + return openDecisions(args); + }); + const states = await Promise.all([holder.current(), holder.current(), holder.current()]); + expect(states.every((state) => state.ok)).toBe(true); + expect(opens).toBe(1); + }); + + test('the two-move window (directory briefly absent) keeps serving the last handle', async () => { + const root = await scratch(NAME); + const dir = await writeCorpus(root, 'demo'); + const holder = new CorpusHolder({ key: 'demo', dir, adrDir: 'docs/adr' }, root, (args) => openDecisions(args)); + expect((await holder.current()).ok).toBe(true); + await rename(dir, join(root, 'demo.old')); + const during = await holder.current(); + expect(during.ok && during.corpus.commit).toBe(COMMIT_A); + }); + + test('a failed load keeps the old handle, and a failed first load is a 503', async () => { + const root = await scratch(NAME); + const dir = await writeCorpus(root, 'demo'); + let fail = false; + const holder = new CorpusHolder({ key: 'demo', dir, adrDir: 'docs/adr' }, root, async (args) => { + if (fail) throw new Error('boom'); + return openDecisions(args); + }); + expect((await holder.current()).ok).toBe(true); + fail = true; + await writeSidecar(dir, COMMIT_B); + const kept = await holder.current(); + expect(kept.ok && kept.corpus.commit).toBe(COMMIT_A); + + const fresh = new CorpusHolder({ key: 'demo', dir, adrDir: 'docs/adr' }, root, async () => { + throw new Error('boom'); + }); + expect(await fresh.current()).toEqual({ ok: false, code: 'corpus-unreadable' }); + }); + + test('a directory replaced by a symlink out of the root is refused, old handle or not', async () => { + const root = await scratch(NAME); + const outside = await scratch(`${NAME}-outside`); + try { + const dir = await writeCorpus(root, 'demo'); + await writeCorpus(outside, 'elsewhere'); + const holder = new CorpusHolder({ key: 'demo', dir, adrDir: 'docs/adr' }, root, (args) => openDecisions(args)); + expect((await holder.current()).ok).toBe(true); + await rm(dir, { recursive: true }); + await symlink(join(outside, 'elsewhere'), dir); + expect(await holder.current()).toEqual({ ok: false, code: 'corpus-escapes-root' }); + } finally { + await removeScratch(`${NAME}-outside`); + } + }); +}); diff --git a/packages/server/test/writing-verbs.test.ts b/packages/server/test/writing-verbs.test.ts new file mode 100644 index 0000000..4696426 --- /dev/null +++ b/packages/server/test/writing-verbs.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, test } from 'bun:test'; +import { readdirSync, readFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +/** + * ADR-0055 clause 2, second half: the server stays read-only (ADR-0044, ADR-0051, ADR-0052). + * The patterns are `packages/mcp/test/writing-verbs.test.ts`'s, applied to + * `packages/server/src`: neither a writing transition nor a CLI verb that records a person's + * act (`accept`, `approve`, `object`, `resolve`) may appear in its source. + * + * ADR-0016 clause 2: observed failing with `[].push('approve');` added to the getHealth + * handler in src/server.ts. The retained input is that form, among the plants below. + */ +const SRC = resolve(dirname(fileURLToPath(import.meta.url)), '../src'); +const VERBS = ['accept', 'approve', 'object', 'resolve'] as const; +const TRANSITIONS = /\b(?:acceptAdrSource|approveAdrSource|objectAdrSource|resolveObjectionAdrSource)\b/; + +function mentionsVerb(text: string): boolean { + const alt = VERBS.join('|'); + const type = `['"\`](?:null|string|array|number|boolean|integer|object)['"\`]`; + const code = text.replace(new RegExp(`\\[\\s*${type}(?:\\s*,\\s*${type})*(?:\\s*,)?\\s*\\]`, 'g'), ''); + return [ + new RegExp(`[\\[,(]\\s*['"\`](?:${alt})['"\`]\\s*[,\\])]`), + new RegExp(`(?])=\\s*['"\`](?:${alt})['"\`]`), + new RegExp(`\\badr_(?:${alt})\\b`, 'i'), + new RegExp(`(?:\\badr|ADRKIT_CLI\\}?"?|@adrkit\\/cli|\\$\\{[^}]*\\})[\\s\\p{Cf}]+(?:${alt})\\b`, 'iu'), + ].some((pattern) => pattern.test(code)); +} + +function sources(): string[] { + return readdirSync(SRC, { recursive: true, encoding: 'utf8' }) + .filter((path) => /\.[cm]?[jt]s$/.test(path)) + .map((path) => join(SRC, path)) + .sort(); +} + +describe('writing verbs', () => { + test('no source file imports a writing transition or names a ratifying or review verb', () => { + const files = sources(); + expect(files.some((path) => path.endsWith('server.ts'))).toBe(true); + const hits = files.filter((path) => { + const text = readFileSync(path, 'utf8'); + return TRANSITIONS.test(text) || mentionsVerb(text); + }); + expect(hits).toEqual([]); + }); + + test('no source file imports @adrkit/core or @adrkit/cli, or spawns a process', () => { + const hits = sources().filter((path) => + /from\s+['"]@adrkit\/(?:core|cli)|node:child_process|['"]child_process['"]|(? { + for (const plant of [ + "import { approveAdrSource } from '@adrkit/core';", + "import { acceptAdrSource } from '@adrkit/core';", + "args.push('approve')", + "spawn(cli, ['object', id])", + 'execFile(`${cli} resolve`)', + ]) { + expect({ plant, caught: TRANSITIONS.test(plant) || mentionsVerb(plant) }).toEqual({ plant, caught: true }); + } + for (const idiom of ["schema: { type: 'object' }", 'await Promise.resolve()', "const root = resolve(dir, '.')"]) { + expect({ idiom, caught: TRANSITIONS.test(idiom) || mentionsVerb(idiom) }).toEqual({ idiom, caught: false }); + } + }); +}); diff --git a/scripts/check-deps.test.ts b/scripts/check-deps.test.ts index e306e4b..bf3dd80 100644 --- a/scripts/check-deps.test.ts +++ b/scripts/check-deps.test.ts @@ -576,6 +576,68 @@ describe('portal dependency boundary (ADR-0054)', () => { }); }); +describe('server dependency boundary (ADR-0055)', () => { + const SERVER_MANIFEST = 'packages/server/package.json'; + + function serverManifest(extra: Record = {}): string { + return JSON.stringify( + { + name: '@adrkit/server', + version: '0.0.0', + private: true, + dependencies: { '@adrkit/portal': 'workspace:*', '@adrkit/sdk': 'workspace:*', ...extra }, + devDependencies: { '@types/bun': 'latest' }, + }, + null, + 2, + ); + } + + test('allows exactly the workspace sdk and portal, plus @types/bun', async () => { + const root = await resetTestDir(DIR_NAME); + await writeText(join(root, SERVER_MANIFEST), serverManifest()); + await expect(checkDependencyRules(root)).resolves.toEqual({ ok: true, violations: [] }); + }); + + // ADR-0055 clause 10: the SDK is the door. A direct core edge would make the server a + // second caller of core, and is the only proof the entry exists: without it the package + // is silently unconstrained. + // + // ADR-0016 clause 2: observed failing before the entry was added. With the entry absent, + // this test returned `{ ok: true, violations: [] }`; the retained input is the manifest + // below, unchanged. + test('proves the server allowlist entry exists, by rejecting a direct @adrkit/core dependency', async () => { + const root = await resetTestDir(DIR_NAME); + await writeText(join(root, SERVER_MANIFEST), serverManifest({ '@adrkit/core': 'workspace:*' })); + + const result = await checkDependencyRules(root); + expect(result.ok).toBe(false); + expect(result.violations.map((violation) => `${violation.dependency}: ${violation.reason}`)).toEqual([ + '@adrkit/core: @adrkit/server declares a dependency outside its allowed public surface', + ]); + }); + + // ADR-0055 fact 4 and Option D: no HTTP framework. + test('rejects an HTTP framework', async () => { + for (const framework of ['hono', '@hono/node-server', 'express', 'fastify']) { + const root = await resetTestDir(DIR_NAME); + await writeText(join(root, SERVER_MANIFEST), serverManifest({ [framework]: '^1' })); + const result = await checkDependencyRules(root); + expect(result.violations.map((violation) => violation.dependency)).toEqual([framework]); + } + }); + + test('rejects the cli and the stdio MCP server', async () => { + const root = await resetTestDir(DIR_NAME); + await writeText( + join(root, SERVER_MANIFEST), + serverManifest({ '@adrkit/cli': 'workspace:*', '@adrkit/mcp': 'workspace:*' }), + ); + const result = await checkDependencyRules(root); + expect(result.violations.map((violation) => violation.dependency).sort()).toEqual(['@adrkit/cli', '@adrkit/mcp']); + }); +}); + describe('ci surface admits the portal generator (ADR-0054 item 4)', () => { const CI_MANIFEST = 'packages/ci/package.json'; diff --git a/scripts/check-deps.ts b/scripts/check-deps.ts index 821aef6..c1a331b 100644 --- a/scripts/check-deps.ts +++ b/scripts/check-deps.ts @@ -224,6 +224,21 @@ function allowedDependenciesFor(packageName: string): Record { expect(CI_WORKFLOW).toContain('scripts/smoke-container.mjs adrkit:ci mcp'); expect(CI_WORKFLOW).toContain('adrkit:ci ci 2>&1'); expect(CI_WORKFLOW).toContain('adrkit:ci queue-action 2>&1'); + expect(CI_WORKFLOW).toContain('adrkit:ci server'); + }); + + // ADR-0055 clauses 12 to 14. The server is network-facing, so its documented run keeps + // everything of the stdio MCP posture but `--network none`: a read-only root file system, + // `:ro` mounts, and a loopback default that only an explicit ADRKIT_SERVER_HOST overrides. + // ADR-0016 clause 2: observed failing with the `COPY packages/server/src` line removed from + // the Containerfile, and (the selector test above) with the entrypoint's + // `server | adrkit-server)` case renamed; the retained inputs are both lines, restored. + test('builds and documents the server selector with its read-only posture', () => { + expect(CONTAINERFILE).toContain('COPY packages/sdk/src ./packages/sdk/src'); + expect(CONTAINERFILE).toContain('COPY packages/portal/src ./packages/portal/src'); + expect(CONTAINERFILE).toContain('COPY packages/server/src ./packages/server/src'); + expect(CONTAINERFILE).toContain('bun build packages/server/src/bin.ts'); + const allInOne = CONTAINERFILE.split('FROM runtime AS adrkit\n')[1] ?? ''; + expect(allInOne).toContain('/out/server.js /opt/adrkit/server.js'); + + const help = Bun.spawnSync(['sh', ENTRYPOINT_PATH, '--help']).stdout.toString(); + expect(help).toContain('server | adrkit-server'); + expect(help).toContain('authenticates no one'); + + const section = README.split('### The read-only REST server')[1]?.split('\n## ')[0] ?? ''; + expect(section.trimStart()).toStartWith('**This server authenticates no one.**'); + expect(section).toContain('ADRKIT_SERVER_CORPORA'); + expect(section).toContain('--read-only'); + expect(section).toMatch(/:ro\b/); + const run = section.split('```sh')[1]?.split('```')[0] ?? ''; + expect(run).toContain('adrkit:vX.Y.Z server'); + expect(run).not.toContain('--network none'); + expect(run).not.toMatch(/-p (?!127\.0\.0\.1:)/); + + for (const line of ['!packages/sdk/src/**', '!packages/portal/src/**', '!packages/server/src/**']) { + expect(dockerignoreLines().has(line)).toBe(true); + } + expect(CI_WORKFLOW).toContain('docker build -f Containerfile --target server -t adrkit-server:ci .'); }); test('admits only runtime source and the committed Action bundles', () => { diff --git a/scripts/container-entrypoint.sh b/scripts/container-entrypoint.sh index e943efc..28c779f 100644 --- a/scripts/container-entrypoint.sh +++ b/scripts/container-entrypoint.sh @@ -10,6 +10,8 @@ usage: adrkit-container [selector] [args...] selectors: cli | adr | adrkit Run the adr CLI mcp | adrkit-mcp Run the stdio MCP server + server | adrkit-server Run the read-only REST server and portal + (authenticates no one; see `server --help`) ci | adrkit-ci Run the governing-decisions GitHub Action queue-action | adrkit-queue-action Run the managed ARB queue GitHub Action @@ -40,6 +42,10 @@ case "$command" in shift exec node "$ADRKIT_HOME/adrkit-mcp.js" "$@" ;; + server | adrkit-server) + shift + exec node "$ADRKIT_HOME/server.js" "$@" + ;; ci | adrkit-ci) shift exec node "$ADRKIT_HOME/ci.js" "$@"