Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 0 additions & 4 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,3 @@ vite.config.js
tsconfig.json
package-lock.json
apps.json

# The MCP search index is runtime data for the MCP server, not public static
# content: keep it out of the nginx web root.
dist/_mcp
9 changes: 8 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ One paragraph on what changed and why.
## Verification

- `npm run build:headless` — green
- `npm run lint` — green
- `npm test` — green
- `npx playwright test --config=playwright.config.ci.js` — green

Expand All @@ -76,14 +77,20 @@ characters and explain *why* in the body when it is not obvious from the diff.

## Before opening a PR

All three must pass locally — CI runs the same three:
All four must pass locally — CI runs the same four (`make help` has them as targets):

```bash
npm run build:headless # hermetic build
npm run lint # MCP server: tsc, ESLint, Prettier
npm test # embedded web-fragment E2E
npx playwright test --config=playwright.config.ci.js # standalone fragment layer
```

While working on `mcp/`, `npm run test:mcp` runs only the MCP suites, over the
`dist/` the build left behind, in seconds. A deliberate change to what the
server answers regenerates the goldens: `UPDATE_GOLDEN=1 npm run test:mcp`;
review the diff in `tests/fixtures/mcp/golden/` like code.

The build and both suites are hermetic: they use the committed
`tests/fixtures/` artifacts via `apps.json`, so no `GITHUB_TOKEN`, no network and
no sibling repository is required. If a change makes any of them need network,
Expand Down
39 changes: 37 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ Build-time aggregator that downloads pre-built static doc sites from GitHub Rele

## Commands

`make help` lists the same tasks as make targets (`make lint`, `make test-mcp`, `make prod`, …); each
delegates to an npm script, so everything below also works without make.

```bash
# Install
npm install # Node >= 24 required
Expand All @@ -25,10 +28,19 @@ npm run dev:fetch # fetch artifacts then start dev
# Preview
npm run preview # serve the built dist/ (astro preview, :4321)

# MCP server (from source — Node strips the types; needs a build's dist/_mcp/search-index.json)
npm run mcp:dev # HTTP on :8081, restarts on change
npm run mcp:stdio # stdio

# Lint (mcp/ only: tsc --noEmit, ESLint strictTypeChecked, Prettier)
npm run lint
npm run format

# Test (Playwright E2E — self-contained; auto-starts its own servers)
npm test # embedded web-fragment harness
npx playwright test --config=playwright.config.ci.js # standalone fragment server
npm run test:container # real nginx image (needs Docker)
npm run test:mcp # MCP suites only, over an existing dist/ (fast loop)
npm run test:ui # Playwright UI
npm run test:headed # visible browser
npm run test:debug # debug mode
Expand All @@ -49,6 +61,7 @@ apps.json (registry)
→ [3] astro build: [...path].astro enumerates all HTML via getStaticPaths
→ transformSubAppHtml() rewrites URLs + splits the document,
which Base.astro then re-hosts (masthead + head/body)
→ [3b] buildMcpArtifacts() extracts plain-text search index + bundles MCP server → dist/_mcp/
→ dist/
```

Expand Down Expand Up @@ -80,7 +93,12 @@ Orchestrator: `scripts/build-vite.js`. Flags: `--local`, `--headless`.
- `src/scripts/embedded-transitions.js` — Loaded by the layout; inert standalone. Inside a web fragment it runs Astro's view transition on the host document (the iframe's is never painted) and replaces Astro's swap with one that targets reframed's `wf-html`/`wf-head`/`wf-body`, because the default swap nests a new `wf-html` per navigation and leaks every stylesheet. Its head diff never moves a reused node: on a pierced page a `<link>` already moved once by reframed's portal falls out of the applied stylesheets when moved again. It also imports the fetched head and body into the host document before swapping them in: reframed routes a script into its iframe only through host-realm DOM methods, and the router's page is parsed in the iframe, so Astro's `script.replaceWith()` re-run would otherwise execute every swapped-in sub-app script in the host window
- `src/utils/config.js` — `PATH_PREFIX`/`BASE_PATH`, `isHeadlessBuild()` and `REGISTRY_FILE` — the build-wide constants
- `src/utils/registry.js` — Registry validation, manifest reading/validation, expansion map. Shared by the build and by Astro so both resolve the same registry
- `scripts/build-vite.js` — Build orchestrator (4 steps: prepare, hoist, copy assets, astro build)
- `scripts/build-vite.js` — Build orchestrator; step 3b generates MCP search index and self-contained runtime bundle
- `mcp/` — the MCP server, in TypeScript that Node runs as-is (`mcp/tsconfig.json` allows only erasable syntax; relative imports spell `.ts`). Linted on its own (`eslint.config.js`). Three parts, kept apart by an ESLint import boundary:
- `mcp/search-index/schema.ts` — the search index file format and `validateSearchIndex()`, the one thing build and runtime share
- `mcp/build/` — build time only: `extract.ts` (untrusted HTML → bounded markdown-like text, parse5), `index-builder.ts`, `bundle.ts` (the esbuild config: minified, English-only zod locales, the SDK's `_shims` aliased to `src/sdk-shims.ts` to drop Ajv)
- `mcp/src/` — the runtime. `ports.ts` declares `DocumentRepository` and `SearchBackend` (async, adapter-owned opaque cursors); `knowledge-base.ts` is the facade the protocol layer talks to (public shapes, links, bounded reads); `adapters/memory/` implements both ports over the search index (lexical search: stemmed term frequency, title boost, compound split, fuzzy match); `compose.ts` wires adapters to the facade; `protocol/` registers one tool per file plus `kb://` resources on the official SDK v2 `McpServer`; `transport/` is the stateless HTTP handler (Origin, body cap, per-client and global rate limits, in-flight cap, timeout) and listener; `config.ts` is the only reader of argv/env. A new backend (OpenSearch) is a new `adapters/<name>/` pair plus a branch in `compose.ts`, and must pass `tests/support/backend-contract.js`
- `scripts/build-mcp.js` — Emits `dist/_mcp/search-index.json` and bundled `server.mjs`
- `scripts/fetch-apps.js` — GitHub Release artifact downloader. Only *obtains* an artifact; installing it is one shared path in `build-vite.js`
- `scripts/artifacts.js` — Safe tarball extraction + tree copy, shared by both fetch paths. Validates archive members (no traversal, no absolute paths, no symlinks) before anything is written, and replaces the old `cp -r`/`tar` shell-outs so the build runs on Windows
- `scripts/check-artifact.js` — Runs the publish action's contract checker (`actions/lib/check.js`) on every installed artifact, before hoisting; logs findings grouped by rule, and fails a strict build on an error. `check.js` resolves its parsers from `actions/node_modules` or the root, which pins the same versions
Expand Down Expand Up @@ -163,7 +181,9 @@ Self-contained Playwright E2E — `npm test` auto-starts everything (no external
second time drops out of the applied stylesheets; see `embedded-transitions.js`).

Every embedded test runs twice, as Playwright projects `chromium` (:4201) and
`chromium-pierced` (:4202). Suites (`tests/`), all four commands listed in `AGENTS.md`:
`chromium-pierced` (:4202). The `mcp-*.spec.js` suites need no browser; `playwright.config.mcp.js`
runs them alone (`npm run test:mcp`) against an existing `dist/`, starting only the `:3000`
fragment server. Suites (`tests/`), all commands listed in `AGENTS.md`:
- `build-integrity.spec.js` — `dist/` output: both apps enumerated, absolute URL rewriting,
headless markup and the per-app `"headless"` override, the knowledge base stylesheet
inlined into every body (no page links one from its head) plus its stable `dist/style.css`
Expand Down Expand Up @@ -206,6 +226,20 @@ Every embedded test runs twice, as Playwright projects `chromium` (:4201) and
(name ↔ directory, portable fields only), no scripts shipped, every referenced file exists,
`examples/` are byte-identical to the contract's code blocks, and the docs carry the install
command.
- `mcp-golden.spec.js` — byte-exact pins: every protocol case over the synthetic search index
(`tests/fixtures/mcp/synthetic-index.js`; cases in `support/mcp-cases.js`), the same with a
public origin, the extraction of `fixtures/mcp/extract-rich.html`, and the search index the fixture
build generates. Files in `fixtures/mcp/golden/`; long strings are pinned by length and hash.
A deliberate change regenerates them with `UPDATE_GOLDEN=1`.
- `mcp-backend-contract.spec.js` — the memory adapters against `support/backend-contract.js`:
canonical order, complete paging, filters, cursor binding, typed errors, deterministic
best-first ranking (ranking itself is not pinned, so a vector backend can pass it).
- `mcp-bundle.spec.js` — the built `dist/_mcp/server.mjs` answers every protocol case exactly
like the source, and stays within its size budget (`make size`).
- `mcp-server.spec.js`, `mcp-search.spec.js`, `mcp-extract.spec.js`, `mcp-index-builder.spec.js`,
`mcp-error-page.spec.js` — HTTP guards and rate limits, ranking examples, extraction,
search index edge cases, browser error pages. `support/mcp.js` builds the runtime for tests (in
process, or the bundle as a child process).
- `standalone.spec.js` — the `:3000` fragment server directly (`playwright.config.ci.js`).
- `container.spec.js` — the real nginx image (`playwright.config.docker.js`, needs Docker).
- `support/fragment.js` — shadow-DOM traversal + reframed-body wait/query helpers.
Expand All @@ -229,6 +263,7 @@ in the committed `apps.json` without breaking CI, which only has this repo.
- `KB_STRICT` — `true` rejects `prebuilt`/`localPath`/`optional` entries, an empty registry, any entry that yields no apps, and any artifact with an `error` finding from the contract checker (`scripts/check-artifact.js`; every build logs the findings, grouped by rule, strict or not). Production builds only; this repo's own registry is a fixture and fails it by design.
- `KB_HEADLESS` — `true` produces web-fragment output; **anything else, including unset, means standalone**. `scripts/build-vite.js` always exports an explicit value, so the default only applies when `astro build`/`astro dev` runs directly. Read it through `isHeadlessBuild()`, never inline. A per-app `"headless"` in `apps.json` overrides it in either direction.
- `AWS_REGION`, `ECR_REPOSITORY`, `ECS_CLUSTER`, `ECS_SERVICE` — deployment config
- `KB_MCP_*` — the MCP server's runtime settings (`SEARCH_INDEX`, `HOST`, `PORT`, `PUBLIC_ORIGIN`, `ALLOWED_ORIGINS`, `TRUSTED_PROXIES`, `BACKEND` — `memory`, the only backend today). Parsed and validated in `mcp/src/config.ts` only; documented in `docs/mcp.md` (developer guide: `docs/mcp-development.md`)
- `KB_EXAMPLE_ARTIFACT` — overrides the packaged artifact `scripts/setup-test-apps.mjs` registers
- `KB_CONTAINER_PORT`, `KB_SKIP_BUILD` — container-suite harness (`tests/container/serve.mjs`); CI sets `KB_SKIP_BUILD` because its image job already built the image

Expand Down
13 changes: 11 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,17 @@ Thanks for your interest in improving **knowledge-base**.
```bash
npm install # Node >= 24
npm run build:headless # hermetic build from the vendored fixture (tests/fixtures/)
npm run lint # MCP server: tsc, ESLint, Prettier
npm test # embedded web-fragment E2E (Playwright)
```

With GNU make installed (on Windows: `winget install ezwinports.make`, run from
Git Bash), `make help` lists the same tasks as targets.

```bash
make fixture-build && make test-mcp # MCP suites only, the fast loop for mcp/
```

The build and tests are fully hermetic — they use the committed
`tests/fixtures/docs-example.dist.tar.gz` fixture (registered via `apps.json`),
so no `GITHUB_TOKEN`, network, or sibling repository is required.
Expand All @@ -33,6 +41,7 @@ opening your first PR, and required reading for automated contributors.
| `npm test` | Embedded web-fragment harness (`playwright.config.js`) — host gateway proxies/embeds the fragment on `:4201`. |
| `npx playwright test --config=playwright.config.ci.js` | Standalone fragment-server layer (`:3000`) — headers, headless contract, asset routing, #297. |
| `npm run test:container` | Integration tests against the real nginx image. **Needs Docker**, so it is not part of `npm test`. Run it when changing `nginx.conf`, `nginx.headers.conf` or the `Dockerfile`. |
| `npm run test:mcp` | The MCP suites alone (`playwright.config.mcp.js`) over an existing `dist/`. Part of `npm test` too. |

Both run in CI (`.github/workflows/ci.yml`). Please make sure both pass before
opening a PR.
Expand All @@ -41,8 +50,8 @@ opening a PR.

1. Branch off `master`.
2. Keep changes focused; update docs/tests alongside code.
3. Ensure `npm run build:headless`, `npm test`, and the standalone suite pass.
4. CI (type-check, build, E2E, audit) must be green.
3. Ensure `npm run build:headless`, `npm run lint`, `npm test`, and the standalone suite pass.
4. CI (type-check, lint, build, E2E, audit) must be green.

## Reporting security issues

Expand Down
47 changes: 20 additions & 27 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,37 +1,30 @@
# Runtime image: nginx serving the prebuilt static site.
#
# nginx-unprivileged rather than the stock nginx image: this container serves
# static files on port 8080 and needs no privileged port, so there is no reason
# for the master process to run as root. This variant already listens on 8080
# and runs as UID 101.
#
# Pinned by digest, matching how every GitHub Action in .github/workflows is
# pinned. Dependabot bumps the tag; the digest keeps the deployment from moving
# underneath it in the meantime.
FROM nginxinc/nginx-unprivileged:1.29-alpine@sha256:0c79d56aee561a1d81c63f00eee5fb5fe29279560cdc55e91425133104c7fbe6 AS runtime
# Node runtime donor. Only binary and Alpine C++ runtime libraries are copied; no apk/npm run here.
FROM node:24-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1 AS node-runtime

# Overwrite the stock config rather than `RUN rm`-ing it: this image drops to a
# non-root user, which cannot delete files under /etc/nginx.
FROM nginxinc/nginx-unprivileged:1.29-alpine@sha256:0c79d56aee561a1d81c63f00eee5fb5fe29279560cdc55e91425133104c7fbe6 AS runtime
USER root
COPY --from=node-runtime /usr/local/bin/node /usr/local/bin/node
COPY --from=node-runtime /usr/lib/libstdc++.so.6* /usr/lib/
COPY --from=node-runtime /usr/lib/libgcc_s.so.1 /usr/lib/
RUN node --version
COPY nginx.conf /etc/nginx/conf.d/default.conf

# The shared CORS + security header set, included by nginx.conf. Lives outside
# conf.d/ because nginx loads conf.d/*.conf as top-level server configuration
# and this is a fragment, not a server block.
COPY nginx.headers.conf /etc/nginx/kb-headers.conf

# `dist/` is built outside the image (npm run build / build:headless) and is not
# reproducible from this Dockerfile alone — see README. Fail loudly here rather
# than shipping an image that 404s, which is what a missing or half-built dist
# would otherwise produce at runtime.
COPY dist /usr/share/nginx/html
RUN test -f /usr/share/nginx/html/index.html \
|| (echo "dist/ has no index.html — run 'npm run build:headless' before docker build" >&2; exit 1)
RUN test -f /usr/share/nginx/html/style.css \
|| (echo "dist/ has no style.css — sub-app pages reference it via /__wf/knowledge-base/style.css" >&2; exit 1)

RUN test -f /usr/share/nginx/html/_error/not-found.html \
|| (echo "dist/ has no _error/not-found.html — nginx would answer every 404 with a 500" >&2; exit 1)
# Runtime search index is not static public content.
RUN test -f /usr/share/nginx/html/_mcp/server.mjs && test -f /usr/share/nginx/html/_mcp/search-index.json \
|| (echo "dist/_mcp missing — run the knowledge-base build (step 3b) before docker build" >&2; exit 1) \
&& mkdir -p /opt/kb-mcp && mv /usr/share/nginx/html/_mcp/* /opt/kb-mcp/ && rmdir /usr/share/nginx/html/_mcp
COPY docker/kb-entrypoint.sh /usr/local/bin/kb-entrypoint.sh
RUN chmod 0755 /usr/local/bin/kb-entrypoint.sh
USER 101
ENV KB_MCP_HOST=127.0.0.1 KB_MCP_PORT=8081 KB_MCP_SEARCH_INDEX=/opt/kb-mcp/search-index.json NODE_ENV=production
EXPOSE 8080

HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget -qO- http://localhost:8080/healthz || exit 1

CMD ["nginx", "-g", "daemon off;"]
CMD wget -qO- http://127.0.0.1:8080/healthz >/dev/null && wget -qO- http://127.0.0.1:8081/healthz >/dev/null || exit 1
CMD ["/usr/local/bin/kb-entrypoint.sh"]
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,12 @@ own apps for a real deployment.

---

## AI agents (MCP)

Public read-only MCP endpoint: `https://<host>/knowledge-base/mcp`. It exposes searchable `kb://` documentation resources plus `search_documents`, `list_documents`, and `read_document`. See [docs/mcp.md](docs/mcp.md) for client setup, limits, security posture, and operations, and [docs/mcp-development.md](docs/mcp-development.md) to work on the server.

---

## Testing

E2E tests use Playwright. Everything is hermetic — built from
Expand All @@ -219,7 +225,10 @@ E2E tests use Playwright. Everything is hermetic — built from
|---|---|
| `npm test` | **Embedded** harness (`playwright.config.js`). Starts the fragment server (`:3000`) and a minimal web-fragments **host gateway** (`tests/host/server.mjs`, `:4201`) that embeds the fragment. Covers shadow-DOM isolation, smooth no-reload SPA routing, cross-app navigation, asset 404s, and the fragment-history limitation. |
| `npx playwright test --config=playwright.config.ci.js` | **Standalone** layer. Hits the fragment server (`tests/fragment-server.mjs`, `:3000`) directly. Covers HTTP header safety (`X-Frame-Options`), the headless contract, CSS-link stability (web-fragments [#297](https://github.com/web-fragments/web-fragments/issues/297)), and asset routing. |
| `npm run test:container` | **Container** layer (needs Docker). Runs the real production image — nginx serving `dist/` — instead of the Express mirror the other two use. Covers the shipped `nginx.conf`: rewrites, response headers, the CSP in a browser, and that the image does not run as root. |
| `npm run test:container` | **Container** layer (needs Docker). Runs real production image — nginx plus loopback MCP sidecar — instead of Express mirror. Covers shipped rewrites, headers, CSP, MCP proxy, and unprivileged runtime. |
| `npm run test:mcp` | **MCP** suites only (`playwright.config.mcp.js`) over an existing `dist/`: golden protocol transcripts, the backend contract, the built bundle against the source and its size budget. |
| `npm run mcp:stdio` / `npm run mcp:http` / `npm run mcp:dev` | Runs the MCP server from source (TypeScript, no build step) over the search index from `npm run build:headless`; `mcp:dev` restarts on change. |
| `make help` | The same tasks as make targets, plus `make lint` and `make prod` (clean, install, strict build, bundle size check, image). |

> `tests/fragment-server.mjs` serves `dist/` and mirrors the production
> `nginx.conf` rewrites (including `/__wf/knowledge-base/* → /knowledge-base/*`).
Expand Down
Loading
Loading