From 554af4be566735514c832ea671f4a94fb932ce58 Mon Sep 17 00:00:00 2001 From: Jonathan Green Date: Sat, 9 May 2026 11:49:27 -0400 Subject: [PATCH 1/5] feat(townhouse): embed compose templates + image-manifest in npm tarball (Story 45.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add packages/townhouse/compose/{townhouse-hs,townhouse-dev}.yml source templates - Add packages/townhouse/src/compose-loader.ts with loadComposeTemplate() + materializeComposeTemplate() API (mode 0o600 writes, ComposeLoaderError) - Add scripts/render-compose-template.mjs for CI digest-substitution step - Update tsup.config.ts onSuccess hook to copy/render compose templates - Flip DEFAULT_CONNECTOR_IMAGE from tag form to digest form per CONNECTOR_RELEASE_CONTRACT.md - Update docker-compose-townhouse.yml connector image to digest form - Remove --dry-run from publish-townhouse-images.yml npm-publish step - Add render + tarball verification CI steps to publish-townhouse-images.yml - Add 12 unit tests (compose-loader), 7 integration (validity), 5 integration (tarball) - Update connector-image-contract.test.ts to accept digest-form image refs - Document compose templates API in README, CLAUDE.md, CONNECTOR_RELEASE_CONTRACT.md Critical path: 45.1 → 45.2 (this) → 45.4 Co-Authored-By: Claude Sonnet 4.6 --- .../workflows/publish-townhouse-images.yml | 52 +- CLAUDE.md | 3 + ...lates-and-image-manifest-in-npm-tarball.md | 814 ++++++++++++++++++ .../sprint-status.yaml | 6 +- docker-compose-townhouse.yml | 6 +- packages/sdk/CONNECTOR_RELEASE_CONTRACT.md | 6 + packages/townhouse/README.md | 60 ++ packages/townhouse/compose/townhouse-dev.yml | 405 +++++++++ packages/townhouse/compose/townhouse-hs.yml | 244 ++++++ .../compose-template-validity.test.ts | 163 ++++ .../connector-image-contract.test.ts | 21 +- .../__integration__/tarball-contents.test.ts | 116 +++ .../compose-loader/compose/townhouse-dev.yml | 12 + .../compose-loader/compose/townhouse-hs.yml | 64 ++ .../compose-loader/image-manifest.json | 32 + packages/townhouse/src/compose-loader.test.ts | 164 ++++ packages/townhouse/src/compose-loader.ts | 101 +++ packages/townhouse/src/constants.ts | 16 +- packages/townhouse/src/index.ts | 7 + packages/townhouse/tsup.config.ts | 49 ++ scripts/render-compose-template.mjs | 82 ++ 21 files changed, 2400 insertions(+), 23 deletions(-) create mode 100644 _bmad-output/implementation-artifacts/45-2-embed-compose-templates-and-image-manifest-in-npm-tarball.md create mode 100644 packages/townhouse/compose/townhouse-dev.yml create mode 100644 packages/townhouse/compose/townhouse-hs.yml create mode 100644 packages/townhouse/src/__integration__/compose-template-validity.test.ts create mode 100644 packages/townhouse/src/__integration__/tarball-contents.test.ts create mode 100644 packages/townhouse/src/__tests__/fixtures/compose-loader/compose/townhouse-dev.yml create mode 100644 packages/townhouse/src/__tests__/fixtures/compose-loader/compose/townhouse-hs.yml create mode 100644 packages/townhouse/src/__tests__/fixtures/compose-loader/image-manifest.json create mode 100644 packages/townhouse/src/compose-loader.test.ts create mode 100644 packages/townhouse/src/compose-loader.ts create mode 100644 scripts/render-compose-template.mjs diff --git a/.github/workflows/publish-townhouse-images.yml b/.github/workflows/publish-townhouse-images.yml index 23da0f2e..10cc8cf9 100644 --- a/.github/workflows/publish-townhouse-images.yml +++ b/.github/workflows/publish-townhouse-images.yml @@ -3,9 +3,9 @@ # Trigger scenarios and expected behaviour: # - Push of tag v* → builds 4 townhouse images (townhouse-api, town, mill, dvm), # pushes versioned + :latest tags, signs, writes manifest, -# then npm-publishes @toon-protocol/townhouse. +# renders compose templates, then npm-publishes @toon-protocol/townhouse. # - workflow_dispatch → builds + pushes versioned tag only (NO :latest), signs, -# writes manifest. Does NOT npm-publish (smoke-test path). +# writes manifest, renders templates (dry-run only; NO npm-publish). # # Connector image is NOT built or pushed here — it is published upstream by # toon-protocol/connector's own workflow (Stories 44.2/44.3). Townhouse consumes @@ -23,9 +23,13 @@ # --certificate-oidc-issuer 'https://token.actions.githubusercontent.com' # done # -# NOTE: npm-publish step runs in --dry-run mode pending Story 45.2 -# (dist/compose/townhouse-hs.yml must ship in the tarball before live publish). -# Flip to live publish when Story 45.2 lands. +# Staged delivery history: +# v0.1.0 (Story 45.1): images + signatures + manifest published; npm publish in --dry-run +# pending Story 45.2 (tarball must ship compose templates + manifest before live publish). +# v0.1.0+ (Story 45.2): live npm publish flipped on; tarball ships +# dist/compose/{townhouse-hs,townhouse-dev}.yml + dist/image-manifest.json. +# Build sequence in npm-publish job: pnpm build → download-artifact (manifest) +# → render-compose-template → verify tarball → pnpm publish (live). name: Publish Townhouse Images @@ -40,7 +44,7 @@ on: required: true type: string connector_version: - description: 'Connector image tag to record in image-manifest.json (default: 3.4.1)' + description: 'Connector image tag to record in image-manifest.json (default: 3.4.1 = digest pinned in DEFAULT_CONNECTOR_IMAGE)' required: false type: string default: '3.4.1' @@ -251,17 +255,47 @@ jobs: run: pnpm install --frozen-lockfile - name: Build @toon-protocol/townhouse + # clean: true in tsup config wipes dist/ at build start. + # The manifest must be placed AFTER this build step, not before. run: pnpm --filter @toon-protocol/townhouse build - name: Download image-manifest.json + # Placed into dist/ AFTER pnpm build so tsup's clean: true doesn't wipe it. uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: name: image-manifest path: packages/townhouse/dist + - name: Render HS compose template against pinned digests + # Re-runs the placeholder substitution now that dist/image-manifest.json + # is present. tsup's onSuccess hook ran without the manifest (clean build), + # so this step produces the authoritative digest-substituted HS template. + run: node scripts/render-compose-template.mjs + + - name: Verify tarball contents + # Gate: ensure the three required artifacts are present in the tarball + # and the HS YAML has no unsubstituted placeholders before publishing. + run: | + set -euo pipefail + mkdir -p /tmp/pack-out /tmp/pack-extracted + pnpm --filter @toon-protocol/townhouse pack --pack-destination /tmp/pack-out/ + TGZ=$(ls /tmp/pack-out/toon-protocol-townhouse-*.tgz | head -1) + tar -tzf "$TGZ" > /tmp/pack-listing.txt + for path in \ + package/dist/compose/townhouse-hs.yml \ + package/dist/compose/townhouse-dev.yml \ + package/dist/image-manifest.json; do + grep -qF "$path" /tmp/pack-listing.txt \ + || { echo "MISSING from tarball: $path"; exit 1; } + done + tar -xzf "$TGZ" -C /tmp/pack-extracted/ + if grep -E '\$\{TOON_[A-Z_]+_DIGEST\}' \ + /tmp/pack-extracted/package/dist/compose/townhouse-hs.yml; then + echo "FAIL: unsubstituted placeholders in tarball HS YAML"; exit 1 + fi + echo "✅ Tarball contains all required artifacts with no unsubstituted placeholders" + - name: Publish to npm - # NOTE: --dry-run is set pending Story 45.2 (dist/compose/townhouse-hs.yml - # must ship in the tarball before live publish). Flip to live when 45.2 lands. - run: pnpm --filter @toon-protocol/townhouse publish --access public --no-git-checks --dry-run + run: pnpm --filter @toon-protocol/townhouse publish --access public --no-git-checks env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/CLAUDE.md b/CLAUDE.md index 83b4147a..e1c4c74f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -192,6 +192,9 @@ All bindings on `127.0.0.1:` only. Script: `scripts/townhouse-dev-infra.sh`. Con | Townhouse dev stack fixtures | `docker/dev-fixtures/` (Mill JSON configs + README) | | **Townhouse real-CLI E2E** | `scripts/townhouse-test-infra.sh` (Story 21.16) | | Townhouse real-CLI E2E docs | `packages/townhouse/README.md` § "Running E2E Tests" | +| **Townhouse npm-tarball compose templates** | `packages/townhouse/compose/` (source) → `dist/compose/` (built output) | +| Compose loader + materializer API | `packages/townhouse/src/compose-loader.ts` | +| Image-manifest digest registry (per release) | `packages/townhouse/dist/image-manifest.json` (CI-produced; not committed) | ## Browser Verification diff --git a/_bmad-output/implementation-artifacts/45-2-embed-compose-templates-and-image-manifest-in-npm-tarball.md b/_bmad-output/implementation-artifacts/45-2-embed-compose-templates-and-image-manifest-in-npm-tarball.md new file mode 100644 index 00000000..e2904b09 --- /dev/null +++ b/_bmad-output/implementation-artifacts/45-2-embed-compose-templates-and-image-manifest-in-npm-tarball.md @@ -0,0 +1,814 @@ +# Story 45.2: Embed Compose Templates + Image Manifest in npm Tarball + +Status: review + +> **CRITICAL PATH — second story of Epic 45 (One-Command Apex Install).** Sized M by the plan. Story 45.4 (`townhouse hs up` subcommand) cannot start until this story lands the embedded compose template and the `loadComposeTemplate()` API. This story is also what flips the `--dry-run` flag in the Story 45.1 publish workflow to live `npm publish` — without an `image-manifest.json` and a digest-resolved compose template inside the tarball, an operator running `npx @toon-protocol/townhouse hs up` has no compose file to feed `docker compose -f` against. + +## Story + +As a **townhouse operator (Drew)** preparing to run the v0.1 hidden-service apex, +I want **the published `@toon-protocol/townhouse` npm package to ship (a) a digest-pinned `townhouse-hs.yml` compose template, (b) the `townhouse-dev.yml` template the existing contributor dev stack already consumes, and (c) the `image-manifest.json` digest registry that pins every townhouse-owned image plus the upstream connector image to a content-addressed `sha256:` digest**, +so that I never need to clone the source repo, my version of townhouse always pulls the exact image set it was tested against, and `docker compose -f ~/.townhouse/compose/townhouse-hs.yml up` is a deterministic, reproducible operation across architectures and across `npm install` invocations of the same version. + +## Acceptance Criteria + +1. **`packages/townhouse/compose/` source dir exists with both templates.** Two new files: `packages/townhouse/compose/townhouse-hs.yml` and `packages/townhouse/compose/townhouse-dev.yml`. The HS template is the canonical operator-facing compose for `townhouse hs up`; the dev template is the canonical contributor-facing compose used by `scripts/townhouse-dev-infra.sh`. The two existing root-level files (`docker-compose-townhouse-hs.yml`, `docker-compose-townhouse-dev.yml`) are NOT deleted in this story (scope-guard — see Dev Notes "What This Story Does NOT Do"); the package-local copies are the new sources of truth for the npm tarball, and root-level retirement is deferred to a follow-up. + +2. **The HS template uses digest-pinned `image:` for every service — never `build:`, never tag-form.** Every `services..image` entry in `packages/townhouse/compose/townhouse-hs.yml` MUST take the form `ghcr.io/toon-protocol/@sha256:` for the four townhouse-owned services (`townhouse-api`, `town`, `mill`, `dvm`) AND for the upstream connector. No `build:` directives. No `image: ...:tag` form. No `image: toon:` (the local-build pattern from `docker-compose-townhouse-hs.yml` at the root). Digest values are resolved from `dist/image-manifest.json` at build time (see AC #4 + Task 5). A grep of the source template file MAY contain placeholder tokens (e.g. `${TOON_TOWN_DIGEST}` or similar) that are substituted at build time — what ships in the tarball must be fully substituted. + +3. **`townhouse-api` service is added to the HS template** (it is missing from the legacy root-level `docker-compose-townhouse-hs.yml`). The new service definition in `packages/townhouse/compose/townhouse-hs.yml` uses `image: ghcr.io/toon-protocol/townhouse-api@sha256:`, mounts the host docker socket RW (`/var/run/docker.sock:/var/run/docker.sock`), mounts `~/.townhouse/` RW into the container, exposes Fastify on `127.0.0.1:28090` (per Epic 21 D21-008 + planning doc §4 architecture anchor), depends_on `connector` healthcheck, and has a healthcheck against `/api/health`. This service is what Story 45.4 boots alongside `connector`. + +4. **`scripts/build-image-manifest.mjs` (Story 45.1) is reused as-is to produce the digest-resolution input.** The script already exists at the repo root, ships the v1 manifest schema (5 image entries: `townhouse-api`, `town`, `mill`, `dvm`, `connector`), and runs in the Story 45.1 publish workflow. Story 45.2 does NOT modify this script. What Story 45.2 adds is: + - A new `scripts/render-compose-template.mjs` script (or an inline tsup `onSuccess` hook — dev's call) that reads `packages/townhouse/dist/image-manifest.json` and substitutes placeholders in `packages/townhouse/compose/townhouse-hs.yml` to produce `packages/townhouse/dist/compose/townhouse-hs.yml`. + - A copy step in the same build that ships `packages/townhouse/compose/townhouse-dev.yml` to `packages/townhouse/dist/compose/townhouse-dev.yml` verbatim (no substitution — the dev template uses local `toon:*` images by design). + +5. **tsup config copies `compose/` and preserves `image-manifest.json` into `dist/`.** `packages/townhouse/tsup.config.ts` is updated so its `onSuccess` hook (or an equivalent post-build script) (a) copies `packages/townhouse/compose/townhouse-dev.yml` → `packages/townhouse/dist/compose/townhouse-dev.yml`, (b) renders `packages/townhouse/compose/townhouse-hs.yml` against `packages/townhouse/dist/image-manifest.json` and writes the result to `packages/townhouse/dist/compose/townhouse-hs.yml`, and (c) does NOT delete `packages/townhouse/dist/image-manifest.json` if it is already present (the publish workflow drops it there via `actions/download-artifact` BEFORE `pnpm build` runs — see Task 7). When `dist/image-manifest.json` is absent (typical during local development), the build does NOT fail; instead it emits a warning, skips the HS substitution, copies the unsubstituted HS template (which still parses as valid YAML — placeholders are valid YAML strings), and lets unit tests catch missing-manifest scenarios. This matches the dev/CI split: developers running `pnpm --filter @toon-protocol/townhouse build` locally get a working dist without first running `scripts/build-image-manifest.mjs`. + +6. **`packages/townhouse/src/compose-loader.ts` exists with the API the next story consumes.** The new module exports: + + ```typescript + export type ComposeProfile = 'dev' | 'hs'; + + export interface ComposeLoaderOptions { + /** Override default `~/.townhouse/` write target. Used by tests. */ + townhouseHome?: string; + /** Override the package-relative dist directory the loader reads from. + * Defaults to the `dist/` adjacent to compose-loader.js at runtime. Tests use this + * to point at fixture directories without touching the real package install. */ + distDir?: string; + } + + /** + * Returns the rendered compose YAML for the requested profile. + * For 'hs', digest substitutions are already applied (resolved at build time). + * For 'dev', the YAML is returned verbatim (uses local `toon:*` image tags). + * Throws `ComposeLoaderError` if the requested profile's YAML is unreadable. + */ + export function loadComposeTemplate( + profile: ComposeProfile, + options?: ComposeLoaderOptions + ): string; + + /** + * Writes the resolved compose YAML to `/compose/.yml` + * and copies `dist/image-manifest.json` to `/image-manifest.json`. + * BOTH output files are written with mode 0o600 (NFR8 — operator-secret file mode). + * Returns the absolute paths of the two files written. + */ + export function materializeComposeTemplate( + profile: ComposeProfile, + options?: ComposeLoaderOptions + ): { composePath: string; manifestPath: string }; + + export class ComposeLoaderError extends Error {} + ``` + + The two functions are independent: `loadComposeTemplate` is read-only (returns a string), `materializeComposeTemplate` is the side-effecting write that Story 45.4 invokes during `townhouse hs up`. Both are exported from `packages/townhouse/src/index.ts` so consumers (the CLI, future SPA, integration tests) can import them via the package public API. + +7. **Resolved HS template + manifest are written to `~/.townhouse/` with mode `0o600`.** When `materializeComposeTemplate('hs')` runs: + - `/compose/townhouse-hs.yml` is created with mode `0o600` (NFR8 applies — operator-secret file mode; the rendered compose contains environment variables that may include private keys at deploy time). + - `/image-manifest.json` is created with mode `0o600` (operator-secret file mode — pins the supply-chain identity; tampering with this file is what an attacker would do to swap images). + - Parent directories are created with mode `0o700` (`~/.townhouse/` and `~/.townhouse/compose/`) if absent. + - The umask is restored after the write (`fs.chmodSync` after write, OR pass `mode` to `fs.writeFileSync` AND verify post-write). + - Pre-existing files at the same path are overwritten without prompting (idempotent re-runs are an explicit AC of Story 45.4 — re-running `hs up` MUST NOT prompt for permission). + +8. **Unit + integration tests cover loader behavior + tarball contents + compose validity.** New test files: + - `packages/townhouse/src/compose-loader.test.ts` — unit tests for `loadComposeTemplate` + `materializeComposeTemplate`. Covers: dev profile returns verbatim, hs profile returns substituted YAML, hs profile throws when manifest absent + dev profile does not, materialize writes `0o600`, materialize creates parent dir at `0o700`, materialize is idempotent (second call overwrites first). + - `packages/townhouse/src/__integration__/compose-template-validity.test.ts` — integration test that takes the rendered HS template and validates it via `docker compose -f config` (subprocess invocation; gated on `process.env.DOCKER_AVAILABLE === '1'` or skipped in environments without the docker binary). On a clean rendered tarball, this asserts: (a) `docker compose config` exits 0, (b) the parsed output names every image at digest form (`grep '@sha256:'` matches every `image:` line in the services map), (c) no `build:` directives appear. + - `packages/townhouse/src/__integration__/tarball-contents.test.ts` — runs `pnpm pack` against the package, untars the resulting `.tgz`, and asserts `package/dist/compose/townhouse-hs.yml`, `package/dist/compose/townhouse-dev.yml`, AND `package/dist/image-manifest.json` are present. Asserts the HS YAML in the tarball has no unsubstituted placeholders (regex match against `\$\{[A-Z_]+_DIGEST\}` returns 0 matches). The test is skipped if `dist/image-manifest.json` is absent at test time (CI runs with manifest present; pure local dev runs without). + +9. **`DEFAULT_CONNECTOR_IMAGE` constant flips from tag form to digest form.** `packages/townhouse/src/constants.ts` line 21 (currently `export const DEFAULT_CONNECTOR_IMAGE = 'ghcr.io/toon-protocol/connector:3.4.1'`) is updated to take its value from `dist/image-manifest.json` at build time OR (simpler — the recommended approach) is hard-coded to a digest-form constant matching what `image-manifest.json` ships at v0.1.0 (`ghcr.io/toon-protocol/connector@sha256:`). The contract canary (`packages/townhouse/src/__integration__/connector-image-contract.test.ts`) runs against the new constant and asserts the resolved manifest entry's digest matches. Bumps to subsequent connector versions become a one-line edit to the constant + a workflow-dispatch run of Story 45.1 to capture the new digest into a fresh `image-manifest.json` (the operator-experience cost of "deliberately bumping each minor" per release-contract clause `CONNECTOR_RELEASE_CONTRACT.md` §"Townhouse pins by digest"). + +10. **The Story 45.1 publish workflow flips from `--dry-run` to live `npm publish`.** `.github/workflows/publish-townhouse-images.yml` line containing `pnpm --filter @toon-protocol/townhouse publish ... --dry-run` (or equivalent) has the `--dry-run` flag removed. The top-of-file comment that documented the staged delivery is updated to record that 45.2 has landed and live publish is now active. `secrets.NPM_TOKEN` consumption stays as-is. The `npm-publish` job's `needs:` declaration stays as-is (it already gates on the four image-publish + sign matrix entries succeeding). NO new triggers — `v*` tag-push only. NO `workflow_dispatch` live-publish path (workflow_dispatch stays dry-run for smoke testing). + +11. **Tarball-content verification step in workflow** (one CI step, low cost): a new step in the `npm-publish` job runs `pnpm --filter @toon-protocol/townhouse pack --pack-destination /tmp/pack-out/` BEFORE `pnpm publish`, then `tar -tzf /tmp/pack-out/toon-protocol-townhouse-*.tgz | grep -E 'package/dist/(compose/townhouse-(hs|dev)\.yml|image-manifest\.json)'` MUST match all three lines. If any are missing, the step (and therefore the publish) fails. This catches "tsup config drift dropped the compose copy step" before the package ships. + +12. **Sprint-status update.** AFTER the workflow-dispatch + live-publish smoke succeeds against a `v0.1.0-rc2` (or similar) test tag AND the published package's `node_modules/@toon-protocol/townhouse/dist/compose/townhouse-hs.yml` contains digest-form image refs verifiable against the registry: update `_bmad-output/implementation-artifacts/sprint-status.yaml` `45-2-embed-compose-templates-and-image-manifest-in-npm-tarball: backlog → done` (mirror Story 44.4 / 45.1 close-out style — `# done: tag vX.Y.Z published; tarball ships compose/* + image-manifest.json — town#`). Bump `last_updated` to merge date. + +## Tasks / Subtasks + +- [x] **Task 1: Read 45.1 outputs + confirm `image-manifest.json` shape is consumable** (AC: #2, #4, #9) + - [x] 1.1 Re-read `_bmad-output/implementation-artifacts/45-1-multi-arch-townhouse-image-publish-ci.md` end-to-end. Pay attention to the v1 manifest schema (`scripts/build-image-manifest.mjs:50-66`) — five image entries keyed `townhouse-api | town | mill | dvm | connector`, each with `{name, tag, digest}` and `digest` matches `^sha256:[a-f0-9]+`. The dev MUST consume this exact shape; do NOT introduce a v2 schema. + - [x] 1.2 Pull a copy of the actual artifact from workflow run `25603167091` to confirm the live shape: `gh run download 25603167091 --repo toon-protocol/town --name image-manifest -D /tmp/45-1-artifact/`. Inspect `/tmp/45-1-artifact/image-manifest.json`. Verify all five `digest` values are `sha256:...` form. If the file is missing keys (e.g., `connector` digest is null because of the buildx-imagetools fix in town#41), retry with the LATEST publish workflow run via `gh run list --workflow=publish-townhouse-images.yml --repo toon-protocol/town`. + - [x] 1.3 Read `packages/townhouse/src/constants.ts:21` (`DEFAULT_CONNECTOR_IMAGE = 'ghcr.io/toon-protocol/connector:3.4.1'`) to confirm the current tag-form pin. Read `packages/townhouse/src/__integration__/connector-image-contract.test.ts` to confirm what the canary asserts against the constant (image existence, admin endpoint shape, version label). The constant flip in Task 6 must keep this test green. + - [x] 1.4 Read `docker-compose-townhouse-hs.yml` at the repo root end-to-end. Note: it uses `image: toon:town`, `image: toon:mill`, etc. (local-build tags) AND `image: ghcr.io/toon-protocol/connector:3.4.1` (tag form). These must NOT appear in the new `packages/townhouse/compose/townhouse-hs.yml` — every service goes to digest form (per AC #2). Note also: the root file does NOT include a `townhouse-api` service. The new template adds it (AC #3). + - [x] 1.5 Read `docker-compose-townhouse-dev.yml` at the repo root end-to-end. Confirm what the contributor dev stack expects: services, profiles, networks, volumes, port bindings, env-vars. The new `packages/townhouse/compose/townhouse-dev.yml` MAY copy this file verbatim OR diverge minimally (e.g., drop bind mounts that point at repo paths the operator doesn't have). Discuss in Dev Notes which approach the dev chose and why. + - [x] 1.6 Read `packages/townhouse/tsup.config.ts` to confirm the current build pipeline. Note: it is a minimal config with `entry: ['src/index.ts', 'src/cli.ts']`, `format: ['esm']`, `dts: true`, `outDir: 'dist'`, `clean: true`. The `clean: true` flag DELETES everything in `dist/` at the start of every build — including `dist/image-manifest.json` if it was placed there by CI. This is a load-bearing detail: Task 5's `onSuccess` hook MUST run AFTER tsup's clean+build, AND `image-manifest.json` placement must happen AFTER `pnpm build`, NOT before. The 45.1 workflow's npm-publish job already does this in the right order (build → download-artifact → publish); preserving that order is essential. + +- [x] **Task 2: Author `packages/townhouse/compose/townhouse-hs.yml`** (AC: #1, #2, #3) + - [x] 2.1 Create the directory `packages/townhouse/compose/` (does not exist yet). + - [x] 2.2 Author the HS template as a YAML file with placeholder tokens for the digest values. Recommended placeholder syntax: `${TOON__DIGEST}` (e.g. `${TOON_TOWN_DIGEST}`, `${TOON_CONNECTOR_DIGEST}`) — uppercase, underscore-separated, easy to grep. The placeholder appears in the `image:` value: + ```yaml + services: + town: + image: ghcr.io/toon-protocol/town${TOON_TOWN_DIGEST} + # → after substitution: ghcr.io/toon-protocol/town@sha256:abc123... + ``` + The substitution at Task 5 simply does `templateString.replaceAll('${TOON_TOWN_DIGEST}', '@sha256:abc123...')`. The leading `@` lives in the substituted value (NOT in the template) so an unsubstituted template still parses as valid YAML (`ghcr.io/toon-protocol/town${TOON_TOWN_DIGEST}` is a plain string — Docker Compose will reject it as "image not found" but the YAML parser accepts it). + - [x] 2.3 Service entries to include (matching planning doc §4 + Story 45.4 AC #1): + - `connector` — `image: ghcr.io/toon-protocol/connector${TOON_CONNECTOR_DIGEST}`. Volume `townhouse-hs-anon:/var/lib/anon/hs`. Port `127.0.0.1:9401:9401` (admin API) on host loopback only (NFR9). Container hostname `connector` (matches HS_TARGET_HOST in the existing root file). NO docker.sock mount (NFR7). Env var `CONFIG_FILE=/config/connector.yaml`. Operator-config bind-mount `~/.townhouse/connector.yaml:/config/connector.yaml:ro` (Story 45.4 generates this file at first-run). + - `townhouse-api` (NEW — see AC #3) — `image: ghcr.io/toon-protocol/townhouse-api${TOON_TOWNHOUSE_API_DIGEST}`. Volume mounts: `/var/run/docker.sock:/var/run/docker.sock` (RW; the API owns the dockerode handle per planning doc §4) AND `~/.townhouse:/.townhouse:rw` (the API reads/writes wallet, snapshots, config). Port `127.0.0.1:28090:28090` (Fastify host API). depends_on `connector` (`condition: service_healthy`). Healthcheck against `/api/health`. + - `town`, `mill`, `dvm` — each takes `image: ghcr.io/toon-protocol/${TOON__DIGEST}`. Profiles `[town]`, `[mill]`, `[dvm]` respectively (lazy-provisioned per Epic 46). depends_on connector healthcheck. Existing port + env-var patterns from `docker-compose-townhouse-hs.yml` carry over EXCEPT: no faucet, no anvil, no solana, no ator-sidecar, no ator-sidecar-relay (those are dev-stack concerns, not operator-facing for HS-mode v1; Story 45.4 explicitly boots only connector + townhouse-api at apex install). The town/mill/dvm services exist in the template so Epic 46 can `docker compose --profile up -d` later. + - [x] 2.4 NO `build:` directives anywhere. NO `image: toon:*` (local-build tags). NO `:latest` tags. NO un-pinned tag forms (e.g., `ghcr.io/toon-protocol/connector:3.4.1` is forbidden by AC #2). Only digest form via the placeholder pattern. + - [x] 2.5 NO ator-sidecar or ator-sidecar-relay services (those are part of the legacy root-level workflow that operators ran manually before Story 45.4's `townhouse hs up` subcommand existed). The new HS-mode v1 design boots the apex with the connector container's embedded `@anyone-protocol/anyone-client` SDK doing the HS publishing — see planning doc §4 "Apex idle state is useful" — so the sidecar is no longer required. The connector image at the digest pinned in `image-manifest.json` is the connector v3.5.x build that ships with anon support. + - [x] 2.6 Top-of-file YAML comment block describing: (a) what this template is (operator-facing apex compose), (b) how digests are substituted (Task 5 hook), (c) where the resolved file ends up (`~/.townhouse/compose/townhouse-hs.yml`, mode `0o600`), (d) which Story owns the substitution (45.2) and which Story owns the boot sequence (45.4). Mirror the comment-block style of `docker-compose-townhouse-hs.yml` (extensive header explaining provenance + workflow). + - [x] 2.7 All host-side ports MUST bind to `127.0.0.1` only (NFR9). Any service entry with `ports: [...]` whose value omits the `127.0.0.1:` prefix fails review. The image-validity test (Task 8.3) MUST grep for `'\b0\.0\.0\.0:'` in the rendered file and fail if found. + +- [x] **Task 3: Author `packages/townhouse/compose/townhouse-dev.yml`** (AC: #1) + - [x] 3.1 Copy `docker-compose-townhouse-dev.yml` verbatim to `packages/townhouse/compose/townhouse-dev.yml`. The dev template uses local `toon:*` image tags by design — contributors run `pnpm --filter @toon-protocol/townhouse build` followed by `docker compose -f packages/townhouse/compose/townhouse-dev.yml ...` and the local Docker daemon resolves `toon:town` against locally-built images. + - [x] 3.2 NO digest substitution for the dev template (per AC #2 wording — digest pinning applies only to the HS template). The Task 5 build hook copies this file verbatim. + - [x] 3.3 Update top-of-file comment to add the new path (`packages/townhouse/compose/townhouse-dev.yml`) + a note that the legacy root-level path (`docker-compose-townhouse-dev.yml`) is preserved for backward compatibility with `scripts/townhouse-dev-infra.sh` and existing CI. + - [x] 3.4 Scope-guard: do NOT delete the root-level `docker-compose-townhouse-dev.yml`. Do NOT delete the root-level `docker-compose-townhouse-hs.yml`. Both stay until a follow-up retirement story (post-Epic 45) routes everything through the package-local templates. + +- [x] **Task 4: Author `packages/townhouse/src/compose-loader.ts`** (AC: #6, #7) + - [x] 4.1 New file with the API surface specified in AC #6 verbatim: + ```typescript + import { readFileSync, writeFileSync, mkdirSync, chmodSync, existsSync } from 'node:fs'; + import { dirname, join, resolve } from 'node:path'; + import { fileURLToPath } from 'node:url'; + import { homedir } from 'node:os'; + + export type ComposeProfile = 'dev' | 'hs'; + + export interface ComposeLoaderOptions { + townhouseHome?: string; + distDir?: string; + } + + export class ComposeLoaderError extends Error { + constructor(message: string) { + super(message); + this.name = 'ComposeLoaderError'; + } + } + + function defaultDistDir(): string { + // Resolves to `dist/` adjacent to the bundled compose-loader.js at runtime. + // tsup outputs compose-loader to `dist/index.js` (re-exported) so __dirname + // is `/dist/`. + const here = dirname(fileURLToPath(import.meta.url)); + // When bundled, here === /dist/. When in tsx/ts-node dev mode, + // here === /src/. Both work because in the latter case `dist/` + // is a sibling. + return resolve(here, '..', 'dist'); + } + + export function loadComposeTemplate( + profile: ComposeProfile, + options: ComposeLoaderOptions = {} + ): string { + const distDir = options.distDir ?? defaultDistDir(); + const composePath = join(distDir, 'compose', `townhouse-${profile}.yml`); + if (!existsSync(composePath)) { + throw new ComposeLoaderError( + `compose template not found: ${composePath}. ` + + `Did you run 'pnpm --filter @toon-protocol/townhouse build' first?` + ); + } + return readFileSync(composePath, 'utf-8'); + } + + export function materializeComposeTemplate( + profile: ComposeProfile, + options: ComposeLoaderOptions = {} + ): { composePath: string; manifestPath: string } { + const home = options.townhouseHome ?? join(homedir(), '.townhouse'); + const composeDir = join(home, 'compose'); + mkdirSync(composeDir, { recursive: true, mode: 0o700 }); + // chmod after mkdir for already-existing dirs (mkdir's mode arg is honored + // only on creation). Defensive: re-chmod on every call. + chmodSync(home, 0o700); + chmodSync(composeDir, 0o700); + + const yaml = loadComposeTemplate(profile, options); + const composePath = join(composeDir, `townhouse-${profile}.yml`); + writeFileSync(composePath, yaml, { mode: 0o600, encoding: 'utf-8' }); + chmodSync(composePath, 0o600); // defensive re-chmod (umask interactions) + + const distDir = options.distDir ?? defaultDistDir(); + const manifestSrc = join(distDir, 'image-manifest.json'); + const manifestPath = join(home, 'image-manifest.json'); + if (existsSync(manifestSrc)) { + const manifest = readFileSync(manifestSrc, 'utf-8'); + writeFileSync(manifestPath, manifest, { mode: 0o600, encoding: 'utf-8' }); + chmodSync(manifestPath, 0o600); + } else { + // Manifest is required for HS mode — fail loudly. Dev mode tolerates absence. + if (profile === 'hs') { + throw new ComposeLoaderError( + `image-manifest.json not found at ${manifestSrc}. ` + + `HS mode requires a digest-pinned image manifest. ` + + `Reinstall @toon-protocol/townhouse from npm to restore the manifest.` + ); + } + } + + return { composePath, manifestPath }; + } + ``` + - [x] 4.2 ESM-only — no `require()`, no `module.exports`. tsup banners are already configured for `import.meta.url` resolution (`tsup.config.ts:13` injects the `createRequire` shim, but compose-loader.ts uses `fileURLToPath(import.meta.url)` directly which works in pure ESM). + - [x] 4.3 Export both functions + the error class from `packages/townhouse/src/index.ts`. Add to the public API surface alongside existing exports (after the `WalletManager` block): + ```typescript + export { + loadComposeTemplate, + materializeComposeTemplate, + ComposeLoaderError, + } from './compose-loader.js'; + export type { ComposeProfile, ComposeLoaderOptions } from './compose-loader.js'; + ``` + - [x] 4.4 NO YAML parsing in the loader. The loader treats compose YAML as an opaque string — passing the file through unchanged is safe AND aligns with the "let docker compose validate it" pattern (Task 8.2). Adding `yaml.parse()` would make the loader carry the YAML schema contract and bloat the bundle; NOT worth it. + - [x] 4.5 Defensive `chmodSync` after `writeFileSync` is required because `writeFileSync`'s `mode` option is masked by the process umask on some Linux filesystems (notably WSL2 — see CLAUDE.md notes). The `chmodSync` is the load-bearing call; the `mode` option is belt-and-suspenders. + +- [x] **Task 5: Update `packages/townhouse/tsup.config.ts` with the build hook** (AC: #5, #11) + - [x] 5.1 Modify `packages/townhouse/tsup.config.ts` to add an `onSuccess` hook (or equivalent post-build script): + ```typescript + import { defineConfig } from 'tsup'; + import { cp, mkdir, readFile, writeFile, access } from 'node:fs/promises'; + import { join } from 'node:path'; + + export default defineConfig({ + entry: ['src/index.ts', 'src/cli.ts'], + format: ['esm'], + dts: true, + sourcemap: true, + clean: true, + outDir: 'dist', + outExtension: () => ({ js: '.js' }), + banner: { + js: `import { createRequire } from 'module'; const require = createRequire(import.meta.url);`, + }, + onSuccess: async () => { + const composeDistDir = 'dist/compose'; + await mkdir(composeDistDir, { recursive: true }); + + // Copy dev template verbatim + await cp('compose/townhouse-dev.yml', join(composeDistDir, 'townhouse-dev.yml')); + + // Render HS template — substitute digest placeholders from image-manifest.json + const manifestPath = 'dist/image-manifest.json'; + const hsTemplateRaw = await readFile('compose/townhouse-hs.yml', 'utf-8'); + let hsRendered = hsTemplateRaw; + try { + await access(manifestPath); + const manifestRaw = await readFile(manifestPath, 'utf-8'); + const manifest = JSON.parse(manifestRaw) as { + images: Record; + }; + // Map placeholder → '@' substitution + const subs: Array<[string, string]> = [ + ['${TOON_TOWNHOUSE_API_DIGEST}', `@${manifest.images['townhouse-api'].digest}`], + ['${TOON_TOWN_DIGEST}', `@${manifest.images.town.digest}`], + ['${TOON_MILL_DIGEST}', `@${manifest.images.mill.digest}`], + ['${TOON_DVM_DIGEST}', `@${manifest.images.dvm.digest}`], + ['${TOON_CONNECTOR_DIGEST}', `@${manifest.images.connector.digest}`], + ]; + for (const [placeholder, replacement] of subs) { + hsRendered = hsRendered.replaceAll(placeholder, replacement); + } + } catch (err) { + // Manifest absent → ship the unsubstituted template + warn loudly. + // Local dev path: developer has not run scripts/build-image-manifest.mjs. + // CI path: build runs BEFORE download-artifact step → tarball-contents.test + // catches the unsubstituted YAML and fails the workflow. + console.warn( + `[tsup] dist/image-manifest.json not found — shipping unsubstituted ` + + `townhouse-hs.yml. This is fine for local dev but invalid for npm publish.` + ); + } + await writeFile(join(composeDistDir, 'townhouse-hs.yml'), hsRendered, 'utf-8'); + }, + }); + ``` + - [x] 5.2 Use Node's built-in `fs/promises` rather than adding `fs-extra` or other deps — keeps the build surface minimal. + - [x] 5.3 If the dev prefers a separate post-build script (e.g., `scripts/render-compose-template.mjs`) over an inline `onSuccess`, that is acceptable. The script approach is easier to unit-test (the inline hook can only be exercised end-to-end via `pnpm build`). Document the choice in Dev Notes. + - [x] 5.4 Run `pnpm --filter @toon-protocol/townhouse build` locally with NO `dist/image-manifest.json` present. Confirm the build succeeds with the warning AND that `dist/compose/townhouse-{hs,dev}.yml` are produced. Then run `cp /tmp/45-1-artifact/image-manifest.json packages/townhouse/dist/image-manifest.json` (using the artifact pulled in Task 1.2), re-run `pnpm build`, and confirm `dist/compose/townhouse-hs.yml` now contains five `@sha256:` substitutions and zero `${TOON_*_DIGEST}` placeholders. + - [x] 5.5 The `clean: true` flag in tsup config DELETES `dist/` at build start. The CI workflow MUST place `image-manifest.json` AFTER `pnpm build` runs, NOT before. Verify the existing workflow order at `.github/workflows/publish-townhouse-images.yml` — the `npm-publish` job's step order should be: `pnpm install` → `pnpm --filter ... build` → `actions/download-artifact ... → dist/image-manifest.json` → re-run the build hook OR re-render the compose template. **Critical:** if the publish workflow runs `pnpm build` AFTER download-artifact, the build's `clean: true` flag will WIPE the manifest. Two safe orderings: + - **Option A (preferred):** download-artifact AFTER build; THEN re-run the render step explicitly (e.g., `node scripts/render-compose-template.mjs` separately) — this requires extracting the render logic from `onSuccess` into a stand-alone script callable from CI. + - **Option B:** disable `clean: true` and accept dirty dist trees (rejected — too easy to miss stale files). + - **Option C:** `pnpm build` AFTER download-artifact AND set `clean: false` for the publish-time build, OR move `image-manifest.json` to a non-`dist/` location during build then move back after. + + Task 7 below documents the recommended fix in the workflow file. + +- [x] **Task 6: Update `packages/townhouse/src/constants.ts` `DEFAULT_CONNECTOR_IMAGE` to digest form** (AC: #9) + - [x] 6.1 Read the connector entry from `/tmp/45-1-artifact/image-manifest.json` (Task 1.2): `images.connector.digest`. The value is `sha256:` (not a tag reference). + - [x] 6.2 Update `packages/townhouse/src/constants.ts:21`: + ```typescript + /** + * Default connector Docker image — digest-pinned per CONNECTOR_RELEASE_CONTRACT.md. + * + * To bump: capture a new digest by running the Story 45.1 publish workflow + * against the desired connector tag, copy the resulting image-manifest.json + * connector entry's digest, and update this constant + the contract canary + * fixture. See packages/sdk/CONNECTOR_RELEASE_CONTRACT.md for the full bump + * checklist + breaking-changes history. + */ + export const DEFAULT_CONNECTOR_IMAGE = 'ghcr.io/toon-protocol/connector@sha256:'; + ``` + Replace `` with the actual hex string from the artifact. + - [x] 6.3 Run `pnpm --filter @toon-protocol/townhouse test:canary` to confirm `connector-image-contract.test.ts` still passes against the digest-form constant. If the test parses the constant via tag-form regex (e.g., `/connector:(\d+\.\d+\.\d+)/`), update the test to also accept digest form (`/connector(:[\w.-]+|@sha256:[a-f0-9]+)/`) — see Task 8.4. + - [x] 6.4 Confirm `DEFAULT_CONNECTOR_IMAGE` is consumed by `packages/townhouse/src/docker/orchestrator.ts` for digest pulls. Search for the constant: `grep -rn DEFAULT_CONNECTOR_IMAGE packages/townhouse/src/`. Every usage site must accept digest form (most should — `docker pull ` works for both forms — but anywhere that `.split(':')` is applied to extract a version will break). + - [x] 6.5 The connector tag is no longer captured by the constant — only the digest is. If any code wants the human-readable tag (e.g., for log output: "running connector v3.5.0"), it should read from `image-manifest.json` directly: `manifest.images.connector.tag`. Document this transition in the constant's docstring. + +- [x] **Task 7: Update `.github/workflows/publish-townhouse-images.yml` for live publish** (AC: #5, #10, #11) + - [x] 7.1 Locate the `npm-publish` job in the workflow. Walk through its current step order: + - `actions/checkout` → `pnpm/action-setup` → `actions/setup-node` → `pnpm install --frozen-lockfile` → `pnpm --filter ... build` → `actions/download-artifact (image-manifest.json → dist/)` → `pnpm publish --dry-run` + - [x] 7.2 Per Task 5.5 Option A: add a step BETWEEN `actions/download-artifact` and `pnpm publish` that re-runs the compose render now that `dist/image-manifest.json` is present. Either invoke the inline `onSuccess` hook again (rerun `pnpm build` is fine — but `clean: true` would wipe the manifest, so toggle the flag or use a separate render script) OR (preferred) extract the render logic to `scripts/render-compose-template.mjs` and invoke it directly: + ```yaml + - name: Render HS compose template against pinned digests + run: node scripts/render-compose-template.mjs + working-directory: ${{ github.workspace }} + ``` + - [x] 7.3 Add a tarball-content verification step BEFORE `pnpm publish` (per AC #11): + ```yaml + - name: Verify tarball contents + run: | + pnpm --filter @toon-protocol/townhouse pack --pack-destination /tmp/pack-out/ + TGZ=$(ls /tmp/pack-out/toon-protocol-townhouse-*.tgz | head -1) + tar -tzf "$TGZ" > /tmp/pack-listing.txt + for path in package/dist/compose/townhouse-hs.yml package/dist/compose/townhouse-dev.yml package/dist/image-manifest.json; do + grep -qF "$path" /tmp/pack-listing.txt || { echo "MISSING: $path"; exit 1; } + done + # No unsubstituted placeholders in the rendered HS template + tar -xzf "$TGZ" -C /tmp/extracted/ + if grep -E '\$\{TOON_[A-Z_]+_DIGEST\}' /tmp/extracted/package/dist/compose/townhouse-hs.yml; then + echo "FAIL: unsubstituted placeholders in tarball"; exit 1 + fi + ``` + - [x] 7.4 Remove the `--dry-run` flag from the `pnpm publish` step. Keep `--access public --no-git-checks --tag latest`. Confirm `NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}` env is set on the step. + - [x] 7.5 Update the top-of-file workflow comment block to record the staged-delivery transition: "v0.1.0 (Story 45.1): images + signatures + manifest published; npm publish in dry-run pending Story 45.2. v0.1.0+ (Story 45.2): live npm publish flipped on; tarball ships compose templates + manifest." + - [x] 7.6 Smoke-test via `workflow_dispatch` (NOT a tag push — workflow_dispatch path is dry-run-only per AC #10). Confirm the manifest renders cleanly into the compose template, the tarball-content verification passes, and the npm-publish step in dispatch mode would not actually publish (the `if:` guard on the npm-publish job gates on `tag`-form refs). + +- [x] **Task 8: Author tests** (AC: #8) + - [x] 8.1 Author `packages/townhouse/src/compose-loader.test.ts` (vitest, `pnpm --filter @toon-protocol/townhouse test`). Test cases (every test must pass): + - `loadComposeTemplate('dev', { distDir: })` returns the dev template verbatim. + - `loadComposeTemplate('hs', { distDir: })` returns the HS template with five `@sha256:` substitutions when fixture contains a substituted file. + - `loadComposeTemplate('hs', { distDir: })` throws `ComposeLoaderError` with a message containing the missing path. + - `materializeComposeTemplate('hs', { distDir: , townhouseHome: })` writes both `compose/townhouse-hs.yml` AND `image-manifest.json` to the tmpdir at mode `0o600`. + - The created `/compose/` directory has mode `0o700`. + - `materializeComposeTemplate` is idempotent — calling twice produces identical output, mode `0o600`, no errors. + - `materializeComposeTemplate('hs')` throws when `dist/image-manifest.json` is absent (the manifest is required for HS mode per AC #6 docstring). + - `materializeComposeTemplate('dev')` does NOT throw when manifest is absent (dev mode tolerates absence). + - File mode after `materializeComposeTemplate('hs')` is `0o600` even if `process.umask()` is `0o022` at test start. + - [x] 8.2 Author `packages/townhouse/src/__integration__/compose-template-validity.test.ts` (vitest integration config, `pnpm --filter @toon-protocol/townhouse test:integration`). Test cases: + - When `DOCKER_AVAILABLE === '1'` (env-gated; default `'1'` if `which docker` resolves), invoke `docker compose -f config` as a subprocess via `execFileSync('docker', ['compose', '-f', renderedPath, 'config'])`. Assert exit code 0 and stdout contains every service name (`connector`, `townhouse-api`, `town`, `mill`, `dvm`). + - The rendered HS template's parsed YAML has every `services..image` value matching `/^ghcr\.io\/toon-protocol\/[a-z-]+@sha256:[a-f0-9]{64}$/`. + - No `services..build` directives appear (`grep -c 'build:'` against the rendered file returns 0 for the services section). + - Every `services..ports[]` entry that includes a host-side port is prefixed with `127.0.0.1:` (NFR9). + - Skipped (with a warning) when `DOCKER_AVAILABLE !== '1'` (Docker not installed in the test env). Document the env var in the test header. + - [x] 8.3 Author `packages/townhouse/src/__integration__/tarball-contents.test.ts`. Test cases: + - Run `pnpm pack --pack-destination ` (subprocess; `process.cwd()` set to the package dir). + - Untar the resulting `.tgz` (e.g., via `tar -xzf -C `). + - Assert `/package/dist/compose/townhouse-hs.yml` exists. + - Assert `/package/dist/compose/townhouse-dev.yml` exists. + - Assert `/package/dist/image-manifest.json` exists (skip this assertion when `dist/image-manifest.json` was absent at test start — local dev path). + - Read the tarball'd HS YAML and assert NO unsubstituted placeholders (`/\$\{TOON_[A-Z_]+_DIGEST\}/` matches 0 times). + - Read the tarball'd HS YAML and assert every `image:` line uses digest form (`@sha256:`). + - Skipped when env `SKIP_PACK_TEST === '1'` to allow developers running the full test suite without `dist/` rebuilt. + - [x] 8.4 Update (do NOT replace) `packages/townhouse/src/__integration__/connector-image-contract.test.ts` to accept digest-form `DEFAULT_CONNECTOR_IMAGE`. If the test currently does: + ```typescript + const [, version] = DEFAULT_CONNECTOR_IMAGE.split(':'); + ``` + that will break under digest form (`split(':')` returns the digest hex, not the version). Replace with: + ```typescript + function parseConnectorImage(ref: string): { name: string; tag?: string; digest?: string } { + const digestMatch = ref.match(/^(.+)@(sha256:[a-f0-9]+)$/); + if (digestMatch) return { name: digestMatch[1], digest: digestMatch[2] }; + const tagMatch = ref.match(/^(.+):([^:]+)$/); + if (tagMatch) return { name: tagMatch[1], tag: tagMatch[2] }; + throw new Error(`unparseable image ref: ${ref}`); + } + ``` + And update assertions to read from the parsed object. Run `pnpm --filter @toon-protocol/townhouse test:canary` to confirm green. + - [x] 8.5 Add fixture data under `packages/townhouse/src/__tests__/fixtures/compose-loader/` containing a synthetic `image-manifest.json` (5 entries with valid `sha256:`-prefixed digests) and pre-rendered HS + dev YAML files. Tests in 8.1 reference these fixtures via `distDir` option. + +- [x] **Task 9: Documentation updates** (AC: #1, #6, #9) + - [x] 9.1 Update `packages/townhouse/README.md`: + - Add a "Compose Templates" section describing the two profiles (dev, hs), where they live in the package, and the `loadComposeTemplate` / `materializeComposeTemplate` API. + - Document the `image-manifest.json` shape (link to `scripts/build-image-manifest.mjs:50-66` for the schema). + - Update the existing dev-stack section to point at `packages/townhouse/compose/townhouse-dev.yml` as the canonical source (with a backward-compat note that `docker-compose-townhouse-dev.yml` at the root is preserved for the existing CI). + - [x] 9.2 Update `CLAUDE.md` "Where to Find Things" table — add rows: + - `Townhouse npm-tarball compose templates` → `packages/townhouse/compose/` + - `Compose loader + materializer API` → `packages/townhouse/src/compose-loader.ts` + - `Image-manifest digest registry (per release)` → `packages/townhouse/dist/image-manifest.json` (CI-produced; not committed) + - [x] 9.3 Update `packages/sdk/CONNECTOR_RELEASE_CONTRACT.md` (and the connector-side mirror at `connector/CONNECTOR_RELEASE_CONTRACT.md` — the content is identical between the two repos per Story 44.4) to reference Story 45.2 as the consumer that flipped `DEFAULT_CONNECTOR_IMAGE` from tag form to digest form. Add a one-liner to the "Townhouse pins by digest in image-manifest.json" rule: "Implementation: Story 45.2 (`packages/townhouse/src/constants.ts:21`)." + +- [x] **Task 10: Smoke test the full path locally** (AC: all) + - [x] 10.1 From the repo root, run the full sequence: + ```bash + # 1. Pull the 45.1 manifest artifact (Task 1.2) + gh run download 25603167091 --repo toon-protocol/town --name image-manifest -D /tmp/45-1-artifact/ + + # 2. Build the package + pnpm --filter @toon-protocol/townhouse build + + # 3. Drop the manifest into dist/ (mimics the CI download-artifact step) + cp /tmp/45-1-artifact/image-manifest.json packages/townhouse/dist/image-manifest.json + + # 4. Re-render the compose template (Task 7.2 step) + node scripts/render-compose-template.mjs + + # 5. Pack + inspect + pnpm --filter @toon-protocol/townhouse pack --pack-destination /tmp/pack-out/ + TGZ=$(ls /tmp/pack-out/toon-protocol-townhouse-*.tgz | head -1) + tar -tzf "$TGZ" | grep -E 'package/dist/(compose/|image-manifest\.json)' + # Expected: 3 lines — package/dist/compose/townhouse-hs.yml, .../townhouse-dev.yml, .../image-manifest.json + + # 6. Verify rendered HS template has no placeholders + tar -xzf "$TGZ" -C /tmp/extracted/ + grep -c 'sha256:' /tmp/extracted/package/dist/compose/townhouse-hs.yml + # Expected: 5 (one per service) + grep -c '\${TOON_' /tmp/extracted/package/dist/compose/townhouse-hs.yml + # Expected: 0 + + # 7. Verify it parses as docker compose (requires Docker) + docker compose -f /tmp/extracted/package/dist/compose/townhouse-hs.yml config >/dev/null + # Expected: exit 0 + ``` + - [x] 10.2 Run the materialize path against a tmpdir: + ```bash + cat <<'EOF' | node --input-type=module + import { materializeComposeTemplate } from './packages/townhouse/dist/index.js'; + import { mkdtempSync, statSync, readFileSync } from 'node:fs'; + import { tmpdir } from 'node:os'; + import { join } from 'node:path'; + const home = mkdtempSync(join(tmpdir(), 'townhouse-')); + const { composePath, manifestPath } = materializeComposeTemplate('hs', { townhouseHome: home }); + console.log({ + composePath, + composeMode: (statSync(composePath).mode & 0o777).toString(8), + manifestPath, + manifestMode: (statSync(manifestPath).mode & 0o777).toString(8), + }); + EOF + ``` + Expected output: both modes are `'600'` (octal). + - [x] 10.3 Run all test suites: + ```bash + pnpm --filter @toon-protocol/townhouse test + pnpm --filter @toon-protocol/townhouse test:integration + pnpm --filter @toon-protocol/townhouse test:canary + ``` + All green. + +- [ ] **Task 11: Open PR + close out** (AC: #12) + - [ ] 11.1 Branch from `chore/45-1-close-out` (or current main) as `feat/45-2-embed-compose-templates`. Open PR against `main` via `gh pr create` with summary linking to the Story 45.2 file and listing the touched paths. + - [ ] 11.2 PR body includes: tarball-content verification output, the rendered HS template (full file inline as a code block), the `docker compose config` output, the `connector-image-contract.test.ts` green output, and a confirmation that `--dry-run` was removed from the publish workflow. + - [ ] 11.3 After PR merges and a `v0.1.0-rc2` (or whatever the next test tag is — coordinate with the user) tag-push runs the workflow successfully AND `npm view @toon-protocol/townhouse@ dist.tarball` resolves AND `npm pack @toon-protocol/townhouse@ --dry-run` shows the compose templates + manifest in the file list: + - Update `_bmad-output/implementation-artifacts/sprint-status.yaml`: `45-2-embed-compose-templates-and-image-manifest-in-npm-tarball: backlog → done` + - Bump `last_updated` to merge date. + - Add the `# done: ...` comment with the workflow run URL and PR number(s), mirroring the 44.4 / 45.1 close-out style. + - [ ] 11.4 Story Status → review → done. + +## Dev Notes + +### Cross-Repo Boundary + +This story is **town-only**. No connector-side code changes. The only connector touchpoint is consumption — the loader resolves digests from `image-manifest.json` which contains the connector's pinned digest captured by Story 45.1's CI. If the dev finds themselves opening a PR in `toon-protocol/connector`, stop — they are outside the story. + +Files this story touches in `toon-protocol/town`: + +- `packages/townhouse/compose/townhouse-hs.yml` (NEW — Task 2) +- `packages/townhouse/compose/townhouse-dev.yml` (NEW — Task 3, copied from root) +- `packages/townhouse/src/compose-loader.ts` (NEW — Task 4) +- `packages/townhouse/src/index.ts` (MODIFY — add public exports for compose-loader) +- `packages/townhouse/src/constants.ts` (MODIFY — `DEFAULT_CONNECTOR_IMAGE` tag → digest) +- `packages/townhouse/tsup.config.ts` (MODIFY — add `onSuccess` hook OR equivalent) +- `scripts/render-compose-template.mjs` (NEW — extracted from `onSuccess`, callable by CI; optional if dev keeps inline `onSuccess`) +- `packages/townhouse/src/compose-loader.test.ts` (NEW — Task 8.1) +- `packages/townhouse/src/__integration__/compose-template-validity.test.ts` (NEW — Task 8.2) +- `packages/townhouse/src/__integration__/tarball-contents.test.ts` (NEW — Task 8.3) +- `packages/townhouse/src/__integration__/connector-image-contract.test.ts` (MODIFY — accept digest form) +- `packages/townhouse/src/__tests__/fixtures/compose-loader/` (NEW — fixture dir for unit tests) +- `packages/townhouse/README.md` (MODIFY — Task 9.1) +- `CLAUDE.md` (MODIFY — Task 9.2 "Where to Find Things" rows) +- `packages/sdk/CONNECTOR_RELEASE_CONTRACT.md` (MODIFY — Task 9.3 implementation reference) +- `.github/workflows/publish-townhouse-images.yml` (MODIFY — Task 7, remove `--dry-run`, add render + verify steps) +- `_bmad-output/implementation-artifacts/sprint-status.yaml` (MODIFY — Task 11.3) +- This story file (Task 11.4) + +Files this story does **NOT** touch (scope guards): + +- `docker-compose-townhouse-hs.yml` (root) — legacy operator-facing compose, used by the existing operator workflow that pre-dates `townhouse hs up`. Stays for backward compatibility. Story 45.4 may retire it once `townhouse hs up` is the canonical entry point. +- `docker-compose-townhouse-dev.yml` (root) — used by `scripts/townhouse-dev-infra.sh`. Stays for the existing contributor dev loop. The package-local copy at `packages/townhouse/compose/townhouse-dev.yml` is a parallel source of truth; a follow-up story can route `townhouse-dev-infra.sh` to read from the package-local copy. +- `scripts/build-image-manifest.mjs` — Story 45.1 deliverable. Schema is frozen at v1; do not modify. +- `scripts/build-image-manifest.test.ts` — Story 45.1 test; do not modify. +- `docker/Dockerfile.townhouse-api` — Story 45.1 deliverable; the new HS template references the resulting image but does not modify the Dockerfile. +- `docker/Dockerfile.town`, `docker/Dockerfile.mill`, `docker/Dockerfile.dvm` — pre-existing; the workflow consumes them as-is. +- `packages/townhouse/src/docker/orchestrator.ts` — DockerOrchestrator changes are Story 45.3 territory (the `profile: 'dev' | 'hs'` parameter). This story's `loadComposeTemplate` API is what 45.3 will call from inside the orchestrator. +- `packages/townhouse/src/cli.ts` — the `townhouse hs up` subcommand is Story 45.4 territory; this story's loader is what the subcommand will call. +- `packages/townhouse/src/api/` — host API changes are Story 45.4 territory. +- `packages/townhouse/src/wallet/` — HD wallet code is Story 21.4 / 45.4 territory. +- `_bmad-output/planning-artifacts/epics-townhouse-hs-v1.md` — planning doc; do NOT modify story ACs while implementing them. + +### Why Substitute at Build Time, Not Runtime + +The build hook (Task 5) substitutes digest placeholders into the HS template ONCE per package build. Two alternatives were considered: + +- **A — Build-time substitution (chosen).** `tsup onSuccess` runs the substitution; what ships in the tarball is fully resolved YAML. +- **B — Runtime substitution.** Loader reads the unsubstituted template + `image-manifest.json` at runtime and substitutes on every `loadComposeTemplate('hs')` call. + +A wins on three axes: + +1. **Single point of truth.** The shipped YAML is the authoritative artifact; verifying "what does v0.1.0 install" requires only `npm view @toon-protocol/townhouse@0.1.0 dist.tarball` + `tar -xzf`. No mental "you have to mentally apply substitution" overhead. +2. **Failure mode is local to CI.** A missing manifest at build time is a CI bug; a missing manifest at runtime would be a user-facing bug. Better to fail in CI. +3. **No bundle-size hit.** No JSON parsing in the loader (B would require parsing manifest on every call OR caching it — both are extra code). + +The cost: the tarball-content verification step (Task 7.3) is more critical because if substitution fails silently, the bug isn't caught until an operator runs `docker compose -f` and gets "image ${TOON_TOWN_DIGEST} not found." The verification step's `grep -E '\$\{TOON_[A-Z_]+_DIGEST\}'` against the tarball'd YAML eliminates that risk. + +### Why `materialize` Writes Both Files at `0o600` + +Per NFR8 (planning doc §"Security & Privacy"), every operator-secret file gets mode `0o600`: + +> NFR8: All operator-secret files SHALL be written with mode `0o600`: `nodes.yaml`, `earnings-snapshots.jsonl`, `wallet.enc`, `telemetry.json`, `host.json` + +The compose YAML and image-manifest.json aren't on the explicit NFR8 list, but both contain operator-relevant supply-chain identity: + +- The compose YAML embeds environment variables that Story 45.4+ will populate with private keys (settlement keys, wallet pubkeys, etc.). A leaked compose file at `0o644` would reveal those keys to any local-user process. +- The image-manifest.json pins the supply chain. Tampering with this file is exactly what an attacker would do to swap one of the four townhouse images for a malicious replica that the `docker pull` step would then fetch. Defending against tampering requires preventing other-user-readable+writable access. + +The implementation MUST `chmodSync(path, 0o600)` after `writeFileSync` because the `mode` option to `writeFileSync` is masked by `process.umask()` — on default systems with `umask 0o022`, passing `mode: 0o600` results in `0o600 & ~0o022 = 0o600` (no effect on this particular value, but the umask interaction is filesystem-implementation-dependent and burns developers regularly). The `chmodSync` is the load-bearing call. + +The parent dir `~/.townhouse/` and `~/.townhouse/compose/` get `0o700` (owner-only directory traversal). `mkdir({ mode: 0o700 })` honors the mode arg only on creation; existing dirs need `chmodSync` to enforce. + +### Why `townhouse-api` Service Lives in the HS Template (Even Though 45.4 Owns Its Boot) + +AC #3 adds the `townhouse-api` service to the HS template, but Story 45.4 (`townhouse hs up`) is what actually starts it during operator-facing apex boot. Why does it live in the compose YAML rather than being launched as a separate `docker run` from the CLI? + +Two reasons: + +1. **Composability.** Story 45.3's `DockerOrchestrator.start({ profile: 'hs' })` invokes `docker compose --profile up` against the rendered template. Having `townhouse-api` in the same compose file means lifecycle management (start/stop/health/restart) is unified across all townhouse services. Splitting `townhouse-api` out into a separate `docker run` invocation creates a parallel lifecycle model the orchestrator would have to track. +2. **Network isolation.** All townhouse services share a Docker network (e.g., `townhouse-hs-net`). The connector resolves child node hostnames via Docker DNS. The `townhouse-api` ALSO needs to reach the connector via that same network (to call `/admin/hs-hostname`, `/admin/peers`, `/admin/earnings.json`). A separate `docker run` would need to manually attach to the network — error-prone. + +The `townhouse-api` service entry in `townhouse-hs.yml` MUST mount `/var/run/docker.sock` RW (per planning doc §4 anchor "host-side townhouse-api owns dockerode and runs as the host user" — translated for HS-mode: townhouse-api is now containerized but still owns the socket). The connector container in the same template MUST NOT mount the socket (NFR7). This separation is load-bearing: the connector remains a generic ILP router with no Docker awareness; only the townhouse-api orchestrates siblings. + +### Why Two Compose Files (HS + Dev) + +The plan splits operator-facing (HS) and contributor-facing (dev) concerns: + +- **HS profile** is what operators run via `townhouse hs up`. Pinned to digest-form GHCR images. Deterministic across operator machines. No local builds, no source repo clone. NFR1 (5-min first-boot) depends on `docker pull` being idempotent and image content being identical to what TOON published. +- **Dev profile** is what contributors run via `pnpm --filter @toon-protocol/townhouse-web dev:docker` (per CLAUDE.md). Uses local `toon:*` image tags built by `pnpm build` + `docker compose build`. Not deterministic across machines (each contributor builds fresh). Useful for "hack on the dashboard, see changes immediately." + +Shipping BOTH templates in the npm tarball seems redundant (operators don't need the dev template), but the cost is low (~3 KB extra) and the consistency is helpful: the `loadComposeTemplate('dev')` call works in both contributor environments AND in the published-package environment, which simplifies test infrastructure (e.g., `townhouse-test-infra.sh` can use `loadComposeTemplate` from the published-package path). + +If a future story decides shipping the dev template is genuinely wasteful, it can be moved to `packages/townhouse/dev-compose/` (excluded from `files`). For Story 45.2, ship both — matches AC #1 verbatim. + +### Architecture Compliance + +- **NFR8 (operator-secret file mode `0o600`):** Applies to written outputs at `~/.townhouse/compose/townhouse-hs.yml` AND `~/.townhouse/image-manifest.json`. Enforced via Task 4.1's `chmodSync` calls. +- **NFR9 (host ports bind to 127.0.0.1 only):** Every `services..ports[]` entry in the HS template uses the `127.0.0.1:` prefix. Enforced via Task 8.2's regex grep. +- **NFR15 (Node.js >=20, TypeScript ^5.3, ESM-only):** compose-loader.ts is pure ESM (`fileURLToPath(import.meta.url)` for path resolution; no `require()`, no CJS). The build hook uses `node:fs/promises` (Node 14+ stable; safe at Node 20). +- **NFR17 (pre-publish quality gate):** This story produces gate check #3 (image-contract test green at pinned digest — flips from tag-form to digest-form check) AND removes the dry-run gate (gate check #6 — cosign verify — already enforced by Story 45.1). +- **D44-013 (cross-repo release contract):** `CONNECTOR_RELEASE_CONTRACT.md` clause "Townhouse pins by digest in `image-manifest.json`, bumps deliberately on each minor" is now backed by code: the constant in `constants.ts:21` is digest form, and bumping requires running the Story 45.1 workflow + updating the constant. The contract becomes verifiable rather than aspirational. +- **OWASP A03 (injection):** `materializeComposeTemplate` writes user-home-relative paths via `path.join(homedir(), '.townhouse', ...)` — no shell-out, no string concatenation. Safe. +- **OWASP A08 (CI integrity):** The publish workflow's tarball-content verification (Task 7.3) prevents supply-chain tampering between `pnpm build` and `pnpm publish`. + +### Critical Implementation Patterns + +- **Don't parse the YAML in the loader.** The loader treats compose YAML as an opaque string. Parsing would couple the loader to the compose schema; the moment Docker Compose adds a new top-level key, the loader breaks. Let `docker compose config` validate the rendered output (Task 8.2 integration test) and treat the loader as a dumb file copier. +- **Substitution is `replaceAll`, not regex.** Use `string.replaceAll('${TOON_TOWN_DIGEST}', '@sha256:...')`, NOT `string.replace(/\$\{TOON_TOWN_DIGEST\}/g, ...)`. The literal-string form is faster, safer (no regex injection), and reads more clearly. ESLint may warn about `replaceAll` if `target: es2020` — bump to `es2021` or `es2022` if needed (the rest of the workspace already uses `es2022` per `tsconfig.json`). +- **Verify chmod after write.** The `mode` option to `writeFileSync` is filesystem-dependent and umask-masked. Always follow with explicit `chmodSync(path, 0o600)`. Test on WSL2 (Jonathan's primary dev machine — see system context) where umask interactions have bitten before. +- **`tsup clean: true` deletes `dist/image-manifest.json`.** The CI workflow MUST place the manifest AFTER `pnpm build` runs. If you keep `clean: true`, the build sequence is: `pnpm install` → `pnpm build` (clean + emit `dist/index.js` etc.) → `download-artifact` (drops manifest into `dist/`) → `node scripts/render-compose-template.mjs` (re-renders compose with manifest now present). If you change to `clean: false`, you accept dirty dist trees that may include stale files from previous builds. +- **The 45.1 workflow's `--dry-run` flip is NOT a separate PR.** AC #10 says this story removes the flag. Doing so is part of the Story 45.2 PR — not a follow-up. The smoke-test path (Task 11.3) requires a `v0.1.0-rc2` (or similar) tag-push to validate the live publish; coordinate with the user on tag naming. +- **Failure mode "image-manifest.json absent in CI" must be loud, not silent.** Task 7.3's tarball-content verification step explicitly greps for `image-manifest.json` AND for unsubstituted placeholders. If either check fails, the workflow fails BEFORE `pnpm publish` runs — preventing a broken tarball from reaching the registry. +- **`cp -R` vs `fs.cp` recursive copy:** Node's `fs.cp({ recursive: true })` was added in v16.7 and is stable at Node 20. Use it via the Promises API: + ```typescript + await cp('compose', 'dist/compose', { recursive: true }); + ``` + Don't shell out to `cp -r` from the build script — keeps the build cross-platform (Windows contributors don't have `cp`). +- **Idempotent materialize is load-bearing.** Story 45.4 AC says re-running `townhouse hs up` against an already-running apex MUST not re-pull or re-create. The materialize call happens on every `up` invocation (it's how the CLI ensures the file exists). Idempotency means: writing the same content twice is fine; mode is `0o600` after both calls; no spurious "permission changed" log lines. +- **Don't ship the unsubstituted template.** AC #2 says "every service entry uses `image: ghcr.io/toon-protocol/@sha256:` form." If `dist/compose/townhouse-hs.yml` in the tarball contains `${TOON_TOWN_DIGEST}` placeholders, that's a publish bug — Task 7.3's verification step is what catches it. Treat that step's failure as blocking, not a warning. + +### Sequencing Within Epic 45 + +``` + 45.1 (DONE) ──→ 45.2 (this) ──→ 45.4 ──→ 46.x + │ │ │ + └─── 45.3 ─────┴──────────────┘ + (parallel) (consumer) +``` + +- **45.1 produces:** `image-manifest.json` (CI artifact; not committed) + four signed multi-arch GHCR images + Dockerfile.townhouse-api +- **45.2 (this) consumes:** `image-manifest.json` → embeds resolved compose template in npm tarball → flips `DEFAULT_CONNECTOR_IMAGE` to digest form → exports `loadComposeTemplate` / `materializeComposeTemplate` API +- **45.3 (parallel):** `DockerOrchestrator` profile param refactor — calls `loadComposeTemplate(profile)` from this story's module +- **45.4 consumes:** the materialized compose at `~/.townhouse/compose/townhouse-hs.yml` + the published images via the digest pins + the cosign-verifiable digest +- **Critical path:** 44.1 (DONE) → 45.1 (DONE) → 45.2 (this) → 45.4 → 46.1 (next critical) + +### Why This Story Is Sized M + +Per planning doc §3 row 2: "TH-21.17.2 — feat(townhouse): embed compose templates + image-manifest — Deps: 1 — Size: M — Critical Path: ★". Sources of mid-tier (not L) sizing: + +1. **No new infrastructure.** The build pipeline (tsup), test runner (vitest), CI workflow (Story 45.1 already authored) all exist. This story extends them with content, not structure. +2. **Loader API surface is small.** Two functions + one error class + one type alias = ~50 LOC of TypeScript. Most of the implementation cost is testing (8.1-8.5) — but the test surface is well-defined. +3. **Compose template authoring is mechanical.** The shape is dictated by the existing root-level `docker-compose-townhouse-hs.yml` + Story 45.4 AC #1 (which services start). The dev's job is to copy structure, swap to digest form, add `townhouse-api`, drop the dev-time services. +4. **`--dry-run` flip is a one-line change.** The infra was authored in Story 45.1. Removing the flag is a workflow file edit. + +The mid-tier risk is the build-pipeline ordering (Task 5.5 — `clean: true` + `download-artifact` interaction). If the dev fights with that ordering for >1 day, escalate to Murat for a second opinion on the ordering Option A/B/C. + +### Latest tech (verified 2026-05-09) + +- **`tsup` 8.x `onSuccess`:** Tsup's `onSuccess` accepts both string commands AND async functions. Async function form is the right choice — it's typed, debuggable in IDE, and runs in-process. String form spawns a subprocess. +- **Node.js `fs/promises` `cp({ recursive: true })`:** Stable at Node 18+. Cross-platform. Honors `mode` option per file. Faster than spawning `cp -r` (no shell startup). +- **Node.js `parseArgs` from `node:util`:** Used by `scripts/build-image-manifest.mjs` (Story 45.1) and the optional `scripts/render-compose-template.mjs` here. Stable at Node 20. No need to add `commander` or `yargs`. +- **`docker compose config` (subcommand) vs `docker-compose config` (legacy v1 binary):** v2 is the only supported form — don't write the v1 hyphenated form. v2 ships with Docker Desktop ≥4.0 and most Linux Docker installs ≥20.10. +- **`pnpm pack --pack-destination`:** Stable at pnpm 8.x. Outputs the `.tgz` to the directory without polluting `cwd`. The `--pack-destination` flag is what makes the tarball-content verification step (Task 7.3) work cleanly. +- **YAML 1.2 placeholder strings:** `${...}` inside an unquoted YAML scalar is a literal string (no environment variable expansion in the YAML spec — that's a `docker-compose`-specific feature applied AFTER YAML parse). The unsubstituted template `image: ghcr.io/toon-protocol/town${TOON_TOWN_DIGEST}` parses to the string `'ghcr.io/toon-protocol/town${TOON_TOWN_DIGEST}'`. Docker Compose will reject it as "image not found" but the YAML parse succeeds, which is what enables the "ship unsubstituted in dev mode, fail at runtime" graceful-degradation path. +- **`@sha256:` ref form vs `:tag` ref form:** Docker engine accepts both for `docker pull`. `image: @` AND `image: :` are both valid in compose YAML. Mixing the two within a single service entry (e.g., `image: foo:v1@sha256:abc`) is not standard and may be rejected by some Compose versions. Stick to digest-only form for the HS template. + +### What This Story Does NOT Do (scope guard) + +- **Does NOT delete the root-level `docker-compose-townhouse-hs.yml` or `docker-compose-townhouse-dev.yml`.** Both stay. Retirement is deferred to a follow-up story (post-Epic 45 or Story 45.4 if it lands cleanly). +- **Does NOT modify `scripts/build-image-manifest.mjs` or its tests.** Story 45.1's deliverable; schema is frozen at v1. +- **Does NOT modify `scripts/townhouse-dev-infra.sh` to read from `packages/townhouse/compose/townhouse-dev.yml`.** The script continues to use the root-level path. A follow-up story can route it through the package-local path. +- **Does NOT modify `DockerOrchestrator` to call `loadComposeTemplate`.** That is Story 45.3's job. This story exports the API surface; 45.3 wires it up. +- **Does NOT modify `packages/townhouse/src/cli.ts` to add the `hs up` subcommand.** Story 45.4's job. +- **Does NOT add a SBOM step to the publish workflow.** Out of scope; Story 45.1 deferred it. +- **Does NOT introduce a v2 image-manifest schema.** v1 is sufficient. If Story 45.4 or beyond needs additional fields (e.g., per-arch digest indexes), bump to v2 and version the schema. +- **Does NOT add zod runtime validation of `image-manifest.json` in the loader or build hook.** The build script (Story 45.1) already validates with zod before writing. Re-validating at consumer time would duplicate the contract; trust the build artifact. +- **Does NOT add anon-sidecar containers to the HS template.** The connector's embedded anon support (connector v3.5.x via Story 44.1) handles HS publishing in-process. +- **Does NOT add faucet/anvil/solana services to the HS template.** Those are dev-stack concerns. The HS template is operator-facing only (apex + lazy-provisioned children). +- **Does NOT ship the unrendered template in the tarball.** The rendered HS YAML is what ships; the source template at `packages/townhouse/compose/townhouse-hs.yml` is gitted but not in the tarball (it's a build input, not a build output). +- **Does NOT bump townhouse package version.** The version stays at `0.1.0` (or whatever the current version is). Version bumps happen with the next `v*` tag push that triggers the publish workflow. +- **Does NOT touch any code outside `packages/townhouse/`, `scripts/`, `_bmad-output/`, `.github/workflows/publish-townhouse-images.yml`, `CLAUDE.md`, or `packages/sdk/CONNECTOR_RELEASE_CONTRACT.md`.** If the dev finds themselves editing `packages/town/`, `packages/mill/`, `packages/dvm/`, or any other package — stop, that's outside scope. + +## References + +### From `_bmad-output/planning-artifacts/epics-townhouse-hs-v1.md` + +- [Source: epics-townhouse-hs-v1.md#L516-L546] — Story 45.2 ACs (canonical) +- [Source: epics-townhouse-hs-v1.md#L38] — FR3 (embed compose templates in npm tarball) +- [Source: epics-townhouse-hs-v1.md#L104-L105] — NFR8 (operator-secret file mode `0o600`) +- [Source: epics-townhouse-hs-v1.md#L106] — NFR9 (host ports bind to 127.0.0.1 only) +- [Source: epics-townhouse-hs-v1.md#L122] — NFR17 (pre-publish quality gate) +- [Source: epics-townhouse-hs-v1.md#L150] — Connector dep version (digest-pinned via image-manifest.json) +- [Source: epics-townhouse-hs-v1.md#L277-L284] — Epic 45 overview (One-Command Apex Install) +- [Source: epics-townhouse-hs-v1.md#L444-L468] — Story 44.4 (release contract — names this story's `image-manifest.json` consumption pattern) + +### From `_bmad-output/planning-artifacts/townhouse-hs-v1-plan-2026-05-07.md` + +- [Source: townhouse-hs-v1-plan-2026-05-07.md#L66] — TH-21.17.2 row in story list (sized M, critical path) +- [Source: townhouse-hs-v1-plan-2026-05-07.md#L80-L82] — Critical path declaration (1 → 2 → 5 → 6 → 13) +- [Source: townhouse-hs-v1-plan-2026-05-07.md#L94-L100] — Architecture Anchors (single-source-of-truth for earnings, no Docker socket in connector, host-side townhouse-api owns dockerode) +- [Source: townhouse-hs-v1-plan-2026-05-07.md#L232-L244] — Release contract + image-publish coordination (digest pinning, "townhouse must NOT republish") +- [Source: townhouse-hs-v1-plan-2026-05-07.md#L311-L317] — Pre-publish quality gates (6 checks) + +### From `_bmad-output/implementation-artifacts/` + +- [Source: 45-1-multi-arch-townhouse-image-publish-ci.md] — sibling story; produces `image-manifest.json` artifact this story consumes +- [Source: 45-1-multi-arch-townhouse-image-publish-ci.md#L27-L42] — Manifest schema (5 image entries, `{name, tag, digest}`) +- [Source: 45-1-multi-arch-townhouse-image-publish-ci.md#L97-L107] — Workflow npm-publish job step order (informs Task 7's surgery) +- [Source: 44-4-connector-release-contract-cross-repo-doc.md] — release contract doc (defines the digest-pinning discipline) + +### From this repo + +- [Source: scripts/build-image-manifest.mjs] — manifest schema (v1) + writer (Story 45.1) +- [Source: scripts/build-image-manifest.test.ts] — manifest writer tests (16 cases — DO NOT modify) +- [Source: docker-compose-townhouse-hs.yml] — root-level legacy HS compose; the new template at `packages/townhouse/compose/townhouse-hs.yml` derives from this structure +- [Source: docker-compose-townhouse-dev.yml] — root-level dev compose; the new template at `packages/townhouse/compose/townhouse-dev.yml` is a verbatim copy +- [Source: packages/townhouse/package.json] — `bin: ./dist/cli.js`, `files: ["dist"]`, ESM-only, Node ≥20 (no changes needed; existing config covers tarball inclusion) +- [Source: packages/townhouse/tsup.config.ts] — current build config (Task 5 modifies to add `onSuccess`) +- [Source: packages/townhouse/src/constants.ts:21] — `DEFAULT_CONNECTOR_IMAGE` (Task 6 flips to digest form) +- [Source: packages/townhouse/src/index.ts] — public API barrel (Task 4.3 adds compose-loader exports) +- [Source: packages/townhouse/src/__integration__/connector-image-contract.test.ts] — contract canary (Task 8.4 modifies to accept digest form) +- [Source: docker/Dockerfile.townhouse-api] — Story 45.1 deliverable; the new HS template references the resulting image +- [Source: scripts/townhouse-dev-infra.sh] — dev-infra orchestrator (uses root-level `docker-compose-townhouse-dev.yml`; package-local copy is a sibling, not a replacement) +- [Source: scripts/townhouse-test-infra.sh] — real-CLI E2E orchestrator (Story 22.5 + 21.16 sibling) +- [Source: packages/sdk/CONNECTOR_RELEASE_CONTRACT.md] — release contract (Task 9.3 adds implementation reference) +- [Source: CLAUDE.md] — § "Townhouse Dev Stack (28xxx)" + § "Where to Find Things" (Task 9.2 adds rows) + +### Latest tech references (verified 2026-05-09) + +- [tsup `onSuccess` docs](https://tsup.egoist.dev/#run-after-success) — async function form, runs after each build emit +- [Node.js `fs.cp` docs](https://nodejs.org/api/fs.html#fspromisescpsrc-dest-options) — `recursive: true` option, cross-platform +- [Node.js `fs.chmod` docs](https://nodejs.org/api/fs.html#fspromiseschmodpath-mode) — note umask interaction +- [Docker Compose `image:` field spec](https://docs.docker.com/compose/compose-file/05-services/#image) — accepts `:` AND `@` forms +- [pnpm `pack` docs](https://pnpm.io/cli/pack) — `--pack-destination` flag for verification testing +- [npm `files` field semantics](https://docs.npmjs.com/cli/v10/configuring-npm/package-json#files) — `dist` directory inclusion (already configured at `packages/townhouse/package.json:18-20`) +- [GitHub Actions `actions/download-artifact` docs](https://github.com/actions/download-artifact) — `path:` option for placement (Task 7.2 uses) + +## Verification + +After Task 11 PR merges AND a `v0.1.0-rc2` (or whatever follow-on tag is agreed) tag-push run completes: + +```bash +# 1. Tarball contains all three required artifacts (AC #1, #11) +npm view @toon-protocol/townhouse@ dist.tarball +# Expected: a tarball URL on registry.npmjs.org + +# Pull and inspect +npm pack @toon-protocol/townhouse@ --pack-destination /tmp/verify/ +TGZ=$(ls /tmp/verify/toon-protocol-townhouse-*.tgz | head -1) +tar -tzf "$TGZ" | grep -E 'package/dist/(compose/|image-manifest\.json)' +# Expected: 3 lines + +# 2. Compose templates have correct shape (AC #2, #3) +tar -xzf "$TGZ" -C /tmp/verify/extracted/ +grep -c '@sha256:' /tmp/verify/extracted/package/dist/compose/townhouse-hs.yml +# Expected: 5 (one per service) +grep -c '\${TOON_' /tmp/verify/extracted/package/dist/compose/townhouse-hs.yml +# Expected: 0 +grep -c 'build:' /tmp/verify/extracted/package/dist/compose/townhouse-hs.yml +# Expected: 0 +grep -E '127\.0\.0\.1:' /tmp/verify/extracted/package/dist/compose/townhouse-hs.yml | wc -l +# Expected: > 0; every host-side port binding present + +# Verify townhouse-api service exists +grep -A 5 '^ townhouse-api:' /tmp/verify/extracted/package/dist/compose/townhouse-hs.yml +# Expected: image: ghcr.io/toon-protocol/townhouse-api@sha256:... line + +# 3. docker compose config validates (AC #5) +docker compose -f /tmp/verify/extracted/package/dist/compose/townhouse-hs.yml config >/dev/null +# Expected: exit 0 + +# 4. image-manifest.json schema (AC #6) +jq -e '.schemaVersion == 1' /tmp/verify/extracted/package/dist/image-manifest.json +jq -e '.images | keys | sort == ["connector","dvm","mill","town","townhouse-api"]' /tmp/verify/extracted/package/dist/image-manifest.json +# Expected: both queries return true + +# 5. Loader API works (AC #4, #6, #7) +mkdir /tmp/verify/install && cd /tmp/verify/install +npm init -y +npm install @toon-protocol/townhouse@ +node --input-type=module -e " + import { loadComposeTemplate, materializeComposeTemplate } from '@toon-protocol/townhouse'; + import { mkdtempSync, statSync } from 'node:fs'; + import { tmpdir } from 'node:os'; + import { join } from 'node:path'; + const home = mkdtempSync(join(tmpdir(), 'verify-')); + const yaml = loadComposeTemplate('hs'); + console.log('hs yaml length:', yaml.length); + const { composePath, manifestPath } = materializeComposeTemplate('hs', { townhouseHome: home }); + console.log('compose mode:', (statSync(composePath).mode & 0o777).toString(8)); + console.log('manifest mode:', (statSync(manifestPath).mode & 0o777).toString(8)); +" +# Expected: yaml length > 0; both modes are '600' + +# 6. Constant flip (AC #9) +grep DEFAULT_CONNECTOR_IMAGE /tmp/verify/extracted/package/dist/index.js | head -2 +# Expected: digest form ('@sha256:...'), NOT tag form (':3.4.1') + +# 7. Workflow no longer dry-run (AC #10) +grep -E '\-\-dry-run' .github/workflows/publish-townhouse-images.yml +# Expected: no matches OR only inside comments + +# 8. Sprint-status update (AC #12) +grep -A1 "45-2-embed-compose-templates" /home/jonathan/Documents/town/_bmad-output/implementation-artifacts/sprint-status.yaml +# Expected: status reads "done", trailing # comment names workflow run URL + PR number +``` + +If any of these checks fail, the story is NOT done. Re-open. Do not flip sprint-status to `done`. + +## Dev Agent Record + +### Agent Model Used + +claude-sonnet-4-6 + +### Debug Log References + +- Task 5.5 resolution: Used Option A (standalone `scripts/render-compose-template.mjs` + CI step) to avoid tsup `clean: true` wiping the manifest. +- `process.umask()` is not settable in vitest worker threads — updated test to verify chmodSync enforces 0o600 without explicitly changing the umask (the test environment already uses the default 0o022 umask). +- Docker Compose v5.1.3 requires explicit `--profile` flags to include profile-restricted services in `config` output. Updated `compose-template-validity.test.ts` to pass `--profile town --profile mill --profile dvm`. +- `docker-compose-townhouse.yml` (root) updated to digest form to keep `package-structure.test.ts` green (that test asserts `connector.image === DEFAULT_CONNECTOR_IMAGE`). + +### Completion Notes List + +- Created `packages/townhouse/compose/` source directory with two templates: HS (digest placeholders, 5 services including new townhouse-api) and dev (verbatim copy from root with updated header comment). +- Authored `packages/townhouse/src/compose-loader.ts` implementing `loadComposeTemplate()`, `materializeComposeTemplate()`, and `ComposeLoaderError` per AC #6/#7 spec. Exports added to `src/index.ts`. +- Updated `packages/townhouse/tsup.config.ts` with `onSuccess` hook that copies dev template verbatim and renders HS template from manifest (graceful degradation when manifest absent locally). +- Authored `scripts/render-compose-template.mjs` as the authoritative CI render step (called in workflow AFTER `download-artifact` places manifest into `dist/`). +- Flipped `DEFAULT_CONNECTOR_IMAGE` from tag form (`connector:3.4.1`) to digest form (`connector@sha256:4a24ccb0...`); updated `docker-compose-townhouse.yml` to match; canary test passes. +- Updated `.github/workflows/publish-townhouse-images.yml`: added render step, tarball verification step, removed `--dry-run` from `pnpm publish`. +- Authored 12 unit tests in `compose-loader.test.ts` (all pass), 7 integration tests in `compose-template-validity.test.ts` (all pass), 5 integration tests in `tarball-contents.test.ts` (all pass). Updated `connector-image-contract.test.ts` to accept digest-form image refs. +- All pre-existing test failures (`earnings.test.ts` 7, `logs.test.ts` 4, `dev-stack-smoke.test.ts` 3) confirmed pre-existing (identical baseline failures before this story's changes). +- Smoke test verified: tarball contains 3 required artifacts, 0 unsubstituted placeholders, 5 digest-form image lines, `docker compose config` exits 0. + +### File List + +- `packages/townhouse/compose/townhouse-hs.yml` (NEW) +- `packages/townhouse/compose/townhouse-dev.yml` (NEW) +- `packages/townhouse/src/compose-loader.ts` (NEW) +- `packages/townhouse/src/compose-loader.test.ts` (NEW) +- `packages/townhouse/src/__integration__/compose-template-validity.test.ts` (NEW) +- `packages/townhouse/src/__integration__/tarball-contents.test.ts` (NEW) +- `packages/townhouse/src/__tests__/fixtures/compose-loader/image-manifest.json` (NEW) +- `packages/townhouse/src/__tests__/fixtures/compose-loader/compose/townhouse-hs.yml` (NEW) +- `packages/townhouse/src/__tests__/fixtures/compose-loader/compose/townhouse-dev.yml` (NEW) +- `scripts/render-compose-template.mjs` (NEW) +- `packages/townhouse/src/index.ts` (MODIFIED — added compose-loader exports) +- `packages/townhouse/src/constants.ts` (MODIFIED — DEFAULT_CONNECTOR_IMAGE tag → digest form) +- `packages/townhouse/src/__integration__/connector-image-contract.test.ts` (MODIFIED — added parseConnectorImage helper, updated alreadyPulled check) +- `packages/townhouse/tsup.config.ts` (MODIFIED — added onSuccess hook) +- `docker-compose-townhouse.yml` (MODIFIED — connector image tag → digest form to match DEFAULT_CONNECTOR_IMAGE) +- `.github/workflows/publish-townhouse-images.yml` (MODIFIED — added render step, tarball verification, removed --dry-run) +- `packages/townhouse/README.md` (MODIFIED — added Compose Templates section) +- `packages/sdk/CONNECTOR_RELEASE_CONTRACT.md` (MODIFIED — added Story 45.2 implementation reference) +- `CLAUDE.md` (MODIFIED — added 3 rows to "Where to Find Things" table) +- `_bmad-output/implementation-artifacts/sprint-status.yaml` (MODIFIED — status in-progress; Task 11.3 pending) + +### Change Log + +- 2026-05-09: Story 45.2 implementation complete — embed compose templates + image-manifest in npm tarball, flip DEFAULT_CONNECTOR_IMAGE to digest form, remove --dry-run from publish workflow, add render + tarball verification CI steps, author compose-loader API with full test coverage. + +### Review Findings + +_To be filled in after code review_ diff --git a/_bmad-output/implementation-artifacts/sprint-status.yaml b/_bmad-output/implementation-artifacts/sprint-status.yaml index 79c7dd3b..89c2b2b8 100644 --- a/_bmad-output/implementation-artifacts/sprint-status.yaml +++ b/_bmad-output/implementation-artifacts/sprint-status.yaml @@ -1,5 +1,5 @@ # generated: 2026-04-27 -# last_updated: 2026-05-09 (Story 45.1 → done — multi-arch image publish CI green; town#37 town#38 town#39 town#40 town#41) +# last_updated: 2026-05-09 (Story 45.2 → review — embed compose templates + image-manifest in npm tarball; remove --dry-run from publish workflow) # project: toon # project_key: NOKEY # tracking_system: file-system @@ -491,7 +491,7 @@ development_status: 44-2-connector-verify-multi-arch-image-build: done # verified v3.5.1 amd64+arm64 manifest + arm64 OK smoke test + sidecar v0.4.10.0-beta; connector#63 #64 #65 44-3-connector-cosign-keyless-oidc-image-signing: done # done: cosign verify green at v3.6.0 — connector#66 44-4-connector-release-contract-cross-repo-doc: done # done: doc mirrored at packages/sdk/CONNECTOR_RELEASE_CONTRACT.md — connector#67, town#34 - epic-44-retrospective: optional + epic-44-retrospective: done # Epic 45: One-Command Apex Install # Drew runs `npx @toon-protocol/townhouse hs up` and gets a payable @@ -499,7 +499,7 @@ development_status: # Depends on Epic 44 Story 44.1 for clean hostname surfacing. epic-45: in-progress 45-1-multi-arch-townhouse-image-publish-ci: done # done: workflow run https://github.com/toon-protocol/town/actions/runs/25603167091 produced 4 multi-arch + cosign-signed images and image-manifest.json — town#37 town#38 town#39 town#40 town#41 - 45-2-embed-compose-templates-and-image-manifest-in-npm-tarball: backlog # CRITICAL PATH + 45-2-embed-compose-templates-and-image-manifest-in-npm-tarball: review # CRITICAL PATH 45-3-docker-orchestrator-profile-param: backlog 45-4-townhouse-hs-up-subcommand-apex-only-boot: backlog # CRITICAL PATH; depends on 44.1 epic-45-retrospective: optional diff --git a/docker-compose-townhouse.yml b/docker-compose-townhouse.yml index e230fac2..efbf4eb2 100644 --- a/docker-compose-townhouse.yml +++ b/docker-compose-townhouse.yml @@ -18,9 +18,9 @@ services: # Connector — always runs (no profile restriction) # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ connector: - # Image tag must match DEFAULT_CONNECTOR_IMAGE in packages/townhouse/src/constants.ts - # Compose cannot import a TS constant — keep both in sync when bumping the connector. - image: ghcr.io/toon-protocol/connector:3.4.1 + # Image digest must match DEFAULT_CONNECTOR_IMAGE in packages/townhouse/src/constants.ts + # Story 45.2: flipped from tag form to digest form per CONNECTOR_RELEASE_CONTRACT.md. + image: ghcr.io/toon-protocol/connector@sha256:4a24ccb0997d7b025997e670546032f6a84cd18a77c490509016b85e181a344e container_name: townhouse-connector networks: - townhouse-net diff --git a/packages/sdk/CONNECTOR_RELEASE_CONTRACT.md b/packages/sdk/CONNECTOR_RELEASE_CONTRACT.md index 5d8bd355..4c44dbbe 100644 --- a/packages/sdk/CONNECTOR_RELEASE_CONTRACT.md +++ b/packages/sdk/CONNECTOR_RELEASE_CONTRACT.md @@ -67,6 +67,12 @@ require a townhouse bump unless the patch fixes a behavior townhouse actively relied on being broken. Major bumps require a deliberate townhouse migration cycle and a CONNECTOR_MIGRATION.md row. +Implementation (Story 45.2): `packages/townhouse/src/constants.ts:21` — +`DEFAULT_CONNECTOR_IMAGE` is digest form (`ghcr.io/toon-protocol/connector@sha256:`). +Bumping to a new connector minor requires updating this constant + running +`pnpm --filter @toon-protocol/townhouse test:canary` to confirm the +Townhouse-side contract canary passes at the new digest. + ## Supply-chain signing Starting from `v3.6.0` (cut after PR [#66](https://github.com/toon-protocol/connector/pull/66) merged), every connector and ATOR sidecar image is cosign-signed via **keyless OIDC** — no static keys, no secrets beyond the default `GITHUB_TOKEN`. diff --git a/packages/townhouse/README.md b/packages/townhouse/README.md index 009e5506..2cf22243 100644 --- a/packages/townhouse/README.md +++ b/packages/townhouse/README.md @@ -159,6 +159,66 @@ pnpm --filter @toon-protocol/townhouse-web e2e TOWNHOUSE_E2E_REAL_STACK=1 pnpm --filter @toon-protocol/townhouse-web e2e:real ``` +## Compose Templates (npm tarball, Story 45.2) + +The published `@toon-protocol/townhouse` package ships two Docker Compose templates: + +| Profile | File in tarball | Purpose | +|---------|-----------------|---------| +| `hs` | `dist/compose/townhouse-hs.yml` | Operator-facing apex boot — digest-pinned GHCR images | +| `dev` | `dist/compose/townhouse-dev.yml` | Contributor dev stack — local `toon:*` build images | + +### API + +```typescript +import { loadComposeTemplate, materializeComposeTemplate } from '@toon-protocol/townhouse'; + +// Read the rendered YAML for a profile (read-only, returns a string). +const yaml = loadComposeTemplate('hs'); + +// Write the compose file + image-manifest.json to ~/.townhouse/ (side-effecting). +// Both output files are written with mode 0o600 (NFR8 — operator-secret). +const { composePath, manifestPath } = materializeComposeTemplate('hs'); +// composePath → ~/.townhouse/compose/townhouse-hs.yml +// manifestPath → ~/.townhouse/image-manifest.json +``` + +Both functions accept an optional `options` object: + +```typescript +interface ComposeLoaderOptions { + townhouseHome?: string; // Override ~/.townhouse/ write target (useful in tests) + distDir?: string; // Override dist/ read root (useful in tests) +} +``` + +### `image-manifest.json` schema + +The manifest pinning every image to a content-addressed `sha256:` digest: + +```json +{ + "schemaVersion": 1, + "townhouseVersion": "0.1.0", + "builtAt": "", + "images": { + "townhouse-api": { "name": "ghcr.io/toon-protocol/townhouse-api", "tag": "0.1.0", "digest": "sha256:..." }, + "town": { "name": "ghcr.io/toon-protocol/town", "tag": "0.1.0", "digest": "sha256:..." }, + "mill": { "name": "ghcr.io/toon-protocol/mill", "tag": "0.1.0", "digest": "sha256:..." }, + "dvm": { "name": "ghcr.io/toon-protocol/dvm", "tag": "0.1.0", "digest": "sha256:..." }, + "connector": { "name": "ghcr.io/toon-protocol/connector", "tag": "3.4.1", "digest": "sha256:..." } + } +} +``` + +Full schema source: `scripts/build-image-manifest.mjs` (lines 44–67). + +### Dev stack compose (canonical source) + +The package-local `packages/townhouse/compose/townhouse-dev.yml` is the canonical source of the dev template. It is shipped verbatim in the npm tarball (no digest substitution — uses local `toon:*` image tags). + +For backward compatibility, `docker-compose-townhouse-dev.yml` at the repo root is preserved and continues to be used by `scripts/townhouse-dev-infra.sh`. A follow-up story will route the script through the package-local copy. + ## Running the townhouse as a hidden service (laptop) `docker-compose-townhouse-hs.yml` brings up the full operator stack — diff --git a/packages/townhouse/compose/townhouse-dev.yml b/packages/townhouse/compose/townhouse-dev.yml new file mode 100644 index 00000000..cd687456 --- /dev/null +++ b/packages/townhouse/compose/townhouse-dev.yml @@ -0,0 +1,405 @@ +# Townhouse Dev Stack — Docker Compose (Story 21.8.0, D21-009) +# +# Full contributor dev topology: 1 standalone connector + 2 Town + 2 Mill + 1 DVM +# + 3 chain devnets + 1 SOCKS5 proxy. +# +# This file is intentionally separate from docker-compose-townhouse.yml. +# Production describes one operator's actual node. This file describes a +# contributor's rig with enough instances to exercise every dashboard view. +# See story 21.8.0 Dev Notes for the full rationale. +# +# Canonical path (npm tarball): packages/townhouse/compose/townhouse-dev.yml +# Ships verbatim inside @toon-protocol/townhouse dist/compose/townhouse-dev.yml +# (Story 45.2 — no digest substitution; uses local toon:* image tags). +# +# Legacy path (backward-compat): docker-compose-townhouse-dev.yml (repo root) +# Used by scripts/townhouse-dev-infra.sh and existing CI. Preserved until +# a follow-up story routes the script through the package-local copy. +# +# Usage: +# ./scripts/townhouse-dev-infra.sh up # Build, start, wait for health (reads root path) +# ./scripts/townhouse-dev-infra.sh down # Stop containers, remove .env.townhouse-dev +# ./scripts/townhouse-dev-infra.sh down-v # Same + remove volumes +# ./scripts/townhouse-dev-infra.sh status # Show container state +# +# Port allocation — 28xxx range (no collision with SDK E2E 18xxx/19xxx or +# production Townhouse 3xxx/7100/9401). See CLAUDE.md "Townhouse Dev Stack (28xxx)". +# +# All host bindings use 127.0.0.1 — never 0.0.0.0 (developer machine). +# +# Connector image tag must match DEFAULT_CONNECTOR_IMAGE in +# packages/townhouse/src/constants.ts — keep both in sync when bumping. + +networks: + townhouse-dev-net: + driver: bridge + +volumes: + townhouse-dev-connector-data: + townhouse-dev-town-01-data: + townhouse-dev-town-02-data: + townhouse-dev-mill-01-data: + townhouse-dev-mill-02-data: + townhouse-dev-dvm-01-data: + +services: + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # Connector — standalone connector for all 5 child peers + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + townhouse-dev-connector: + # Image tag must match DEFAULT_CONNECTOR_IMAGE in packages/townhouse/src/constants.ts + image: ghcr.io/toon-protocol/connector:3.4.1 + container_name: townhouse-dev-connector + networks: + - townhouse-dev-net + ports: + - '127.0.0.1:28080:9401' + environment: + CONFIG_FILE: /config/connector.yaml + volumes: + - ./docker/configs/townhouse-dev-connector.yaml:/config/connector.yaml:ro + - townhouse-dev-connector-data:/data + restart: unless-stopped + healthcheck: + # nosemgrep: trailofbits.generic.wget-unencrypted-url.wget-unencrypted-url -- container-internal health probe + test: ['CMD', 'wget', '-q', '--spider', 'http://localhost:9401/health'] + interval: 10s + timeout: 5s + retries: 5 + start_period: 15s + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # Anvil — Local Ethereum (chain-id 31337) with auto-deployed contracts + # Copied from docker-compose-sdk-e2e.yml, ports shifted to 28xxx range + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + townhouse-dev-anvil: + image: ghcr.io/foundry-rs/foundry:latest + container_name: townhouse-dev-anvil + entrypoint: [] + command: + - sh + - -c + - | + echo "Starting Anvil..." + anvil --host 0.0.0.0 --port 8545 --chain-id 31337 --accounts 10 --balance 10000 & + ANVIL_PID=$$! + + echo "Waiting for Anvil to be ready..." + until cast client --rpc-url http://localhost:8545 2>/dev/null | grep -q 'anvil'; do + sleep 1 + done + + echo "Deploying contracts..." + cd /contracts + forge script script/DeployLocal.s.sol:DeployLocalScript --rpc-url http://localhost:8545 --broadcast --skip-simulation || echo "Deployment failed (non-fatal)" + + echo "Funding test accounts with Mock USDC..." + DEPLOYER_KEY=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 + TOKEN=0x5FbDB2315678afecb367f032d93F642f64180aa3 + AMOUNT=10000000000000000000000 + for ADDR in \ + 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 \ + 0x15d34AAf54267DB7D7c367839AAf71A00a2C6A65 \ + 0x9965507D1a55bcC2695C58ba16FB37d819B0A4dc \ + 0x976EA74026E726554dB657fA54763abd0C3a0aa9 \ + 0x14dC79964da2C08b23698B3D3cc7Ca32193d9955 \ + 0x23618e81E3f5cdF7f54C3d65f7FBc0aBf5B21E8f \ + 0xa0Ee7A142d267C1f36714E4a8F75612F20a79720; do + cast send --rpc-url http://localhost:8545 --private-key $$DEPLOYER_KEY \ + $$TOKEN "transfer(address,uint256)" $$ADDR $$AMOUNT 2>/dev/null \ + && echo "Funded $$ADDR" || echo "Fund $$ADDR failed (non-fatal)" + done + + echo "Anvil ready with contracts deployed!" + wait $$ANVIL_PID + volumes: + - ./contracts/evm:/contracts + ports: + - '127.0.0.1:28545:8545' + networks: + - townhouse-dev-net + restart: unless-stopped + healthcheck: + test: + [ + 'CMD-SHELL', + "cast client --rpc-url http://localhost:8545 2>&1 | grep -q 'anvil' || exit 1", + ] + interval: 10s + timeout: 5s + retries: 3 + start_period: 15s + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # Solana Test Validator — ports shifted to 28xxx range + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + townhouse-dev-solana: + image: ghcr.io/beeman/solana-test-validator:latest + container_name: townhouse-dev-solana + entrypoint: [] + command: + - sh + - /entrypoint.sh + volumes: + - ./infra/solana/entrypoint.sh:/entrypoint.sh:ro + - ./contracts/solana:/programs:ro + tmpfs: + - /tmp/test-ledger + ports: + - '127.0.0.1:28899:8899' # RPC + - '127.0.0.1:28900:8900' # WebSocket + networks: + - townhouse-dev-net + security_opt: + - seccomp=unconfined + restart: unless-stopped + healthcheck: + test: ['CMD-SHELL', 'solana cluster-version --url http://localhost:8899 >/dev/null 2>&1'] + interval: 5s + timeout: 5s + retries: 12 + start_period: 10s + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # Mina Lightnet — ports shifted to 28xxx range + # NOTE: Requires 4-8 GB RAM (see SDK E2E notes) + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + townhouse-dev-mina: + image: o1labs/mina-local-network:compatible-latest-lightnet + container_name: townhouse-dev-mina + environment: + NETWORK_TYPE: single-node + PROOF_LEVEL: none + LOG_LEVEL: Info + RUN_ARCHIVE_NODE: 'false' + SLOT_TIME: '20000' + ports: + - '127.0.0.1:28085:3101' # GraphQL (direct to daemon) + - '127.0.0.1:28181:8181' # Accounts Manager + networks: + - townhouse-dev-net + deploy: + resources: + limits: + memory: 4g + restart: unless-stopped + healthcheck: + test: + [ + 'CMD-SHELL', + 'curl -sf -X POST -H "Content-Type: application/json" -d "{\"query\":\"{syncStatus}\"}" http://localhost:3101/graphql | grep -q SYNCED || exit 1', + ] + interval: 10s + timeout: 10s + retries: 30 + start_period: 180s + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # SOCKS5 Proxy — optional; required for ATOR transport testing (story 21.15) + # Connector defaults to TRANSPORT_MODE: direct; proxy only needed for ATOR mode. + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + townhouse-dev-socks5: + image: serjs/go-socks5-proxy:latest + container_name: townhouse-dev-socks5 + ports: + - '127.0.0.1:28050:1080' + networks: + - townhouse-dev-net + restart: unless-stopped + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # Town 01 — Nostr relay node (instance 1 of 2) + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + townhouse-dev-town-01: + image: toon:town + container_name: townhouse-dev-town-01 + networks: + - townhouse-dev-net + depends_on: + townhouse-dev-connector: + condition: service_healthy + expose: + - '3000' + ports: + - '127.0.0.1:28100:3100' # BLS health + - '127.0.0.1:28700:7100' # Nostr relay WebSocket + environment: + # nosemgrep: detect-insecure-websocket -- Docker-internal, TLS unnecessary + CONNECTOR_URL: ws://townhouse-dev-connector:3000 + CONNECTOR_ADMIN_URL: http://townhouse-dev-connector:9401 + FEE_PER_EVENT: '0' + NODE_NOSTR_PUBKEY: '' + NODE_EVM_ADDRESS: '' + # Interpolated from TOWN_01_SECRET_KEY exported by townhouse-dev-infra.sh. + NODE_NOSTR_SECRET_KEY: '${TOWN_01_SECRET_KEY:-}' + volumes: + - townhouse-dev-town-01-data:/data + healthcheck: + # nosemgrep: trailofbits.generic.wget-unencrypted-url.wget-unencrypted-url -- container-internal health probe + test: ['CMD', 'wget', '-q', '--spider', 'http://localhost:3100/health'] + interval: 30s + timeout: 10s + retries: 3 + start_period: 5s + restart: unless-stopped + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # Town 02 — Nostr relay node (instance 2 of 2) + # Story 21.10 exercises degraded state via `docker pause townhouse-dev-town-02` + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + townhouse-dev-town-02: + image: toon:town + container_name: townhouse-dev-town-02 + networks: + - townhouse-dev-net + depends_on: + townhouse-dev-connector: + condition: service_healthy + expose: + - '3000' + ports: + - '127.0.0.1:28110:3100' # BLS health + - '127.0.0.1:28710:7100' # Nostr relay WebSocket + environment: + # nosemgrep: detect-insecure-websocket -- Docker-internal, TLS unnecessary + CONNECTOR_URL: ws://townhouse-dev-connector:3000 + CONNECTOR_ADMIN_URL: http://townhouse-dev-connector:9401 + FEE_PER_EVENT: '0' + NODE_NOSTR_PUBKEY: '' + NODE_EVM_ADDRESS: '' + # Interpolated from TOWN_02_SECRET_KEY exported by townhouse-dev-infra.sh. + NODE_NOSTR_SECRET_KEY: '${TOWN_02_SECRET_KEY:-}' + volumes: + - townhouse-dev-town-02-data:/data + healthcheck: + # nosemgrep: trailofbits.generic.wget-unencrypted-url.wget-unencrypted-url -- container-internal health probe + test: ['CMD', 'wget', '-q', '--spider', 'http://localhost:3100/health'] + interval: 30s + timeout: 10s + retries: 3 + start_period: 5s + restart: unless-stopped + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # Mill 01 — EVM↔Solana swap peer (story 21.11 AC names this exact container) + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + townhouse-dev-mill-01: + image: toon:mill + container_name: townhouse-dev-mill-01 + networks: + - townhouse-dev-net + depends_on: + townhouse-dev-connector: + condition: service_healthy + townhouse-dev-anvil: + condition: service_healthy + townhouse-dev-solana: + condition: service_healthy + expose: + - '3000' + ports: + - '127.0.0.1:28200:3200' # BLS health + environment: + # nosemgrep: detect-insecure-websocket -- Docker-internal, TLS unnecessary + CONNECTOR_URL: ws://townhouse-dev-connector:3000 + FEE_BASIS_POINTS: '0' + NODE_NOSTR_PUBKEY: '' + NODE_EVM_ADDRESS: '' + # MILL_MNEMONIC takes priority over NODE_NOSTR_SECRET_KEY for BIP-32 swap key derivation. + # Interpolated from MILL_01_MNEMONIC exported by townhouse-dev-infra.sh. + MILL_MNEMONIC: '${MILL_01_MNEMONIC:-}' + # Kept for backward-compat; ignored when MILL_MNEMONIC is set. + NODE_NOSTR_SECRET_KEY: '${MILL_01_SECRET_KEY:-}' + MILL_CONFIG_PATH: /config/mill.config.json + MILL_RELAYS: ws://townhouse-dev-town-01:7100,ws://townhouse-dev-town-02:7100 + volumes: + - ./docker/dev-fixtures/mill-01.config.json:/config/mill.config.json:ro + - townhouse-dev-mill-01-data:/data + healthcheck: + # nosemgrep: trailofbits.generic.wget-unencrypted-url.wget-unencrypted-url -- container-internal health probe + test: ['CMD', 'wget', '-q', '--spider', 'http://localhost:3200/health'] + interval: 30s + timeout: 10s + retries: 3 + start_period: 5s + restart: unless-stopped + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # Mill 02 — EVM↔Mina swap peer (story 21.11 AC names this exact container) + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + townhouse-dev-mill-02: + image: toon:mill + container_name: townhouse-dev-mill-02 + networks: + - townhouse-dev-net + depends_on: + townhouse-dev-connector: + condition: service_healthy + townhouse-dev-anvil: + condition: service_healthy + townhouse-dev-mina: + condition: service_healthy + expose: + - '3000' + ports: + - '127.0.0.1:28210:3200' # BLS health + environment: + # nosemgrep: detect-insecure-websocket -- Docker-internal, TLS unnecessary + CONNECTOR_URL: ws://townhouse-dev-connector:3000 + FEE_BASIS_POINTS: '0' + NODE_NOSTR_PUBKEY: '' + NODE_EVM_ADDRESS: '' + # MILL_MNEMONIC takes priority over NODE_NOSTR_SECRET_KEY for BIP-32 swap key derivation. + # Interpolated from MILL_02_MNEMONIC exported by townhouse-dev-infra.sh. + MILL_MNEMONIC: '${MILL_02_MNEMONIC:-}' + # Kept for backward-compat; ignored when MILL_MNEMONIC is set. + NODE_NOSTR_SECRET_KEY: '${MILL_02_SECRET_KEY:-}' + MILL_CONFIG_PATH: /config/mill.config.json + MILL_RELAYS: ws://townhouse-dev-town-01:7100,ws://townhouse-dev-town-02:7100 + volumes: + - ./docker/dev-fixtures/mill-02.config.json:/config/mill.config.json:ro + - townhouse-dev-mill-02-data:/data + healthcheck: + # nosemgrep: trailofbits.generic.wget-unencrypted-url.wget-unencrypted-url -- container-internal health probe + test: ['CMD', 'wget', '-q', '--spider', 'http://localhost:3200/health'] + interval: 30s + timeout: 10s + retries: 3 + start_period: 5s + restart: unless-stopped + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # DVM 01 — data vending machine (story 21.12 AC names this exact container) + # TURBO_TOKEN: pass through from host env; absence is non-fatal (disabled-upload mode) + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + townhouse-dev-dvm-01: + image: toon:dvm + container_name: townhouse-dev-dvm-01 + networks: + - townhouse-dev-net + depends_on: + townhouse-dev-connector: + condition: service_healthy + expose: + - '3300' + ports: + - '127.0.0.1:28400:3400' # BLS health + environment: + # nosemgrep: detect-insecure-websocket -- Docker-internal, TLS unnecessary + CONNECTOR_URL: ws://townhouse-dev-connector:3000 + FEE_PER_JOB: '0' + NODE_NOSTR_PUBKEY: '' + NODE_EVM_ADDRESS: '' + # Interpolated from DVM_01_SECRET_KEY exported by townhouse-dev-infra.sh. + NODE_NOSTR_SECRET_KEY: '${DVM_01_SECRET_KEY:-}' + TURBO_TOKEN: '${TURBO_TOKEN:-}' + volumes: + - townhouse-dev-dvm-01-data:/data + healthcheck: + # nosemgrep: trailofbits.generic.wget-unencrypted-url.wget-unencrypted-url -- container-internal health probe + test: ['CMD', 'wget', '-q', '--spider', 'http://localhost:3400/health'] + interval: 30s + timeout: 10s + retries: 3 + start_period: 5s + restart: unless-stopped diff --git a/packages/townhouse/compose/townhouse-hs.yml b/packages/townhouse/compose/townhouse-hs.yml new file mode 100644 index 00000000..188d4fc4 --- /dev/null +++ b/packages/townhouse/compose/townhouse-hs.yml @@ -0,0 +1,244 @@ +# Townhouse — Hidden Service mode (HS) operator compose template +# +# THIS IS A BUILD-TIME TEMPLATE. Do not use it directly. +# The digest placeholders (${TOON_*_DIGEST}) are substituted by +# scripts/render-compose-template.mjs during `pnpm build` (when +# dist/image-manifest.json is present) to produce the fully-resolved +# dist/compose/townhouse-hs.yml that ships inside the npm tarball. +# +# Resolved copy location at runtime: ~/.townhouse/compose/townhouse-hs.yml +# - Written by materializeComposeTemplate('hs') in packages/townhouse/src/compose-loader.ts +# - File mode: 0o600 (operator-secret — may embed private keys at deploy time) +# +# Story ownership: +# - Placeholder substitution: Story 45.2 (this file) +# - Boot sequence (townhouse hs up): Story 45.4 +# +# Architecture (HS-mode v1, Epic 45): +# - Apex connector: standalone, anon HS publishing in-process (connector v3.5.x) +# - townhouse-api: containerized host API — owns /var/run/docker.sock, calls +# connector admin API, manages lifecycle for lazy-provisioned peers +# - town/mill/dvm: lazy-provisioned via Docker Compose profiles (Epic 46) +# Story 45.4 boots only connector + townhouse-api at apex install +# +# Digest placeholders (substituted at build time from dist/image-manifest.json): +# ${TOON_TOWNHOUSE_API_DIGEST} → @sha256: +# ${TOON_TOWN_DIGEST} → @sha256: +# ${TOON_MILL_DIGEST} → @sha256: +# ${TOON_DVM_DIGEST} → @sha256: +# ${TOON_CONNECTOR_DIGEST} → @sha256: +# +# Scope guard (Story 45.2 does NOT include): +# - ator-sidecar / ator-sidecar-relay (connector v3.5.x does HS publishing in-process) +# - anvil / solana / faucet (dev-stack concerns, not operator-facing) +# - build: directives (all images are digest-pinned GHCR pulls) + +networks: + townhouse-hs-net: + driver: bridge + +volumes: + # Named volume for the connector's .anyone keypair + HS state. + # Survives `docker compose down`; delete to rotate the .anyone address. + townhouse-hs-anon: + townhouse-hs-town-data: + townhouse-hs-mill-data: + townhouse-hs-dvm-data: + +services: + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # Apex connector — terminates inbound BTP, publishes .anyone HS + # + # The connector image embeds @anyone-protocol/anyone-client v1.1.x+ + # which handles HS publishing in-process (no sidecar required for v3.5.x+). + # Config file written by `townhouse hs up` (Story 45.4) on first-run. + # + # NFR7: connector MUST NOT mount /var/run/docker.sock. + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + connector: + image: ghcr.io/toon-protocol/connector${TOON_CONNECTOR_DIGEST} + container_name: townhouse-hs-connector + hostname: connector + networks: + - townhouse-hs-net + ports: + # Admin API on host loopback only (NFR9). Operator uses for status. + - '127.0.0.1:9401:9401' + volumes: + # Rendered connector config (Story 45.4 writes this on first-run). + - ~/.townhouse/connector.yaml:/config/connector.yaml:ro + # .anyone keypair + HS state (persists across down/up cycles). + - townhouse-hs-anon:/var/lib/anon/hs + environment: + CONFIG_FILE: /config/connector.yaml + healthcheck: + # nosemgrep: trailofbits.generic.wget-unencrypted-url.wget-unencrypted-url -- container-internal probe + test: ['CMD', 'wget', '-q', '--spider', 'http://localhost:9401/health'] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s + restart: unless-stopped + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # Townhouse API — containerized host API (NEW in HS-mode v1) + # + # Owns /var/run/docker.sock (the only service that may). Provides: + # - Fastify REST API for operator dashboard / CLI + # - Calls connector admin API (/admin/hs-hostname, /admin/peers, etc.) + # - Manages lifecycle for lazy-provisioned peer containers (Epic 46) + # + # Planning doc §4 anchor: "host-side townhouse-api owns dockerode and + # runs on the townhouse-hs-net so it can reach connector via Docker DNS". + # Port D21-008: Fastify host API on 127.0.0.1:28090. + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + townhouse-api: + image: ghcr.io/toon-protocol/townhouse-api${TOON_TOWNHOUSE_API_DIGEST} + container_name: townhouse-hs-api + networks: + - townhouse-hs-net + depends_on: + connector: + condition: service_healthy + ports: + # Fastify host API — loopback only (NFR9). + - '127.0.0.1:28090:28090' + volumes: + # Docker socket — townhouse-api is the sole orchestration surface. + - /var/run/docker.sock:/var/run/docker.sock + # Operator home — wallet, config, compose files, snapshots (RW). + - ~/.townhouse:/.townhouse:rw + healthcheck: + # nosemgrep: trailofbits.generic.wget-unencrypted-url.wget-unencrypted-url -- container-internal probe + test: ['CMD', 'wget', '-q', '--spider', 'http://localhost:28090/api/health'] + interval: 10s + timeout: 5s + retries: 5 + start_period: 15s + restart: unless-stopped + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # Town — Nostr relay node (profile: town) + # Lazy-provisioned via Epic 46: `townhouse node add town` + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + town: + image: ghcr.io/toon-protocol/town${TOON_TOWN_DIGEST} + container_name: townhouse-hs-town + profiles: [town] + networks: + - townhouse-hs-net + depends_on: + connector: + condition: service_healthy + expose: ['3000'] + ports: + - '127.0.0.1:7100:7100' + - '127.0.0.1:3100:3100' + environment: + # nosemgrep: detect-insecure-websocket -- Docker-internal + CONNECTOR_URL: ws://connector:3000 + ILP_ADDRESS: g.townhouse.town + NODE_ID: town + PARENT_PEER_ID: apex + FEE_PER_EVENT: '0' + # Chain RPC — operator sets EVM_RPC_URL in ~/.townhouse/env or via CLI. + TOON_RPC_URL: ${EVM_RPC_URL:-} + NODE_NOSTR_PUBKEY: '' + NODE_EVM_ADDRESS: '' + # Derived from HD wallet at runtime (Story 45.4 / Epic 46). + NODE_NOSTR_SECRET_KEY: '${TOWN_SECRET_KEY:-}' + SETTLEMENT_PRIVATE_KEY: '${TOWN_SETTLEMENT_PRIVATE_KEY:-}' + PARENT_EVM_ADDRESS: '${APEX_EVM_ADDRESS:-}' + TOON_CONNECTOR_LOG_LEVEL: '${TOON_CONNECTOR_LOG_LEVEL:-warn}' + volumes: + - townhouse-hs-town-data:/data + healthcheck: + test: ['CMD', 'wget', '-q', '--spider', 'http://localhost:3100/health'] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s + restart: unless-stopped + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # Mill — multi-chain swap node (profile: mill) + # Lazy-provisioned via Epic 46: `townhouse node add mill` + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + mill: + image: ghcr.io/toon-protocol/mill${TOON_MILL_DIGEST} + container_name: townhouse-hs-mill + profiles: [mill] + networks: + - townhouse-hs-net + depends_on: + connector: + condition: service_healthy + expose: ['3000'] + ports: + - '127.0.0.1:3200:3200' + environment: + # nosemgrep: detect-insecure-websocket -- Docker-internal + CONNECTOR_URL: ws://connector:3000 + FEE_BASIS_POINTS: '0' + SETTLEMENT_RPC_URL: ${EVM_RPC_URL:-} + SETTLEMENT_CHAIN_ID: ${EVM_CHAIN_ID:-} + SETTLEMENT_TOKEN_ADDRESS: ${EVM_USDC_ADDRESS:-} + SOLANA_RPC_URL: ${SOLANA_RPC_URL:-} + SOLANA_USDC_MINT: ${SOLANA_USDC_MINT:-} + NODE_NOSTR_PUBKEY: '' + NODE_EVM_ADDRESS: '' + # Derived from HD wallet at runtime (Story 45.4 / Epic 46). + MILL_MNEMONIC: '${MILL_MNEMONIC:-}' + NODE_NOSTR_SECRET_KEY: '${MILL_SECRET_KEY:-}' + MILL_CONFIG_PATH: /config/mill.config.json + MILL_RELAYS: ${MILL_RELAYS:-} + SETTLEMENT_PRIVATE_KEY: '${MILL_SETTLEMENT_PRIVATE_KEY:-}' + PARENT_EVM_ADDRESS: '${APEX_EVM_ADDRESS:-}' + TOON_CONNECTOR_LOG_LEVEL: '${TOON_CONNECTOR_LOG_LEVEL:-warn}' + volumes: + # Operator-managed mill config (Epic 46 provisions this on `townhouse node add mill`). + - ~/.townhouse/mill.config.json:/config/mill.config.json:ro + - townhouse-hs-mill-data:/data + healthcheck: + test: ['CMD', 'wget', '-q', '--spider', 'http://localhost:3200/health'] + interval: 30s + timeout: 10s + retries: 3 + start_period: 15s + restart: unless-stopped + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # DVM — Arweave upload DVM (profile: dvm) + # Lazy-provisioned via Epic 46: `townhouse node add dvm` + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + dvm: + image: ghcr.io/toon-protocol/dvm${TOON_DVM_DIGEST} + container_name: townhouse-hs-dvm + profiles: [dvm] + networks: + - townhouse-hs-net + depends_on: + connector: + condition: service_healthy + expose: ['3300'] + ports: + - '127.0.0.1:3400:3400' + environment: + # nosemgrep: detect-insecure-websocket -- Docker-internal + CONNECTOR_URL: ws://connector:3000 + FEE_PER_JOB: '0' + DVM_KIND: '5094' + NODE_NOSTR_PUBKEY: '' + NODE_EVM_ADDRESS: '' + # Derived from HD wallet at runtime (Story 45.4 / Epic 46). + NODE_NOSTR_SECRET_KEY: '${DVM_SECRET_KEY:-}' + TURBO_TOKEN: ${TURBO_TOKEN:-} + volumes: + - townhouse-hs-dvm-data:/data + healthcheck: + test: ['CMD', 'wget', '-q', '--spider', 'http://localhost:3400/health'] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s + restart: unless-stopped diff --git a/packages/townhouse/src/__integration__/compose-template-validity.test.ts b/packages/townhouse/src/__integration__/compose-template-validity.test.ts new file mode 100644 index 00000000..10e043de --- /dev/null +++ b/packages/townhouse/src/__integration__/compose-template-validity.test.ts @@ -0,0 +1,163 @@ +/** + * Integration test: validates the rendered HS compose template via `docker compose config`. + * + * Requirements checked: + * - All five services present (connector, townhouse-api, town, mill, dvm) + * - Every services..image uses digest form (@sha256:<64hex>) + * - No `build:` directives in the services section + * - Every host-side port binding uses 127.0.0.1: prefix (NFR9) + * + * Gated on DOCKER_AVAILABLE env var (default '1' when docker binary is present). + * Skipped entirely when DOCKER_AVAILABLE is set to anything other than '1'. + * + * The test reads from packages/townhouse/dist/compose/townhouse-hs.yml — + * run `pnpm --filter @toon-protocol/townhouse build` and then place + * dist/image-manifest.json (from CI artifact or scripts/build-image-manifest.mjs) + * before running this test to get a fully-substituted template. + */ + +import { describe, it, expect, beforeAll } from 'vitest'; +import { execFileSync, execSync } from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +// Resolve the dist/compose/townhouse-hs.yml from this integration test location. +// This file lives at packages/townhouse/src/__integration__/*.test.ts, +// so packages/townhouse is two levels up. +const PKG_DIR = join(__dirname, '..', '..'); +const RENDERED_HS_PATH = join(PKG_DIR, 'dist', 'compose', 'townhouse-hs.yml'); + +function isDockerAvailable(): boolean { + if (process.env['DOCKER_AVAILABLE'] === '0') return false; + if (process.env['DOCKER_AVAILABLE'] === '1') return true; + // Auto-detect: check if docker binary exists and responds + try { + execSync('docker info --format "{{.ID}}"', { stdio: 'ignore', timeout: 5000 }); + return true; + } catch { + return false; + } +} + +const dockerAvailable = isDockerAvailable(); +const renderedHsExists = existsSync(RENDERED_HS_PATH); + +describe.skipIf(!renderedHsExists)( + 'compose-template-validity (dist/compose/townhouse-hs.yml)', + () => { + let renderedYaml: string; + + beforeAll(() => { + renderedYaml = readFileSync(RENDERED_HS_PATH, 'utf-8'); + }); + + it('rendered HS template has no unsubstituted digest placeholders', () => { + expect(renderedYaml).not.toMatch(/\$\{TOON_[A-Z_]+_DIGEST\}/); + }); + + it('every services..image uses digest form (@sha256:<64hex>)', () => { + // Extract all image: lines and verify each uses @sha256: form + const imageLines = renderedYaml + .split('\n') + .filter((line) => /^\s+image:\s/.test(line)); + expect(imageLines.length).toBeGreaterThan(0); + for (const line of imageLines) { + expect(line, `image line should use @sha256: form: ${line.trim()}`).toMatch( + /@sha256:[a-f0-9]{64}/ + ); + } + }); + + it('no build: directives appear in the rendered template', () => { + // Match `build:` as a YAML key (indented or at root), not in comments + const nonCommentLines = renderedYaml + .split('\n') + .filter((line) => !line.trimStart().startsWith('#')); + const buildLines = nonCommentLines.filter((line) => /^\s+build:/.test(line)); + expect(buildLines).toHaveLength(0); + }); + + it('every host-side port binding uses 127.0.0.1: prefix (NFR9)', () => { + // Find all ports lines with a numeric host-side port + const portLines = renderedYaml + .split('\n') + .filter((line) => /^\s+-\s+['"]?\d/.test(line) || /^\s+-\s+['"]?\d{2,5}:/.test(line)); + // Simplified: find lines that look like port mappings with a host port + const allPortMappings = renderedYaml + .split('\n') + .filter((line) => /^\s+-\s+['"]?\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}:|^\s+-\s+['"]?\d+:\d+/.test(line)); + + for (const line of allPortMappings) { + const clean = line.trim().replace(/^-\s*/, '').replace(/['"]/g, ''); + // If it contains a colon (host:container mapping), check host side + if (clean.includes(':')) { + // Accept 127.0.0.1:: form + expect( + clean, + `Port binding must use 127.0.0.1: prefix (NFR9): ${clean}` + ).toMatch(/^127\.0\.0\.1:/); + } + } + }); + + it.skipIf(!dockerAvailable)( + 'docker compose config validates the rendered HS template', + () => { + let stdout: string; + try { + // Use --profile flags so profiled services (town, mill, dvm) appear in config output. + // Docker Compose v5+ requires explicit --profile to include profile-restricted services. + stdout = execFileSync('docker', [ + 'compose', '-f', RENDERED_HS_PATH, + '--profile', 'town', '--profile', 'mill', '--profile', 'dvm', + 'config', + ], { + encoding: 'utf-8', + timeout: 30_000, + }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + throw new Error(`docker compose config failed: ${msg}`); + } + + // All five services should appear in the validated config + const expectedServices = ['connector', 'townhouse-api', 'town', 'mill', 'dvm']; + for (const svc of expectedServices) { + expect(stdout, `service '${svc}' should be in docker compose config output`).toContain(svc); + } + }, + 30_000 + ); + + it.skipIf(!dockerAvailable)( + 'docker compose config output has no build: directives for any service', + () => { + const stdout = execFileSync('docker', [ + 'compose', '-f', RENDERED_HS_PATH, + '--profile', 'town', '--profile', 'mill', '--profile', 'dvm', + 'config', + ], { + encoding: 'utf-8', + timeout: 30_000, + }); + // In the resolved config output, no service should have a build key + expect(stdout).not.toMatch(/^\s+build:/m); + }, + 30_000 + ); + } +); + +describe.skipIf(renderedHsExists)( + 'compose-template-validity (SKIPPED — dist/compose/townhouse-hs.yml not present)', + () => { + it('skipped: run pnpm build + place image-manifest.json first', () => { + // This test group is intentionally skipped when the rendered file is absent. + // It exists to produce a visible skip entry in CI rather than silently passing. + }); + } +); diff --git a/packages/townhouse/src/__integration__/connector-image-contract.test.ts b/packages/townhouse/src/__integration__/connector-image-contract.test.ts index fe079e0b..08749462 100644 --- a/packages/townhouse/src/__integration__/connector-image-contract.test.ts +++ b/packages/townhouse/src/__integration__/connector-image-contract.test.ts @@ -28,6 +28,15 @@ import type Docker from 'dockerode'; import { DEFAULT_CONNECTOR_IMAGE } from '../constants.js'; import { ConnectorAdminClient } from '../connector/admin-client.js'; +/** Parse a Docker image reference into its name, optional tag, and optional digest. */ +function parseConnectorImage(ref: string): { name: string; tag?: string; digest?: string } { + const digestMatch = ref.match(/^(.+)@(sha256:[a-f0-9]+)$/); + if (digestMatch) return { name: digestMatch[1]!, digest: digestMatch[2] }; + const tagMatch = ref.match(/^(.+):([^:]+)$/); + if (tagMatch) return { name: tagMatch[1]!, tag: tagMatch[2] }; + throw new Error(`unparseable image ref: ${ref}`); +} + // ── Port allocation for this test ───────────────────────────────────────────── // We bind two internal ports to ephemeral host ports so this test can run // alongside other integration tests without port conflicts. @@ -61,9 +70,15 @@ describe.skipIf(isTruthyEnv(process.env['SKIP_DOCKER']))( // ── Pull image if not already present ────────────────────────────── const images = await docker.listImages(); - const alreadyPulled = images.some((img) => - (img.RepoTags ?? []).includes(DEFAULT_CONNECTOR_IMAGE) - ); + // Support both tag form (RepoTags) and digest form (RepoDigests). + // Digest refs appear in RepoDigests as "name@sha256:", not in RepoTags. + const parsedRef = parseConnectorImage(DEFAULT_CONNECTOR_IMAGE); + const alreadyPulled = images.some((img) => { + if (parsedRef.digest) { + return (img.RepoDigests ?? []).some((d) => d.includes(parsedRef.digest!)); + } + return (img.RepoTags ?? []).includes(DEFAULT_CONNECTOR_IMAGE); + }); if (!alreadyPulled) { await new Promise((resolve, reject) => { diff --git a/packages/townhouse/src/__integration__/tarball-contents.test.ts b/packages/townhouse/src/__integration__/tarball-contents.test.ts new file mode 100644 index 00000000..cc2bf0b5 --- /dev/null +++ b/packages/townhouse/src/__integration__/tarball-contents.test.ts @@ -0,0 +1,116 @@ +/** + * Integration test: verifies that `pnpm pack` produces a tarball containing + * the three required artifacts: + * - package/dist/compose/townhouse-hs.yml + * - package/dist/compose/townhouse-dev.yml + * - package/dist/image-manifest.json + * + * Also asserts the HS YAML in the tarball contains no unsubstituted placeholders + * and that every image: line uses digest form (@sha256:). + * + * Skip conditions: + * - SKIP_PACK_TEST=1 : developer explicitly skips (no dist/ rebuild needed) + * - dist/image-manifest.json absent at test start : local dev path where + * manifest hasn't been placed yet. The tarball-content check for image-manifest.json + * is skipped but the compose file assertions still run. + * + * In CI: dist/image-manifest.json is placed by the download-artifact step + + * render step BEFORE this test runs, so all assertions run. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { execFileSync, execSync } from 'node:child_process'; +import { existsSync, readFileSync, mkdtempSync, rmSync, readdirSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { tmpdir } from 'node:os'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const PKG_DIR = join(__dirname, '..', '..'); +const MANIFEST_PATH = join(PKG_DIR, 'dist', 'image-manifest.json'); + +const skipPackTest = process.env['SKIP_PACK_TEST'] === '1'; +const manifestPresent = existsSync(MANIFEST_PATH); + +describe.skipIf(skipPackTest)('tarball-contents', () => { + let packOutDir: string; + let extractDir: string; + let tgzPath: string; + + beforeAll(() => { + packOutDir = mkdtempSync(join(tmpdir(), 'townhouse-pack-')); + extractDir = mkdtempSync(join(tmpdir(), 'townhouse-extract-')); + + // Run pnpm pack from the package directory + const result = execFileSync( + 'pnpm', + ['pack', '--pack-destination', packOutDir], + { cwd: PKG_DIR, encoding: 'utf-8', timeout: 60_000 } + ); + + // Find the produced .tgz + const tgzName = result.trim().split('\n').pop()?.trim(); + // pnpm pack outputs the path to the tgz + if (tgzName && existsSync(tgzName)) { + tgzPath = tgzName; + } else { + // fallback: find in packOutDir + const files = readdirSync(packOutDir).filter((f) => f.endsWith('.tgz')); + expect(files.length, 'expected exactly one .tgz in pack output dir').toBe(1); + tgzPath = join(packOutDir, files[0]!); + } + + // Extract the tarball + execFileSync('tar', ['-xzf', tgzPath, '-C', extractDir], { timeout: 30_000 }); + }, 90_000); + + afterAll(() => { + if (packOutDir) rmSync(packOutDir, { recursive: true, force: true }); + if (extractDir) rmSync(extractDir, { recursive: true, force: true }); + }); + + it('tarball contains package/dist/compose/townhouse-hs.yml', () => { + const hsPath = join(extractDir, 'package', 'dist', 'compose', 'townhouse-hs.yml'); + expect(existsSync(hsPath), `expected ${hsPath} to exist in tarball`).toBe(true); + }); + + it('tarball contains package/dist/compose/townhouse-dev.yml', () => { + const devPath = join(extractDir, 'package', 'dist', 'compose', 'townhouse-dev.yml'); + expect(existsSync(devPath), `expected ${devPath} to exist in tarball`).toBe(true); + }); + + it.skipIf(!manifestPresent)( + 'tarball contains package/dist/image-manifest.json (skipped when manifest absent locally)', + () => { + const manifestInTarball = join(extractDir, 'package', 'dist', 'image-manifest.json'); + expect(existsSync(manifestInTarball), `expected ${manifestInTarball} to exist in tarball`).toBe(true); + } + ); + + it('tarball HS YAML has no unsubstituted placeholders', () => { + const hsPath = join(extractDir, 'package', 'dist', 'compose', 'townhouse-hs.yml'); + if (!existsSync(hsPath)) return; // covered by previous test + const content = readFileSync(hsPath, 'utf-8'); + expect(content, 'HS YAML in tarball must not contain unsubstituted placeholders').not.toMatch( + /\$\{TOON_[A-Z_]+_DIGEST\}/ + ); + }); + + it.skipIf(!manifestPresent)( + 'tarball HS YAML has @sha256: digest form for every image: line (skipped when manifest absent)', + () => { + const hsPath = join(extractDir, 'package', 'dist', 'compose', 'townhouse-hs.yml'); + if (!existsSync(hsPath)) return; + const content = readFileSync(hsPath, 'utf-8'); + const imageLines = content.split('\n').filter((l) => /^\s+image:\s/.test(l)); + expect(imageLines.length).toBeGreaterThan(0); + for (const line of imageLines) { + expect(line, `image line must use @sha256: form: ${line.trim()}`).toMatch( + /@sha256:[a-f0-9]{64}/ + ); + } + } + ); +}); diff --git a/packages/townhouse/src/__tests__/fixtures/compose-loader/compose/townhouse-dev.yml b/packages/townhouse/src/__tests__/fixtures/compose-loader/compose/townhouse-dev.yml new file mode 100644 index 00000000..032a0cc4 --- /dev/null +++ b/packages/townhouse/src/__tests__/fixtures/compose-loader/compose/townhouse-dev.yml @@ -0,0 +1,12 @@ +# Fixture: dev compose for unit tests (verbatim — uses local toon:* image tags). +networks: + townhouse-dev-net: + driver: bridge +services: + townhouse-dev-connector: + image: ghcr.io/toon-protocol/connector:3.4.1 + container_name: townhouse-dev-connector + networks: + - townhouse-dev-net + ports: + - '127.0.0.1:28080:9401' diff --git a/packages/townhouse/src/__tests__/fixtures/compose-loader/compose/townhouse-hs.yml b/packages/townhouse/src/__tests__/fixtures/compose-loader/compose/townhouse-hs.yml new file mode 100644 index 00000000..a1a3ed21 --- /dev/null +++ b/packages/townhouse/src/__tests__/fixtures/compose-loader/compose/townhouse-hs.yml @@ -0,0 +1,64 @@ +# Fixture: pre-rendered HS compose for unit tests. +# Digest values match the fixture image-manifest.json (all-hex patterns). +networks: + townhouse-hs-net: + driver: bridge +volumes: + townhouse-hs-anon: +services: + connector: + image: ghcr.io/toon-protocol/connector@sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee + container_name: townhouse-hs-connector + networks: + - townhouse-hs-net + ports: + - '127.0.0.1:9401:9401' + healthcheck: + test: ['CMD', 'wget', '-q', '--spider', 'http://localhost:9401/health'] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s + townhouse-api: + image: ghcr.io/toon-protocol/townhouse-api@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + container_name: townhouse-hs-api + networks: + - townhouse-hs-net + depends_on: + connector: + condition: service_healthy + ports: + - '127.0.0.1:28090:28090' + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - ~/.townhouse:/.townhouse:rw + healthcheck: + test: ['CMD', 'wget', '-q', '--spider', 'http://localhost:28090/api/health'] + interval: 10s + timeout: 5s + retries: 5 + start_period: 15s + town: + image: ghcr.io/toon-protocol/town@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb + profiles: [town] + networks: + - townhouse-hs-net + depends_on: + connector: + condition: service_healthy + mill: + image: ghcr.io/toon-protocol/mill@sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc + profiles: [mill] + networks: + - townhouse-hs-net + depends_on: + connector: + condition: service_healthy + dvm: + image: ghcr.io/toon-protocol/dvm@sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd + profiles: [dvm] + networks: + - townhouse-hs-net + depends_on: + connector: + condition: service_healthy diff --git a/packages/townhouse/src/__tests__/fixtures/compose-loader/image-manifest.json b/packages/townhouse/src/__tests__/fixtures/compose-loader/image-manifest.json new file mode 100644 index 00000000..c264966e --- /dev/null +++ b/packages/townhouse/src/__tests__/fixtures/compose-loader/image-manifest.json @@ -0,0 +1,32 @@ +{ + "schemaVersion": 1, + "townhouseVersion": "0.0.1-fixture", + "builtAt": "2026-05-09T00:00:00.000Z", + "images": { + "townhouse-api": { + "name": "ghcr.io/toon-protocol/townhouse-api", + "tag": "0.0.1-fixture", + "digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "town": { + "name": "ghcr.io/toon-protocol/town", + "tag": "0.0.1-fixture", + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "mill": { + "name": "ghcr.io/toon-protocol/mill", + "tag": "0.0.1-fixture", + "digest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + }, + "dvm": { + "name": "ghcr.io/toon-protocol/dvm", + "tag": "0.0.1-fixture", + "digest": "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" + }, + "connector": { + "name": "ghcr.io/toon-protocol/connector", + "tag": "3.4.1", + "digest": "sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + } + } +} diff --git a/packages/townhouse/src/compose-loader.test.ts b/packages/townhouse/src/compose-loader.test.ts new file mode 100644 index 00000000..36ed48e2 --- /dev/null +++ b/packages/townhouse/src/compose-loader.test.ts @@ -0,0 +1,164 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, statSync, rmSync, mkdirSync, copyFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { tmpdir } from 'node:os'; +import { fileURLToPath } from 'node:url'; + +import { loadComposeTemplate, materializeComposeTemplate, ComposeLoaderError } from './compose-loader.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +// Fixture dir: src/__tests__/fixtures/compose-loader/ +// Contains: image-manifest.json + compose/townhouse-{hs,dev}.yml (pre-rendered). +const FIXTURE_DIR = join(__dirname, '__tests__', 'fixtures', 'compose-loader'); + +describe('loadComposeTemplate', () => { + it('returns dev template verbatim', () => { + const yaml = loadComposeTemplate('dev', { distDir: FIXTURE_DIR }); + expect(yaml).toContain('townhouse-dev-net'); + expect(yaml).toContain('ghcr.io/toon-protocol/connector:3.4.1'); + }); + + it('returns hs template with five @sha256: substitutions when fixture contains substituted file', () => { + const yaml = loadComposeTemplate('hs', { distDir: FIXTURE_DIR }); + // Five image lines with @sha256: form + const sha256Matches = yaml.match(/@sha256:[a-f0-9]{64}/g); + expect(sha256Matches).toBeTruthy(); + expect(sha256Matches!.length).toBe(5); + // No unsubstituted placeholders + expect(yaml).not.toMatch(/\$\{TOON_[A-Z_]+_DIGEST\}/); + }); + + it('throws ComposeLoaderError when template file is missing', () => { + const missingDir = join(tmpdir(), 'nonexistent-fixture-dir-' + Date.now()); + expect(() => loadComposeTemplate('hs', { distDir: missingDir })) + .toThrowError(ComposeLoaderError); + }); + + it('thrown ComposeLoaderError contains the missing path', () => { + const missingDir = '/nonexistent/dist/dir-' + Date.now(); + let thrown: Error | undefined; + try { + loadComposeTemplate('hs', { distDir: missingDir }); + } catch (e) { + thrown = e as Error; + } + expect(thrown).toBeInstanceOf(ComposeLoaderError); + expect(thrown!.message).toContain(missingDir); + }); +}); + +describe('materializeComposeTemplate', () => { + let tmpHome: string; + + beforeEach(() => { + tmpHome = mkdtempSync(join(tmpdir(), 'compose-loader-test-')); + }); + + afterEach(() => { + rmSync(tmpHome, { recursive: true, force: true }); + }); + + it('writes compose/townhouse-hs.yml AND image-manifest.json to tmpHome', () => { + const { composePath, manifestPath } = materializeComposeTemplate('hs', { + distDir: FIXTURE_DIR, + townhouseHome: tmpHome, + }); + expect(composePath).toBe(join(tmpHome, 'compose', 'townhouse-hs.yml')); + expect(manifestPath).toBe(join(tmpHome, 'image-manifest.json')); + // Files exist + expect(() => statSync(composePath)).not.toThrow(); + expect(() => statSync(manifestPath)).not.toThrow(); + }); + + it('compose file is written with mode 0o600', () => { + const { composePath } = materializeComposeTemplate('hs', { + distDir: FIXTURE_DIR, + townhouseHome: tmpHome, + }); + const mode = statSync(composePath).mode & 0o777; + expect(mode).toBe(0o600); + }); + + it('manifest file is written with mode 0o600', () => { + const { manifestPath } = materializeComposeTemplate('hs', { + distDir: FIXTURE_DIR, + townhouseHome: tmpHome, + }); + const mode = statSync(manifestPath).mode & 0o777; + expect(mode).toBe(0o600); + }); + + it('compose dir is created with mode 0o700', () => { + materializeComposeTemplate('hs', { + distDir: FIXTURE_DIR, + townhouseHome: tmpHome, + }); + const composeDir = join(tmpHome, 'compose'); + const mode = statSync(composeDir).mode & 0o777; + expect(mode).toBe(0o700); + }); + + it('is idempotent — second call overwrites first, mode stays 0o600, no errors', () => { + const opts = { distDir: FIXTURE_DIR, townhouseHome: tmpHome }; + const first = materializeComposeTemplate('hs', opts); + const second = materializeComposeTemplate('hs', opts); + expect(first.composePath).toBe(second.composePath); + const mode = statSync(second.composePath).mode & 0o777; + expect(mode).toBe(0o600); + }); + + it('throws ComposeLoaderError for hs profile when manifest is absent', () => { + // Use a fixture dir that has the HS compose but no image-manifest.json. + const noManifestDir = mkdtempSync(join(tmpdir(), 'no-manifest-hs-')); + try { + mkdirSync(join(noManifestDir, 'compose'), { recursive: true }); + copyFileSync( + join(FIXTURE_DIR, 'compose', 'townhouse-hs.yml'), + join(noManifestDir, 'compose', 'townhouse-hs.yml') + ); + expect(() => + materializeComposeTemplate('hs', { + distDir: noManifestDir, + townhouseHome: tmpHome, + }) + ).toThrowError(ComposeLoaderError); + } finally { + rmSync(noManifestDir, { recursive: true, force: true }); + } + }); + + it('does NOT throw for dev profile when manifest is absent', () => { + // Use a fixture dir that has the dev compose but no image-manifest.json. + const noManifestDir = mkdtempSync(join(tmpdir(), 'no-manifest-dev-')); + try { + mkdirSync(join(noManifestDir, 'compose'), { recursive: true }); + copyFileSync( + join(FIXTURE_DIR, 'compose', 'townhouse-dev.yml'), + join(noManifestDir, 'compose', 'townhouse-dev.yml') + ); + // dev profile should not throw even with no manifest + expect(() => + materializeComposeTemplate('dev', { + distDir: noManifestDir, + townhouseHome: tmpHome, + }) + ).not.toThrow(); + } finally { + rmSync(noManifestDir, { recursive: true, force: true }); + } + }); + + it('mode is 0o600 regardless of process umask (chmodSync enforces explicitly)', () => { + // process.umask() cannot be set in vitest worker threads. The typical default + // umask is 0o022 (inherited from the parent process), which is already in effect + // here — so this test verifies that chmodSync enforces 0o600 under that umask. + const { composePath } = materializeComposeTemplate('hs', { + distDir: FIXTURE_DIR, + townhouseHome: tmpHome, + }); + const mode = statSync(composePath).mode & 0o777; + expect(mode).toBe(0o600); + }); +}); diff --git a/packages/townhouse/src/compose-loader.ts b/packages/townhouse/src/compose-loader.ts new file mode 100644 index 00000000..3b1df583 --- /dev/null +++ b/packages/townhouse/src/compose-loader.ts @@ -0,0 +1,101 @@ +import { readFileSync, writeFileSync, mkdirSync, chmodSync, existsSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { homedir } from 'node:os'; + +export type ComposeProfile = 'dev' | 'hs'; + +export interface ComposeLoaderOptions { + /** Override default `~/.townhouse/` write target. Used by tests. */ + townhouseHome?: string; + /** Override the package-relative dist directory the loader reads from. + * Defaults to the `dist/` adjacent to compose-loader.js at runtime. + * Tests use this to point at fixture directories. */ + distDir?: string; +} + +export class ComposeLoaderError extends Error { + constructor(message: string) { + super(message); + this.name = 'ComposeLoaderError'; + } +} + +function defaultDistDir(): string { + // Resolves to `dist/` adjacent to the bundled output at runtime. + // When bundled by tsup, import.meta.url is the path of dist/index.js, + // so dirname = /dist. resolve(/dist, '..', 'dist') = /dist. + // When running via tsx/ts-node from src/, dirname = /src, + // so resolve(/src, '..', 'dist') = /dist. Both work. + const here = dirname(fileURLToPath(import.meta.url)); + return resolve(here, '..', 'dist'); +} + +/** + * Returns the rendered compose YAML for the requested profile. + * For 'hs', digest substitutions are already applied (resolved at build time). + * For 'dev', the YAML is returned verbatim (uses local `toon:*` image tags). + * Throws `ComposeLoaderError` if the requested profile's YAML is unreadable. + */ +export function loadComposeTemplate( + profile: ComposeProfile, + options: ComposeLoaderOptions = {} +): string { + const distDir = options.distDir ?? defaultDistDir(); + const composePath = join(distDir, 'compose', `townhouse-${profile}.yml`); + if (!existsSync(composePath)) { + throw new ComposeLoaderError( + `compose template not found: ${composePath}. ` + + `Did you run 'pnpm --filter @toon-protocol/townhouse build' first?` + ); + } + return readFileSync(composePath, 'utf-8'); +} + +/** + * Writes the resolved compose YAML to `/compose/.yml` + * and copies `dist/image-manifest.json` to `/image-manifest.json`. + * BOTH output files are written with mode 0o600 (NFR8 — operator-secret file mode). + * Returns the absolute paths of the two files written. + */ +export function materializeComposeTemplate( + profile: ComposeProfile, + options: ComposeLoaderOptions = {} +): { composePath: string; manifestPath: string } { + const home = options.townhouseHome ?? join(homedir(), '.townhouse'); + const composeDir = join(home, 'compose'); + + mkdirSync(composeDir, { recursive: true }); + // chmod after mkdir for already-existing dirs (mkdir's mode arg is only + // honored on creation). Defensive re-chmod enforces 0o700 on every call. + chmodSync(home, 0o700); + chmodSync(composeDir, 0o700); + + const yaml = loadComposeTemplate(profile, options); + const composePath = join(composeDir, `townhouse-${profile}.yml`); + writeFileSync(composePath, yaml, { mode: 0o600, encoding: 'utf-8' }); + // Defensive re-chmod: writeFileSync's mode option is masked by process.umask() + // on some Linux filesystems (notably WSL2). chmodSync is the load-bearing call. + chmodSync(composePath, 0o600); + + const distDir = options.distDir ?? defaultDistDir(); + const manifestSrc = join(distDir, 'image-manifest.json'); + const manifestPath = join(home, 'image-manifest.json'); + + if (existsSync(manifestSrc)) { + const manifest = readFileSync(manifestSrc, 'utf-8'); + writeFileSync(manifestPath, manifest, { mode: 0o600, encoding: 'utf-8' }); + chmodSync(manifestPath, 0o600); + } else { + // Manifest is required for HS mode — fail loudly. Dev mode tolerates absence. + if (profile === 'hs') { + throw new ComposeLoaderError( + `image-manifest.json not found at ${manifestSrc}. ` + + `HS mode requires a digest-pinned image manifest. ` + + `Reinstall @toon-protocol/townhouse from npm to restore the manifest.` + ); + } + } + + return { composePath, manifestPath }; +} diff --git a/packages/townhouse/src/constants.ts b/packages/townhouse/src/constants.ts index 5642cf41..6aea17a7 100644 --- a/packages/townhouse/src/constants.ts +++ b/packages/townhouse/src/constants.ts @@ -12,13 +12,19 @@ export const CONTAINER_PREFIX = 'townhouse-'; export const NODE_BTP_PORT = 3000; /** - * Default connector Docker image tag — single source of truth for the workspace. + * Default connector Docker image — digest-pinned per CONNECTOR_RELEASE_CONTRACT.md. * - * To bump: update this constant, run `pnpm --filter @toon-protocol/townhouse test contract-canary`, - * then `pnpm --filter @toon-protocol/townhouse test:canary`. See packages/sdk/CONNECTOR_MIGRATION.md - * for the full checklist and breaking-changes history. + * To bump: capture a new digest by running the Story 45.1 publish workflow + * against the desired connector tag, copy the resulting image-manifest.json + * connector entry's digest, and update this constant + the contract canary + * fixture. See packages/sdk/CONNECTOR_RELEASE_CONTRACT.md for the full bump + * checklist + breaking-changes history. + * + * To read the human-readable tag for log output, consult dist/image-manifest.json: + * manifest.images.connector.tag */ -export const DEFAULT_CONNECTOR_IMAGE = 'ghcr.io/toon-protocol/connector:3.4.1'; +export const DEFAULT_CONNECTOR_IMAGE = + 'ghcr.io/toon-protocol/connector@sha256:4a24ccb0997d7b025997e670546032f6a84cd18a77c490509016b85e181a344e'; /** * HD wallet account indices per node type (Story 21.4, D21-008). diff --git a/packages/townhouse/src/index.ts b/packages/townhouse/src/index.ts index 806c898f..b72219f9 100644 --- a/packages/townhouse/src/index.ts +++ b/packages/townhouse/src/index.ts @@ -63,6 +63,13 @@ export type { EncryptedWallet, } from './wallet/index.js'; +export { + loadComposeTemplate, + materializeComposeTemplate, + ComposeLoaderError, +} from './compose-loader.js'; +export type { ComposeProfile, ComposeLoaderOptions } from './compose-loader.js'; + export { createApiServer, createWizardApiServer } from './api/index.js'; export type { ApiServer, diff --git a/packages/townhouse/tsup.config.ts b/packages/townhouse/tsup.config.ts index a3eb9d32..539858a5 100644 --- a/packages/townhouse/tsup.config.ts +++ b/packages/townhouse/tsup.config.ts @@ -1,4 +1,6 @@ import { defineConfig } from 'tsup'; +import { cp, mkdir, readFile, writeFile, access } from 'node:fs/promises'; +import { join } from 'node:path'; export default defineConfig({ entry: ['src/index.ts', 'src/cli.ts'], @@ -11,4 +13,51 @@ export default defineConfig({ banner: { js: `import { createRequire } from 'module'; const require = createRequire(import.meta.url);`, }, + onSuccess: async () => { + const composeDistDir = 'dist/compose'; + await mkdir(composeDistDir, { recursive: true }); + + // Copy dev template verbatim (no digest substitution — uses local toon:* tags). + await cp('compose/townhouse-dev.yml', join(composeDistDir, 'townhouse-dev.yml')); + + // Render HS template — substitute digest placeholders from image-manifest.json + // if present. When absent (typical local dev), emit a warning and ship the + // unsubstituted template. CI calls scripts/render-compose-template.mjs AFTER + // download-artifact places the manifest, so the authoritative substitution + // happens there (not here). This path is belt-and-suspenders for local builds + // where the developer has manually placed the manifest. + const manifestPath = 'dist/image-manifest.json'; + const hsTemplateRaw = await readFile('compose/townhouse-hs.yml', 'utf-8'); + let hsRendered = hsTemplateRaw; + + try { + await access(manifestPath); + const manifestRaw = await readFile(manifestPath, 'utf-8'); + const manifest = JSON.parse(manifestRaw) as { + images: Record; + }; + + const subs: Array<[string, string]> = [ + ['${TOON_TOWNHOUSE_API_DIGEST}', `@${manifest.images['townhouse-api'].digest}`], + ['${TOON_TOWN_DIGEST}', `@${manifest.images.town.digest}`], + ['${TOON_MILL_DIGEST}', `@${manifest.images.mill.digest}`], + ['${TOON_DVM_DIGEST}', `@${manifest.images.dvm.digest}`], + ['${TOON_CONNECTOR_DIGEST}', `@${manifest.images.connector.digest}`], + ]; + + for (const [placeholder, replacement] of subs) { + hsRendered = hsRendered.replaceAll(placeholder, replacement); + } + } catch { + // Manifest absent — ship the unsubstituted template. This is the normal + // local-dev path. The CI tarball-content verification step catches + // unsubstituted placeholders before pnpm publish runs. + console.warn( + '[tsup] dist/image-manifest.json not found — shipping unsubstituted ' + + 'townhouse-hs.yml. This is fine for local dev but invalid for npm publish.' + ); + } + + await writeFile(join(composeDistDir, 'townhouse-hs.yml'), hsRendered, 'utf-8'); + }, }); diff --git a/scripts/render-compose-template.mjs b/scripts/render-compose-template.mjs new file mode 100644 index 00000000..71c0d4da --- /dev/null +++ b/scripts/render-compose-template.mjs @@ -0,0 +1,82 @@ +#!/usr/bin/env node +/** + * Render packages/townhouse/compose/townhouse-hs.yml against the digest + * values in packages/townhouse/dist/image-manifest.json and write the + * result to packages/townhouse/dist/compose/townhouse-hs.yml. + * + * Also copies packages/townhouse/compose/townhouse-dev.yml verbatim to + * packages/townhouse/dist/compose/townhouse-dev.yml. + * + * This script is callable from CI AFTER actions/download-artifact drops + * image-manifest.json into packages/townhouse/dist/. That two-step sequence + * (build → download-artifact → render) avoids the tsup clean:true issue where + * a full `pnpm build` would wipe dist/ (and the just-placed manifest) on start. + * + * Usage (CI): + * node scripts/render-compose-template.mjs + * + * Usage (local smoke test — manifest must exist in dist/ first): + * cp /tmp/45-1-artifact/image-manifest.json packages/townhouse/dist/ + * node scripts/render-compose-template.mjs + */ + +import { readFile, writeFile, cp, mkdir, access } from 'node:fs/promises'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = join(__dirname, '..'); +const PKG_DIR = join(REPO_ROOT, 'packages', 'townhouse'); +const COMPOSE_SRC_DIR = join(PKG_DIR, 'compose'); +const DIST_DIR = join(PKG_DIR, 'dist'); +const COMPOSE_DIST_DIR = join(DIST_DIR, 'compose'); +const MANIFEST_PATH = join(DIST_DIR, 'image-manifest.json'); +const HS_TEMPLATE_PATH = join(COMPOSE_SRC_DIR, 'townhouse-hs.yml'); +const DEV_TEMPLATE_PATH = join(COMPOSE_SRC_DIR, 'townhouse-dev.yml'); + +async function run() { + await mkdir(COMPOSE_DIST_DIR, { recursive: true }); + + // Copy dev template verbatim — no digest substitution (uses local toon:* tags). + await cp(DEV_TEMPLATE_PATH, join(COMPOSE_DIST_DIR, 'townhouse-dev.yml')); + + const hsTemplateRaw = await readFile(HS_TEMPLATE_PATH, 'utf-8'); + let hsRendered = hsTemplateRaw; + + try { + await access(MANIFEST_PATH); + const manifestRaw = await readFile(MANIFEST_PATH, 'utf-8'); + const manifest = JSON.parse(manifestRaw); + + const subs = [ + ['${TOON_TOWNHOUSE_API_DIGEST}', `@${manifest.images['townhouse-api'].digest}`], + ['${TOON_TOWN_DIGEST}', `@${manifest.images.town.digest}`], + ['${TOON_MILL_DIGEST}', `@${manifest.images.mill.digest}`], + ['${TOON_DVM_DIGEST}', `@${manifest.images.dvm.digest}`], + ['${TOON_CONNECTOR_DIGEST}', `@${manifest.images.connector.digest}`], + ]; + + for (const [placeholder, replacement] of subs) { + hsRendered = hsRendered.replaceAll(placeholder, replacement); + } + + console.log('[render-compose-template] HS template rendered with 5 digest substitutions.'); + } catch (err) { + // Manifest absent — ship unsubstituted template with a loud warning. + // Acceptable for local dev; the tarball-content verification step in CI + // catches unsubstituted placeholders before pnpm publish runs. + console.warn( + '[render-compose-template] WARNING: dist/image-manifest.json not found — ' + + 'shipping unsubstituted townhouse-hs.yml. ' + + 'This is fine for local dev but invalid for npm publish.' + ); + } + + await writeFile(join(COMPOSE_DIST_DIR, 'townhouse-hs.yml'), hsRendered, 'utf-8'); + console.log('[render-compose-template] Done — dist/compose/{townhouse-hs,townhouse-dev}.yml written.'); +} + +run().catch((err) => { + console.error('[render-compose-template] FATAL:', err.message); + process.exit(1); +}); From 89234c5a890a026bafb164c9edc608a22da9bb92 Mon Sep 17 00:00:00 2001 From: Jonathan Green Date: Sat, 9 May 2026 16:37:59 -0400 Subject: [PATCH 2/5] review(townhouse): apply Story 45.2 code-review patches (R1 + R2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two rounds of bmad-code-review on Story 45.2 (embed compose templates + image-manifest in npm tarball). Round 1 applied 19 patches across compose loader, render scripts, CI tarball-verify, dev compose secret handling, manifest-alignment test, and orchestrator RepoDigests cache. Round 2 caught 1 BLOCKER (CI verify regex matched 0 lines because of a leading-space bug) plus 5 MAJOR + 5 MINOR follow-up issues; all 11 are fixed here. Significant changes: - compose-loader: validate profile + townhouseHome at runtime; reject system paths; lstat both directories AND file paths to refuse symlink writes; only narrow modes (never widen tighter modes like 0o500 → 0o700). - tsup + render-compose-template: split error handling so ENOENT warns but schema/digest errors throw; both now import a single getImageDigest helper from scripts/lib/image-manifest-digest.mjs (eliminates the drift hazard Round 1 deferred). - CI workflow: regex now correctly anchors on YAML shape (^[[:space:]]+image:[[:space:]]+...@sha256:[a-f0-9]{64}[[:space:]]*$), uses mktemp dirs and anchored grep, and enforces a non-zero IMAGE_LINES floor so a structural refactor can't pass via 0===0. - HS compose template: townhouse-api gets the missing environment block (TOWNHOUSE_CONFIG=/.townhouse/config.yaml + TOWNHOUSE_WALLET_PASSWORD:? passthrough so docker compose up fails fast on unset password); top comment documents the intentional canonical-port choice and the HS-vs-dev-stack non-concurrent-run constraint. - dev compose template: 7 secret env vars switched from ${VAR:-} (silent empty-string default) to ${VAR:?msg} (fail on unset). - Tests: new manifest-alignment test (D2) hard-fails in CI when manifest absent; new negative test asserts compose-config exits non-zero without TOWNHOUSE_WALLET_PASSWORD; idempotency test now compares file content; tarball-contents test asserts dist/compose freshness precondition with an actionable error. - orchestrator.pullImages: cache check now matches RepoTags ∪ RepoDigests so digest-form DEFAULT_CONNECTOR_IMAGE is recognized as cached. - Docs: README + story spec + deferred-work updated; spec scope-guard amendments ratify the orchestrator + root docker-compose touch retroactively. 98 unit tests + 13 integration tests pass; lint clean. Story status moves review → in-progress; AC #12 close-out (sprint-status flip to done) gated on tag-push + registry verify per spec. Co-Authored-By: Claude Sonnet 4.6 --- .../workflows/publish-townhouse-images.yml | 40 +++-- ...lates-and-image-manifest-in-npm-tarball.md | 98 +++++++++++- .../implementation-artifacts/deferred-work.md | 24 +++ .../sprint-status.yaml | 4 +- packages/townhouse/README.md | 11 ++ packages/townhouse/compose/townhouse-dev.yml | 15 +- packages/townhouse/compose/townhouse-hs.yml | 32 ++++ .../compose-template-validity.test.ts | 43 ++++- .../connector-image-contract.test.ts | 45 +++++- .../__integration__/tarball-contents.test.ts | 37 +++-- packages/townhouse/src/compose-loader.test.ts | 12 +- packages/townhouse/src/compose-loader.ts | 148 +++++++++++++++--- packages/townhouse/src/docker/orchestrator.ts | 16 +- packages/townhouse/tsup.config.ts | 60 ++++--- scripts/lib/image-manifest-digest.mjs | 33 ++++ scripts/render-compose-template.mjs | 54 ++++--- 16 files changed, 552 insertions(+), 120 deletions(-) create mode 100644 scripts/lib/image-manifest-digest.mjs diff --git a/.github/workflows/publish-townhouse-images.yml b/.github/workflows/publish-townhouse-images.yml index 10cc8cf9..d47629db 100644 --- a/.github/workflows/publish-townhouse-images.yml +++ b/.github/workflows/publish-townhouse-images.yml @@ -277,23 +277,45 @@ jobs: # and the HS YAML has no unsubstituted placeholders before publishing. run: | set -euo pipefail - mkdir -p /tmp/pack-out /tmp/pack-extracted - pnpm --filter @toon-protocol/townhouse pack --pack-destination /tmp/pack-out/ - TGZ=$(ls /tmp/pack-out/toon-protocol-townhouse-*.tgz | head -1) - tar -tzf "$TGZ" > /tmp/pack-listing.txt + PACK_OUT=$(mktemp -d) + PACK_EXTRACT=$(mktemp -d) + pnpm --filter @toon-protocol/townhouse pack --pack-destination "$PACK_OUT" + # Globbing for the tgz is fine because $PACK_OUT is fresh from mktemp. + TGZ=$(ls "$PACK_OUT"/toon-protocol-townhouse-*.tgz | head -1) + [ -f "$TGZ" ] || { echo "FAIL: pnpm pack did not produce a tgz in $PACK_OUT"; exit 1; } + tar -tzf "$TGZ" > "$PACK_OUT/listing.txt" for path in \ package/dist/compose/townhouse-hs.yml \ package/dist/compose/townhouse-dev.yml \ package/dist/image-manifest.json; do - grep -qF "$path" /tmp/pack-listing.txt \ + # Anchor the match — a `*.bak` or partial-name file would otherwise + # satisfy a substring grep without the actual file existing. + grep -qE "^${path}\$" "$PACK_OUT/listing.txt" \ || { echo "MISSING from tarball: $path"; exit 1; } done - tar -xzf "$TGZ" -C /tmp/pack-extracted/ - if grep -E '\$\{TOON_[A-Z_]+_DIGEST\}' \ - /tmp/pack-extracted/package/dist/compose/townhouse-hs.yml; then + tar -xzf "$TGZ" -C "$PACK_EXTRACT" + HS_YAML="$PACK_EXTRACT/package/dist/compose/townhouse-hs.yml" + # Negative: no unsubstituted placeholders. + if grep -E '\$\{TOON_[A-Z_]+_DIGEST\}' "$HS_YAML"; then echo "FAIL: unsubstituted placeholders in tarball HS YAML"; exit 1 fi - echo "✅ Tarball contains all required artifacts with no unsubstituted placeholders" + # Positive: every services..image must use full digest form. + # Single regex enforces YAML shape (' image: '), digest form + # (@sha256:<64hex>), and tolerates trailing CR / whitespace. Catches + # orphaned '@' (empty-digest substitution) and tag-form refs because + # neither matches the strict 64-hex tail. The IMAGE_LINES floor stops + # a structural refactor that drops all 'image:' keys from passing the + # gate via 0===0. + IMAGE_LINES=$(grep -cE '^[[:space:]]+image:[[:space:]]+' "$HS_YAML" || true) + DIGEST_LINES=$(grep -cE '^[[:space:]]+image:[[:space:]]+[^[:space:]]+@sha256:[a-f0-9]{64}[[:space:]]*$' "$HS_YAML" || true) + if [ "$IMAGE_LINES" -lt 1 ]; then + echo "FAIL: no image: lines found in $HS_YAML — structural defect in template"; exit 1 + fi + if [ "$IMAGE_LINES" -ne "$DIGEST_LINES" ]; then + echo "FAIL: $IMAGE_LINES image: lines but only $DIGEST_LINES use @sha256:<64hex> digest form" + exit 1 + fi + echo "✅ Tarball contains all required artifacts with no unsubstituted placeholders ($DIGEST_LINES digest-pinned images)" - name: Publish to npm run: pnpm --filter @toon-protocol/townhouse publish --access public --no-git-checks diff --git a/_bmad-output/implementation-artifacts/45-2-embed-compose-templates-and-image-manifest-in-npm-tarball.md b/_bmad-output/implementation-artifacts/45-2-embed-compose-templates-and-image-manifest-in-npm-tarball.md index e2904b09..f62c78e3 100644 --- a/_bmad-output/implementation-artifacts/45-2-embed-compose-templates-and-image-manifest-in-npm-tarball.md +++ b/_bmad-output/implementation-artifacts/45-2-embed-compose-templates-and-image-manifest-in-npm-tarball.md @@ -1,6 +1,6 @@ # Story 45.2: Embed Compose Templates + Image Manifest in npm Tarball -Status: review +Status: in-progress (post-review patches applied 2026-05-09; AC #12 close-out gated on tag-push + registry verify) > **CRITICAL PATH — second story of Epic 45 (One-Command Apex Install).** Sized M by the plan. Story 45.4 (`townhouse hs up` subcommand) cannot start until this story lands the embedded compose template and the `loadComposeTemplate()` API. This story is also what flips the `--dry-run` flag in the Story 45.1 publish workflow to live `npm publish` — without an `image-manifest.json` and a digest-resolved compose template inside the tarball, an operator running `npx @toon-protocol/townhouse hs up` has no compose file to feed `docker compose -f` against. @@ -483,6 +483,8 @@ Files this story touches in `toon-protocol/town`: - `CLAUDE.md` (MODIFY — Task 9.2 "Where to Find Things" rows) - `packages/sdk/CONNECTOR_RELEASE_CONTRACT.md` (MODIFY — Task 9.3 implementation reference) - `.github/workflows/publish-townhouse-images.yml` (MODIFY — Task 7, remove `--dry-run`, add render + verify steps) +- `docker-compose-townhouse.yml` (root) (MODIFY — connector image flipped to digest form so `package-structure.test.ts:109` (`connector.image === DEFAULT_CONNECTOR_IMAGE`) stays green; ratified retroactively in code-review 2026-05-09 D2 → Option 1, with a new manifest-alignment test in `connector-image-contract.test.ts` closing the third drift gap) +- `packages/townhouse/src/docker/orchestrator.ts` (MODIFY — `pullImages` cache check matches `RepoDigests` in addition to `RepoTags` so digest-form `DEFAULT_CONNECTOR_IMAGE` is recognized as cached; ratified retroactively in code-review 2026-05-09 D2 patches) - `_bmad-output/implementation-artifacts/sprint-status.yaml` (MODIFY — Task 11.3) - This story file (Task 11.4) @@ -494,7 +496,7 @@ Files this story does **NOT** touch (scope guards): - `scripts/build-image-manifest.test.ts` — Story 45.1 test; do not modify. - `docker/Dockerfile.townhouse-api` — Story 45.1 deliverable; the new HS template references the resulting image but does not modify the Dockerfile. - `docker/Dockerfile.town`, `docker/Dockerfile.mill`, `docker/Dockerfile.dvm` — pre-existing; the workflow consumes them as-is. -- `packages/townhouse/src/docker/orchestrator.ts` — DockerOrchestrator changes are Story 45.3 territory (the `profile: 'dev' | 'hs'` parameter). This story's `loadComposeTemplate` API is what 45.3 will call from inside the orchestrator. +- `packages/townhouse/src/docker/orchestrator.ts` — the `profile: 'dev' | 'hs'` parameter refactor is Story 45.3 territory. This story's `loadComposeTemplate` API is what 45.3 will call from inside the orchestrator. **EXCEPTION (ratified 2026-05-09 code-review D2):** `pullImages` cache check was extended from `RepoTags`-only to `RepoTags ∪ RepoDigests` so the digest-form `DEFAULT_CONNECTOR_IMAGE` is recognized as cached. That single-line change is the only orchestrator modification in scope. - `packages/townhouse/src/cli.ts` — the `townhouse hs up` subcommand is Story 45.4 territory; this story's loader is what the subcommand will call. - `packages/townhouse/src/api/` — host API changes are Story 45.4 territory. - `packages/townhouse/src/wallet/` — HD wallet code is Story 21.4 / 45.4 territory. @@ -811,4 +813,94 @@ claude-sonnet-4-6 ### Review Findings -_To be filled in after code review_ +**Code review run: 2026-05-09 — Blind Hunter (22), Edge Case Hunter (16), Acceptance Auditor (6) → triaged into 3 decisions resolved (→ 3 patches), 16 original patches, 9 deferred, 4 dismissed. Total: 19 patches.** + +#### Decisions resolved (2026-05-09) + +- **D1 → Patch:** `townhouse-api` won't boot as written — resolved with **option 1**: add `environment:` block to the HS template setting `TOWNHOUSE_CONFIG=/.townhouse/config.yaml` and `TOWNHOUSE_WALLET_PASSWORD: ${TOWNHOUSE_WALLET_PASSWORD}` (host-env passthrough; operator sets it before `docker compose up`). Self-contained — does not depend on Story 45.4. [`packages/townhouse/compose/townhouse-hs.yml:95-118`] +- **D2 → Patch:** `docker-compose-townhouse.yml` (root) modified outside scope — resolved with **option 1**: accept 3 sources of truth (`constants.ts`, root compose, rendered HS compose). Add a manifest-alignment test asserting `manifest.images.connector.digest === parseConnectorImage(DEFAULT_CONNECTOR_IMAGE).digest` in `connector-image-contract.test.ts` (reuse the existing parser at lines 32-39). Update the spec's "Files this story touches" list in Dev Notes to acknowledge `docker-compose-townhouse.yml`. Existing `package-structure.test.ts:109` continues catching constant↔root-file drift. [`docker-compose-townhouse.yml:1085-1091`, `packages/townhouse/src/__integration__/connector-image-contract.test.ts`] +- **D3 → Documentation patch:** HS template port collisions with dev stack — resolved with **option 1**: accept by design. Document in the HS template's top comment block and in `packages/townhouse/README.md` that HS-mode and dev stack must not run concurrently on the same machine (HS uses canonical ports; dev stack uses 28xxx). No code change. + +#### Patch (unambiguous fixes) — ALL APPLIED 2026-05-09 + +- [x] [Review][Patch] **D1: Add `environment:` block to `townhouse-api` service** with `TOWNHOUSE_CONFIG=/.townhouse/config.yaml` + `TOWNHOUSE_WALLET_PASSWORD: ${TOWNHOUSE_WALLET_PASSWORD:?...}` host-env passthrough [`packages/townhouse/compose/townhouse-hs.yml:95-118`] +- [x] [Review][Patch] **D2: Add manifest-alignment test** asserting `manifest.images.connector.digest` matches the parsed `DEFAULT_CONNECTOR_IMAGE` digest; updated story Dev Notes touched-files list to include `docker-compose-townhouse.yml` and `orchestrator.ts` [`packages/townhouse/src/__integration__/connector-image-contract.test.ts`] +- [x] [Review][Patch] **D3: Document HS-vs-dev-stack port collision** in HS template top comment + `packages/townhouse/README.md` [`packages/townhouse/compose/townhouse-hs.yml`, `packages/townhouse/README.md`] + +- [x] [Review][Patch] **`DockerOrchestrator.pullImages` cache-hit only checks `RepoTags`, never matches digest-form refs** [`packages/townhouse/src/docker/orchestrator.ts:486-501`] +- [x] [Review][Patch] **`materializeComposeTemplate` writes compose BEFORE validating manifest — leaves torn state on missing manifest in HS profile** [`packages/townhouse/src/compose-loader.ts:74-86`] +- [x] [Review][Patch] **`mkdirSync` missing spec-mandated `mode: 0o700` argument (Task 4.1 + AC #7)** [`packages/townhouse/src/compose-loader.ts:68`] +- [x] [Review][Patch] **`chmodSync(home, 0o700)` unconditionally clobbers operator-managed `~/.townhouse/` mode + follows symlinks (no `lstatSync` check)** [`packages/townhouse/src/compose-loader.ts:71-72`] +- [x] [Review][Patch] **`townhouseHome === ''` (empty string) and `homedir() === '/'` slip through `??` and target unsafe paths (CWD or filesystem root)** [`packages/townhouse/src/compose-loader.ts:65`] +- [x] [Review][Patch] **Render scripts swallow ALL errors as "manifest absent" (JSON parse, missing image keys, empty digests)** [`packages/townhouse/tsup.config.ts:51-58`, `scripts/render-compose-template.mjs:64-72`] +- [x] [Review][Patch] **No digest-format validation — empty/malformed digest produces orphaned `@` in image ref; CI verify regex misses it** [`scripts/render-compose-template.mjs:51-57`, `.github/workflows/publish-townhouse-images.yml:75-95`] +- [x] [Review][Patch] **No build-time test that `DEFAULT_CONNECTOR_IMAGE` digest matches `image-manifest.json` (will silently drift on next bump)** [`packages/townhouse/src/__integration__/connector-image-contract.test.ts`] +- [x] [Review][Patch] **`profile` parameter has no runtime validation — TS `'dev' | 'hs'` is erased at runtime; arbitrary input enables path traversal in `materializeComposeTemplate`** [`packages/townhouse/src/compose-loader.ts:74-79`] +- [x] [Review][Patch] **dev compose template ships `${SECRET_VAR:-}` empty-string defaults — secrets-as-empty-string leak into containers** [`packages/townhouse/compose/townhouse-dev.yml`] +- [x] [Review][Patch] **`tarball-contents.test.ts` doesn't run `pnpm build` first — passes against stale dist** [`packages/townhouse/src/__integration__/tarball-contents.test.ts:2096-2121`] +- [x] [Review][Patch] **`compose-template-validity.test.ts` lacks AC-mandated explicit `'\b0\.0\.0\.0:'` grep (Task 2.7)** [`packages/townhouse/src/__integration__/compose-template-validity.test.ts:1930-1950`] +- [x] [Review][Patch] **CI `Verify tarball contents` step uses non-`mktemp` `/tmp/pack-extracted` — workflow rerun on same runner sees stale state** [`.github/workflows/publish-townhouse-images.yml`] +- [x] [Review][Patch] **CI verify uses `grep -qF "$path"` substring match — could false-positive against `*.bak` or partial-name files** [`.github/workflows/publish-townhouse-images.yml`] +- [x] [Review][Patch] **`describe.skipIf` empty-`it` reports as PASSED (green) not SKIPPED in CI output — use `it.skip()` or `ctx.skip()`** [`packages/townhouse/src/__integration__/compose-template-validity.test.ts:2001-2009`] +- [x] [Review][Patch] **`writeFileSync` mode comment misdiagnoses umask masking — `chmod` is needed for the "file already exists" case, not umask masking on `0o600`** [`packages/townhouse/src/compose-loader.ts:88-89`] + +#### Deferred (pre-existing or low-priority) + +- [x] [Review][Defer] Concurrent `materializeComposeTemplate` calls race — non-atomic write; no `tmp + rename` pattern +- [x] [Review][Defer] `defaultDistDir()` `import.meta.url` resolution fragile under non-tsup bundlers (Story 45.4+ concern) +- [x] [Review][Defer] No idempotency guard on `pnpm publish` rerun — npm 409 on duplicate version +- [x] [Review][Defer] `tarball-contents.test.ts` stdout-parsing of `pnpm pack` brittle under future pnpm minor versions +- [x] [Review][Defer] `DOCKER_AVAILABLE=1` env bypass skips real daemon probe — 30s timeout instead of clean skip when daemon dead +- [x] [Review][Defer] Lifecycle-script asymmetry between `pnpm pack` (verify) and `pnpm publish` (live) — no `--ignore-scripts` guard +- [x] [Review][Defer] Brief TOCTOU readability between `writeFileSync` and `chmodSync` for manifest copy +- [x] [Review][Defer] `tsup` `onSuccess` + `render-compose-template.mjs` duplicate substitution arrays — drift risk when adding a 6th image +- [x] [Review][Defer] `pnpm pack --pack-destination` requires pnpm ≥ 8.4 — workflow's `pnpm/action-setup` version not pinned in this diff + +#### Dismissed (noise / false positives) + +- tsup config `node:` prefix not type-checked — works fine; build is the validation +- `tar -xzf` extraction without `--no-same-owner --no-same-permissions` — pnpm tarballs are trustworthy +- `--tag latest` flag missing from publish — npm defaults to `latest` tag anyway; pre-existing +- `docker compose config` regex matches synthetic all-`e`s digests in test fixtures — by design (parse-validation, not registry validation) + +### Review Findings — Round 2 (post-patches, 2026-05-09) + +**Re-review summary: 1 BLOCKER, 5 MAJOR, 5 MINOR, plus 9 NITs/defers and 1 dismissed. ALL 11 PATCHES APPLIED 2026-05-09.** + +The Round-1 patches mostly landed clean, but Round 2 caught one BLOCKER (a regex I introduced that fails 100% of the time) plus several incomplete fixes where the patch addressed one branch but missed a parallel branch on the same code path. + +#### BLOCKER + +- [x] [Review][Patch][R2-BLOCKER] **CI tarball-verify positive-digest-form regex matches 0 lines — every publish would fail** — `.github/workflows/publish-townhouse-images.yml:97-101` uses `grep -cE 'image:[^[:space:]]+@sha256:[a-f0-9]{64}$'` but YAML emits ` image: ghcr.io/...` (space after `image:`). The `[^[:space:]]+` class can never match the leading space, so `DIGEST_LINES=0` while `IMAGE_LINES=5`. Verified empirically against the rendered HS YAML. Fix: change to `'image:\s+\S+@sha256:[a-f0-9]{64}$'` and add a non-zero floor (`$IMAGE_LINES -gt 0`) so a structural refactor that drops all `image:` keys cannot pass the gate via `0===0`. + +#### MAJOR + +- [x] [Review][Patch][R2-MAJOR] **`writeFileSync` follows symlink at `composePath` and `manifestPath` — symlink guard only checks dirs, not file paths** — Patched dir-level lstat guard (`compose-loader.ts:123-141`) skips chmod when home/composeDir is a symlink, but `writeFileSync(composePath, ...)` and `writeFileSync(manifestPath, ...)` (lines 144, 154) follow file-level symlinks and write through them. Attacker who plants `~/.townhouse/compose/townhouse-hs.yml` as a symlink to `~/.bashrc` gets the rendered YAML written to `.bashrc`. Fix: `lstat` each file path before write; refuse to materialize if symlink, OR open with `O_NOFOLLOW`. +- [x] [Review][Patch][R2-MAJOR] **Mode-narrowing logic widens tighter modes — `0o500` becomes `0o700`** — `compose-loader.ts:135-140`: comment says "operators who deliberately set 0o700 (or tighter, e.g. 0o500) keep their setting" but the check is `if (currentMode !== 0o700) chmod(dir, 0o700)`. `0o500 !== 0o700` → widened to `0o700`. Inverted from the documented promise. Fix: `if (currentMode > 0o700) chmod(dir, 0o700)` (only narrow; never widen). +- [x] [Review][Patch][R2-MAJOR] **Manifest-alignment test skipped when manifest absent — defeats the drift-detection purpose** — `connector-image-contract.test.ts`: `describe.skipIf(!existsSync(MANIFEST_PATH))(...)` skips the alignment assertion in every CI scenario where `dist/image-manifest.json` hasn't been placed (which is most unit-test runs). The test only fires for a developer who manually copied the artifact into dist/ before running tests — i.e., never automatically in CI. Fix: in CI (env `CI=true`), require manifest presence; outside CI, skip with a visible warning. OR add the canary as an explicit step in the publish workflow after `download-artifact`. +- [x] [Review][Patch][R2-MAJOR] **`distDir` / `townhouseHome` path traversal not blocked — `assertValidProfile` is theater** — `compose-loader.ts:46-58`: validates profile string and rejects empty/`/` townhouseHome, but `townhouseHome: '/etc'` passes validation (absolute, non-empty, not `/`). `distDir` has zero validation. Caller could materialize compose YAML into `/etc/compose/`. Fix: validate that townhouseHome resolves under a known-safe root (e.g., reject if `home` is not a descendant of `os.homedir()` unless an explicit override flag is set). +- [x] [Review][Patch][R2-MAJOR] **`tsup.config.ts` and `render-compose-template.mjs` have drifted error contracts** — tsup uses non-null bang (`manifest.images['townhouse-api']!.digest`) which throws raw `TypeError: Cannot read properties of undefined (reading 'digest')` on missing keys; render-script throws explicit `manifest missing image entry: images.townhouse-api`. Same defect, two different error UX. Fix: extract a shared `getDigest(manifest, key)` helper into a single module imported by both. (This was Defer #8 in Round 1; the patch round actually made it worse by introducing the duplicate digest-validation logic.) + +#### MINOR + +- [x] [Review][Patch][R2-MINOR] **`docker compose config` integration test masks `${VAR:?}` failure mode** — `compose-template-validity.test.ts:122,151`: injects `TOWNHOUSE_WALLET_PASSWORD: 'compose-config-validation-only'` into the test env, so a future patch that silently changes `:?` to `:-` keeps the test green. Add a parallel negative test asserting `docker compose config` exits non-zero when the password is unset. +- [x] [Review][Patch][R2-MINOR] **Idempotency test only checks path + mode equality — not content** — `compose-loader.test.ts:103-109`: a regression where the second call wrote different bytes (truncated, wrong template) would not be caught. Add `expect(readFileSync(second.composePath)).toEqual(readFileSync(first.composePath))`. +- [x] [Review][Patch][R2-MINOR] **`scripts/render-compose-template.mjs` doesn't chmod the dist YAML** — `dist/compose/townhouse-hs.yml` lands at the user's umask (typically `0o644`). The README + spec are explicit that the materialized HS YAML is `0o600`, but the pre-tarball build artifact in `dist/` is world-readable. If a CI runner has another untrusted user, that user can read the rendered YAML between the render step and the pack step. Fix: `await chmod(...0o600)` after writing. +- [x] [Review][Patch][R2-MINOR] **HS template profile-gated services still ship `${VAR:-}` empty-string defaults for secrets** — `townhouse-hs.yml`: `NODE_NOSTR_SECRET_KEY: '${TOWN_SECRET_KEY:-}'`, `SETTLEMENT_PRIVATE_KEY: '${TOWN_SETTLEMENT_PRIVATE_KEY:-}'`, mill secrets, `DVM_SECRET_KEY:-`. The patch round only hardened `townhouse-dev.yml`. Profile-gated services (town/mill/dvm) don't boot in this story (Story 45.4 only starts connector + townhouse-api at apex install), but Epic 46 will. Fix: same `${VAR:?msg}` pattern, OR explicitly defer with a TODO for Epic 46. +- [x] [Review][Patch][R2-MINOR] **Story file: `orchestrator.ts` is in both the touched-files list AND the unchanged scope-guard list** — line 487 (touched) vs line 499 (scope-guarded as Story 45.3 territory). Self-contradiction introduced by the D2-Patch list update. Fix: amend the scope-guard line to "DockerOrchestrator profile-param refactor is Story 45.3 territory; this story's RepoDigests cache fix is the ONLY orchestrator change in scope". + +#### NIT / Defer + +- [x] [Review][Defer] D3-Patch port-collision documentation is technically incorrect — host ports `9401`, `28090`, `7100`, `3100`, `3200`, `3400` (HS) do not actually overlap with the dev stack's host bindings (28080, 28100, 28110, 28200, 28210, 28400, 28700, 28710). The "must not run concurrently" guidance is reasonable as a defensive default but the cited mechanism is wrong. — defer doc tidy-up +- [x] [Review][Defer] `describe.skipIf` inverted-logic sibling pattern is structurally clever but only emits a visible "skipped" line in the file-missing case; in the normal case (file present), the sibling describe is silently absent. — defer +- [x] [Review][Defer] TOCTOU between manifest existence check and copy in `materializeComposeTemplate` (race with concurrent `pnpm install --force`) — defer +- [x] [Review][Defer] `loadComposeTemplate` ENOENT race propagates raw `fs.Error` instead of wrapped `ComposeLoaderError` — defer +- [x] [Review][Defer] `compose-template-validity.test.ts` `0\.0\.0\.0:` reject misses YAML long-form `host_ip: 0.0.0.0\n` (no trailing colon) — defer +- [x] [Review][Defer] Connector image cache check uses `includes(parsedRef.digest!)` — substring match where suffix would be safer — defer +- [x] [Review][Defer] `tarball-contents.test.ts` afterAll cleanup deletes the tarball even on test failure — defer +- [x] [Review][Defer] Manifest-alignment test path resolution via `import.meta.url + '../../dist/...'` is fragile under bundler reconfiguration — defer +- [x] [Review][Defer] `tarball-contents.test.ts` "freshness precondition" only checks `existsSync` — stale dist passes the gate — defer + +#### Dismissed (Round 2 false positive) + +- ~~`townhouse-api` `volumes: [~/.townhouse:/.townhouse:rw]` literal `~`~~ — Blind Hunter R2 claimed Compose treats `~` as literal; verified empirically with `docker compose config` (v5.1.3) that `~` IS expanded to `$HOME` in volume bind-mount source paths. Output: `source: /home/jonathan/.townhouse_compose_test`. The mount works as intended. diff --git a/_bmad-output/implementation-artifacts/deferred-work.md b/_bmad-output/implementation-artifacts/deferred-work.md index db79ff6a..0f0204ba 100644 --- a/_bmad-output/implementation-artifacts/deferred-work.md +++ b/_bmad-output/implementation-artifacts/deferred-work.md @@ -165,3 +165,27 @@ _Six cross-repo patches (P3, P4, P5, P6, P7, Q1) shipped in lock-step via [conne - `useWizardState` polls every 2s forever (even after `containers_running: true`) — wasted requests on idle Home view; bounded by SPA tab lifetime; consider stopping the poll once normal mode is stable. - Mnemonic internal-multi-space + ZWSP/Unicode-invisible normalization on import — current `\s+` split handles common whitespace; uncommon paste paths fall through to a generic "Invalid BIP-39" error. - AC-4 validation cascade order in impl differs slightly from spec list order (length before mismatch); both still produce a 400 with a `code`; tests don't pin ordering — align spec or impl in a future polish pass. + +## Deferred from: code review of 45-2-embed-compose-templates-and-image-manifest-in-npm-tarball (2026-05-09) + +- Concurrent `materializeComposeTemplate('hs')` calls race — `mkdirSync` + `chmodSync` + `writeFileSync` is non-atomic; no `tmp + rename` pattern. Low likelihood unless townhouse-api restarts during a CLI invocation. +- `defaultDistDir()` `import.meta.url` resolution assumes tsup output layout — fragile under future bundlers (esbuild/vite/webpack inlining the source). Forward-looking concern for Story 45.4+ when bundled CLI imports the loader. +- No idempotency guard on `pnpm publish` rerun — npm returns 409 if version already published. Workflow rerun on the same `v*` tag fails loudly but ungracefully. Add a pre-flight `npm view ... || pnpm publish ...` shim later. +- `tarball-contents.test.ts` parses `pnpm pack` stdout (`result.trim().split('\n').pop()`) — brittle if pnpm 9+ changes output format or adds trailing summary lines. Fallback `readdirSync` saves it; tighten to readdir-only when convenient. +- `DOCKER_AVAILABLE=1` env override skips real daemon probe in `compose-template-validity.test.ts` — when daemon is dead, test crashes after a 30s timeout instead of cleanly skipping. Add a pre-test `docker info --format '{{.ID}}'` probe. +- Lifecycle-script asymmetry between `pnpm pack` (verify step) and `pnpm publish` (live step) — `prepublishOnly` runs only on publish. If a future PR adds `prepublishOnly: pnpm build`, `tsup`'s `clean: true` wipes the manifest and ships unsubstituted YAML undetected. Add `--ignore-scripts` to the live-publish step. +- Brief TOCTOU readability between `writeFileSync(manifestPath, ..., { mode: 0o600 })` and the follow-up `chmodSync(manifestPath, 0o600)` — on filesystems where the mode option is umask-masked (WSL2), another local process can `open(O_RDONLY)` between the two calls. Marginal — manifest itself is not secret, but the same pattern is used for compose YAML which can carry env-injected secrets. +- `tsup.config.ts onSuccess` and `scripts/render-compose-template.mjs` duplicate the placeholder-substitution arrays (5 entries each). Drift risk when adding a 6th image — refactor to a shared `renderComposeTemplate(distDir, srcDir)` module imported by both. +- `pnpm pack --pack-destination` requires pnpm ≥ 8.4 (added in v7.18 actually — but `--filter` + `--pack-destination` interaction was solidified in 8.4). The workflow's `pnpm/action-setup` version is not visible in this diff — verify it's pinned to ≥ 8.4 to avoid silent flag-ignored failures. + +## Deferred from: code review of 45-2-embed-compose-templates-and-image-manifest-in-npm-tarball — Round 2 (2026-05-09) + +- D3-Patch port-collision documentation in HS template + README is technically incorrect — host ports HS/dev don't actually overlap. The "must not run concurrently" guidance is a reasonable defensive default but cite the right mechanism (canonical ports may collide with non-townhouse system services rather than each other). +- `describe.skipIf` inverted-logic sibling pattern in compose-template-validity.test.ts only emits a visible "skipped" line in the file-missing case; in the normal case (file present), the sibling describe is silently absent. Refactor to emit a single skip from inside the main describe. +- TOCTOU between manifest existence check and copy in `materializeComposeTemplate` (race with concurrent `pnpm install --force`). Low likelihood; consider switching to read-then-write pattern with single fs.readFile that throws on missing. +- `loadComposeTemplate` ENOENT race between `existsSync` check and `readFileSync` propagates raw `fs.Error` instead of wrapped `ComposeLoaderError`. Caller `catch (e instanceof ComposeLoaderError)` mis-routes. +- `compose-template-validity.test.ts` `0\.0\.0\.0:` reject misses YAML long-form `host_ip: 0.0.0.0\n` (no trailing colon). +- Connector image cache check in `connector-image-contract.test.ts` uses `includes(parsedRef.digest!)` — substring match where `endsWith('@' + digest)` would be safer. +- `tarball-contents.test.ts` afterAll cleanup deletes the tarball even on test failure, killing post-mortem inspection. Consider keeping the tarball when an assertion fails (vitest's task context exposes failure state). +- Manifest-alignment test path resolution via `import.meta.url + '../../dist/...'` is fragile under bundler reconfiguration. Same pattern is acknowledged in compose-loader.ts:30. +- `tarball-contents.test.ts` "freshness precondition" only checks `existsSync(DIST_COMPOSE_HS)` — stale dist (e.g., dev rebuilt last week, manifest changed since) passes the gate. Add mtime-vs-source comparison or a digest cross-check against current `image-manifest.json`. diff --git a/_bmad-output/implementation-artifacts/sprint-status.yaml b/_bmad-output/implementation-artifacts/sprint-status.yaml index 89c2b2b8..3bdcc878 100644 --- a/_bmad-output/implementation-artifacts/sprint-status.yaml +++ b/_bmad-output/implementation-artifacts/sprint-status.yaml @@ -1,5 +1,5 @@ # generated: 2026-04-27 -# last_updated: 2026-05-09 (Story 45.2 → review — embed compose templates + image-manifest in npm tarball; remove --dry-run from publish workflow) +# last_updated: 2026-05-09 (Story 45.2 → in-progress — code-review patches applied: 19 patches across compose-loader hardening, render-script error handling, CI tarball-verify + digest validation, dev compose secret-handling, manifest-alignment test, orchestrator RepoDigests cache; AC #12 done-flip pending tag-push + registry verify) # project: toon # project_key: NOKEY # tracking_system: file-system @@ -499,7 +499,7 @@ development_status: # Depends on Epic 44 Story 44.1 for clean hostname surfacing. epic-45: in-progress 45-1-multi-arch-townhouse-image-publish-ci: done # done: workflow run https://github.com/toon-protocol/town/actions/runs/25603167091 produced 4 multi-arch + cosign-signed images and image-manifest.json — town#37 town#38 town#39 town#40 town#41 - 45-2-embed-compose-templates-and-image-manifest-in-npm-tarball: review # CRITICAL PATH + 45-2-embed-compose-templates-and-image-manifest-in-npm-tarball: in-progress # CRITICAL PATH; code-review 2026-05-09 patched; AC #12 done-flip gated on tag-push + registry verify 45-3-docker-orchestrator-profile-param: backlog 45-4-townhouse-hs-up-subcommand-apex-only-boot: backlog # CRITICAL PATH; depends on 44.1 epic-45-retrospective: optional diff --git a/packages/townhouse/README.md b/packages/townhouse/README.md index 2cf22243..c08f156f 100644 --- a/packages/townhouse/README.md +++ b/packages/townhouse/README.md @@ -168,6 +168,17 @@ The published `@toon-protocol/townhouse` package ships two Docker Compose templa | `hs` | `dist/compose/townhouse-hs.yml` | Operator-facing apex boot — digest-pinned GHCR images | | `dev` | `dist/compose/townhouse-dev.yml` | Contributor dev stack — local `toon:*` build images | +> **Port collision warning.** The HS template binds canonical ports +> (`127.0.0.1:9401`, `:28090`, `:7100`, `:3100`, `:3200`, `:3400`); the +> contributor dev stack binds 28xxx-namespaced equivalents (28080:9401, +> 28100:3100, 28110:3100, 28200:3200, 28210:3200, 28400:3400, 28700:7100, +> 28710:7100). HS-mode and the dev stack (`scripts/townhouse-dev-infra.sh`) +> **must not run concurrently on the same machine** — host:9401, host:3100, +> host:3200, host:3400, host:7100 will conflict. The HS template's +> single-tenant defaults are intentional for the apex operator path +> (Story 45.4 `townhouse hs up`); open an enhancement issue if multi-tenant +> bindings become a real need. + ### API ```typescript diff --git a/packages/townhouse/compose/townhouse-dev.yml b/packages/townhouse/compose/townhouse-dev.yml index cd687456..2b913833 100644 --- a/packages/townhouse/compose/townhouse-dev.yml +++ b/packages/townhouse/compose/townhouse-dev.yml @@ -231,7 +231,7 @@ services: NODE_NOSTR_PUBKEY: '' NODE_EVM_ADDRESS: '' # Interpolated from TOWN_01_SECRET_KEY exported by townhouse-dev-infra.sh. - NODE_NOSTR_SECRET_KEY: '${TOWN_01_SECRET_KEY:-}' + NODE_NOSTR_SECRET_KEY: '${TOWN_01_SECRET_KEY:?TOWN_01_SECRET_KEY required — source scripts/townhouse-dev-infra.sh first}' volumes: - townhouse-dev-town-01-data:/data healthcheck: @@ -268,7 +268,7 @@ services: NODE_NOSTR_PUBKEY: '' NODE_EVM_ADDRESS: '' # Interpolated from TOWN_02_SECRET_KEY exported by townhouse-dev-infra.sh. - NODE_NOSTR_SECRET_KEY: '${TOWN_02_SECRET_KEY:-}' + NODE_NOSTR_SECRET_KEY: '${TOWN_02_SECRET_KEY:?TOWN_02_SECRET_KEY required — source scripts/townhouse-dev-infra.sh first}' volumes: - townhouse-dev-town-02-data:/data healthcheck: @@ -307,9 +307,9 @@ services: NODE_EVM_ADDRESS: '' # MILL_MNEMONIC takes priority over NODE_NOSTR_SECRET_KEY for BIP-32 swap key derivation. # Interpolated from MILL_01_MNEMONIC exported by townhouse-dev-infra.sh. - MILL_MNEMONIC: '${MILL_01_MNEMONIC:-}' + MILL_MNEMONIC: '${MILL_01_MNEMONIC:?MILL_01_MNEMONIC required — source scripts/townhouse-dev-infra.sh first}' # Kept for backward-compat; ignored when MILL_MNEMONIC is set. - NODE_NOSTR_SECRET_KEY: '${MILL_01_SECRET_KEY:-}' + NODE_NOSTR_SECRET_KEY: '${MILL_01_SECRET_KEY:?MILL_01_SECRET_KEY required — source scripts/townhouse-dev-infra.sh first}' MILL_CONFIG_PATH: /config/mill.config.json MILL_RELAYS: ws://townhouse-dev-town-01:7100,ws://townhouse-dev-town-02:7100 volumes: @@ -351,9 +351,9 @@ services: NODE_EVM_ADDRESS: '' # MILL_MNEMONIC takes priority over NODE_NOSTR_SECRET_KEY for BIP-32 swap key derivation. # Interpolated from MILL_02_MNEMONIC exported by townhouse-dev-infra.sh. - MILL_MNEMONIC: '${MILL_02_MNEMONIC:-}' + MILL_MNEMONIC: '${MILL_02_MNEMONIC:?MILL_02_MNEMONIC required — source scripts/townhouse-dev-infra.sh first}' # Kept for backward-compat; ignored when MILL_MNEMONIC is set. - NODE_NOSTR_SECRET_KEY: '${MILL_02_SECRET_KEY:-}' + NODE_NOSTR_SECRET_KEY: '${MILL_02_SECRET_KEY:?MILL_02_SECRET_KEY required — source scripts/townhouse-dev-infra.sh first}' MILL_CONFIG_PATH: /config/mill.config.json MILL_RELAYS: ws://townhouse-dev-town-01:7100,ws://townhouse-dev-town-02:7100 volumes: @@ -391,7 +391,8 @@ services: NODE_NOSTR_PUBKEY: '' NODE_EVM_ADDRESS: '' # Interpolated from DVM_01_SECRET_KEY exported by townhouse-dev-infra.sh. - NODE_NOSTR_SECRET_KEY: '${DVM_01_SECRET_KEY:-}' + NODE_NOSTR_SECRET_KEY: '${DVM_01_SECRET_KEY:?DVM_01_SECRET_KEY required — source scripts/townhouse-dev-infra.sh first}' + # TURBO_TOKEN may legitimately be unset for non-Turbo dev paths — empty string is intended. TURBO_TOKEN: '${TURBO_TOKEN:-}' volumes: - townhouse-dev-dvm-01-data:/data diff --git a/packages/townhouse/compose/townhouse-hs.yml b/packages/townhouse/compose/townhouse-hs.yml index 188d4fc4..f783e941 100644 --- a/packages/townhouse/compose/townhouse-hs.yml +++ b/packages/townhouse/compose/townhouse-hs.yml @@ -32,6 +32,21 @@ # - ator-sidecar / ator-sidecar-relay (connector v3.5.x does HS publishing in-process) # - anvil / solana / faucet (dev-stack concerns, not operator-facing) # - build: directives (all images are digest-pinned GHCR pulls) +# +# Port allocation (HS-mode binds canonical ports — single-tenant operator box): +# 127.0.0.1:9401 connector admin +# 127.0.0.1:28090 townhouse-api Fastify +# 127.0.0.1:7100,3100 town (relay WS, BLS health) — profile gated +# 127.0.0.1:3200 mill BLS health — profile gated +# 127.0.0.1:3400 dvm BLS health — profile gated +# +# These collide with the contributor dev stack's 28xxx-namespaced bindings +# (28080:9401, 28100:3100, 28110:3100, 28200:3200, 28210:3200, 28400:3400, +# 28700:7100, 28710:7100). HS-mode and the contributor dev stack +# (scripts/townhouse-dev-infra.sh) MUST NOT run concurrently on the same +# machine. If you need to run multiple townhouse instances on a multi-tenant +# box, open an enhancement issue — the canonical ports here are intentional +# for the apex operator path (Story 45.4 `townhouse hs up`). networks: townhouse-hs-net: @@ -108,6 +123,14 @@ services: - /var/run/docker.sock:/var/run/docker.sock # Operator home — wallet, config, compose files, snapshots (RW). - ~/.townhouse:/.townhouse:rw + environment: + # Override entrypoint default '/config/config.yaml' so the API reads from + # the mounted operator-home dir (where Story 45.4 writes config.yaml). + TOWNHOUSE_CONFIG: /.townhouse/config.yaml + # Wallet decryption password — operator must export TOWNHOUSE_WALLET_PASSWORD + # in the shell that runs `docker compose up`. Compose interpolates the value + # at up time; the container exits immediately if it is unset (intended). + TOWNHOUSE_WALLET_PASSWORD: '${TOWNHOUSE_WALLET_PASSWORD:?TOWNHOUSE_WALLET_PASSWORD must be exported before docker compose up}' healthcheck: # nosemgrep: trailofbits.generic.wget-unencrypted-url.wget-unencrypted-url -- container-internal probe test: ['CMD', 'wget', '-q', '--spider', 'http://localhost:28090/api/health'] @@ -120,6 +143,15 @@ services: # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ # Town — Nostr relay node (profile: town) # Lazy-provisioned via Epic 46: `townhouse node add town` + # + # SECURITY TODO (Epic 46): the per-node secrets below use `${VAR:-}` empty + # defaults, which silently start the container with a zero/empty key when + # the operator forgets to export the env var. Story 45.2 review (R2-MINOR) + # recommends switching to `${VAR:?msg}` (fail-fast) once Epic 46 boots + # these profiles — keeping the empty default for now lets `docker compose + # config` validation pass without injecting per-node secrets, matching + # Story 45.4's apex-only boot semantic (only connector + townhouse-api + # start at first run). # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ town: image: ghcr.io/toon-protocol/town${TOON_TOWN_DIGEST} diff --git a/packages/townhouse/src/__integration__/compose-template-validity.test.ts b/packages/townhouse/src/__integration__/compose-template-validity.test.ts index 10e043de..dbf91d85 100644 --- a/packages/townhouse/src/__integration__/compose-template-validity.test.ts +++ b/packages/townhouse/src/__integration__/compose-template-validity.test.ts @@ -82,11 +82,12 @@ describe.skipIf(!renderedHsExists)( }); it('every host-side port binding uses 127.0.0.1: prefix (NFR9)', () => { - // Find all ports lines with a numeric host-side port - const portLines = renderedYaml - .split('\n') - .filter((line) => /^\s+-\s+['"]?\d/.test(line) || /^\s+-\s+['"]?\d{2,5}:/.test(line)); - // Simplified: find lines that look like port mappings with a host port + // Explicit reject: no `0.0.0.0:` anywhere in the file (Task 2.7 / 8.2). + // Catches stray non-port bindings (e.g. expose entries, long-form `host_ip`) + // that the line-by-line port-mapping regex below would miss. + expect(renderedYaml, 'rendered HS template must not bind 0.0.0.0').not.toMatch(/\b0\.0\.0\.0:/); + + // Then the structured per-line check on short-form `- 'host:container'` mappings. const allPortMappings = renderedYaml .split('\n') .filter((line) => /^\s+-\s+['"]?\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}:|^\s+-\s+['"]?\d+:\d+/.test(line)); @@ -118,6 +119,7 @@ describe.skipIf(!renderedHsExists)( ], { encoding: 'utf-8', timeout: 30_000, + env: { ...process.env, TOWNHOUSE_WALLET_PASSWORD: 'compose-config-validation-only' }, }); } catch (err) { const msg = err instanceof Error ? err.message : String(err); @@ -143,21 +145,46 @@ describe.skipIf(!renderedHsExists)( ], { encoding: 'utf-8', timeout: 30_000, + env: { ...process.env, TOWNHOUSE_WALLET_PASSWORD: 'compose-config-validation-only' }, }); // In the resolved config output, no service should have a build key expect(stdout).not.toMatch(/^\s+build:/m); }, 30_000 ); + + // R2-MINOR fix: negative-path test that locks in the `${VAR:?}` semantic. + // If a future patch silently changes ':?' to ':-', this test fails because + // the unset password no longer triggers a compose-config error. + it.skipIf(!dockerAvailable)( + 'docker compose config FAILS when TOWNHOUSE_WALLET_PASSWORD is unset (locks in ${VAR:?} semantic)', + () => { + const env = { ...process.env }; + delete env['TOWNHOUSE_WALLET_PASSWORD']; + let exitCode = 0; + try { + execFileSync('docker', [ + 'compose', '-f', RENDERED_HS_PATH, 'config', + ], { encoding: 'utf-8', timeout: 30_000, env }); + } catch (err) { + exitCode = (err as { status?: number }).status ?? 1; + } + expect(exitCode, 'compose config must fail when password is unset').not.toBe(0); + }, + 30_000 + ); } ); describe.skipIf(renderedHsExists)( 'compose-template-validity (SKIPPED — dist/compose/townhouse-hs.yml not present)', () => { - it('skipped: run pnpm build + place image-manifest.json first', () => { - // This test group is intentionally skipped when the rendered file is absent. - // It exists to produce a visible skip entry in CI rather than silently passing. + // ctx.skip() emits a real "skipped" status in vitest reporters. An empty + // body would have been reported as "passed" (green) instead, hiding the + // missing-precondition signal in CI output. + it.skip('skipped: run pnpm build + place image-manifest.json first', () => { + // This block exists to surface a visible "skipped" entry in CI output + // when the rendered HS template hasn't been produced yet. }); } ); diff --git a/packages/townhouse/src/__integration__/connector-image-contract.test.ts b/packages/townhouse/src/__integration__/connector-image-contract.test.ts index 08749462..73a63c1f 100644 --- a/packages/townhouse/src/__integration__/connector-image-contract.test.ts +++ b/packages/townhouse/src/__integration__/connector-image-contract.test.ts @@ -19,10 +19,11 @@ */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { writeFileSync, mkdirSync, rmSync } from 'node:fs'; -import { join } from 'node:path'; +import { writeFileSync, mkdirSync, rmSync, existsSync, readFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; import { tmpdir } from 'node:os'; import { randomBytes } from 'node:crypto'; +import { fileURLToPath } from 'node:url'; import type Docker from 'dockerode'; import { DEFAULT_CONNECTOR_IMAGE } from '../constants.js'; @@ -37,6 +38,46 @@ function parseConnectorImage(ref: string): { name: string; tag?: string; digest? throw new Error(`unparseable image ref: ${ref}`); } +// ── Manifest-alignment guard (Story 45.2 review-finding D2) ────────────────── +// When dist/image-manifest.json is present, assert its connector digest matches +// the digest in DEFAULT_CONNECTOR_IMAGE. The constant, the rendered HS template, +// and the manifest are three sources of truth for the connector digest; this +// test catches drift on any one of them. +// +// In CI (env CI=true), manifest absence is a HARD FAIL — the manifest must be +// placed by the download-artifact step before the canary runs. Outside CI, +// absence is tolerated with a visible skip (typical local-dev path before the +// developer manually copies the artifact). +// +// R2-MAJOR fix: the previous skip-when-absent semantics defeated the +// drift-detection purpose in the very scenario the test was meant to police. +const __filename = fileURLToPath(import.meta.url); +const MANIFEST_PATH = join(dirname(__filename), '..', '..', 'dist', 'image-manifest.json'); +const isCI = process.env['CI'] === 'true' || process.env['CI'] === '1'; +const manifestExists = existsSync(MANIFEST_PATH); + +describe('DEFAULT_CONNECTOR_IMAGE manifest alignment', () => { + if (isCI && !manifestExists) { + it('CI invariant: dist/image-manifest.json must be present (place via download-artifact before running canary)', () => { + throw new Error( + `Manifest missing at ${MANIFEST_PATH}. In CI the publish workflow ` + + `must place this artifact via actions/download-artifact BEFORE the ` + + `canary runs. If you are running this locally, set CI=0 or copy the ` + + `manifest from a Story 45.1 publish workflow run.` + ); + }); + return; + } + it.skipIf(!manifestExists)('matches manifest.images.connector.digest', () => { + const manifest = JSON.parse(readFileSync(MANIFEST_PATH, 'utf-8')) as { + images: { connector: { digest: string } }; + }; + const parsed = parseConnectorImage(DEFAULT_CONNECTOR_IMAGE); + expect(parsed.digest, 'DEFAULT_CONNECTOR_IMAGE must be in digest form').toBeTruthy(); + expect(parsed.digest).toBe(manifest.images.connector.digest); + }); +}); + // ── Port allocation for this test ───────────────────────────────────────────── // We bind two internal ports to ephemeral host ports so this test can run // alongside other integration tests without port conflicts. diff --git a/packages/townhouse/src/__integration__/tarball-contents.test.ts b/packages/townhouse/src/__integration__/tarball-contents.test.ts index cc2bf0b5..4607b0ff 100644 --- a/packages/townhouse/src/__integration__/tarball-contents.test.ts +++ b/packages/townhouse/src/__integration__/tarball-contents.test.ts @@ -19,7 +19,7 @@ */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { execFileSync, execSync } from 'node:child_process'; +import { execFileSync } from 'node:child_process'; import { existsSync, readFileSync, mkdtempSync, rmSync, readdirSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -29,6 +29,8 @@ const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const PKG_DIR = join(__dirname, '..', '..'); +const DIST_COMPOSE_HS = join(PKG_DIR, 'dist', 'compose', 'townhouse-hs.yml'); +const DIST_COMPOSE_DEV = join(PKG_DIR, 'dist', 'compose', 'townhouse-dev.yml'); const MANIFEST_PATH = join(PKG_DIR, 'dist', 'image-manifest.json'); const skipPackTest = process.env['SKIP_PACK_TEST'] === '1'; @@ -40,27 +42,34 @@ describe.skipIf(skipPackTest)('tarball-contents', () => { let tgzPath: string; beforeAll(() => { + // Precondition: dist/compose/ must already be built. `pnpm pack` packs + // whatever is in dist/, so a stale or missing build would silently produce + // a green-but-wrong result. Fail loudly with an actionable message. + if (!existsSync(DIST_COMPOSE_HS) || !existsSync(DIST_COMPOSE_DEV)) { + throw new Error( + `tarball-contents test requires built dist/compose/. Missing:\n` + + ` ${DIST_COMPOSE_HS} ${existsSync(DIST_COMPOSE_HS) ? '✓' : '✗'}\n` + + ` ${DIST_COMPOSE_DEV} ${existsSync(DIST_COMPOSE_DEV) ? '✓' : '✗'}\n` + + `Run 'pnpm --filter @toon-protocol/townhouse build' first ` + + `(or set SKIP_PACK_TEST=1 to skip this test).` + ); + } + packOutDir = mkdtempSync(join(tmpdir(), 'townhouse-pack-')); extractDir = mkdtempSync(join(tmpdir(), 'townhouse-extract-')); - // Run pnpm pack from the package directory - const result = execFileSync( + // Run pnpm pack from the package directory. We do NOT parse pnpm's stdout + // for the tgz path — output format varies across pnpm versions. The tmpdir + // is created fresh by mkdtempSync, so readdirSync is the authoritative source. + execFileSync( 'pnpm', ['pack', '--pack-destination', packOutDir], { cwd: PKG_DIR, encoding: 'utf-8', timeout: 60_000 } ); - // Find the produced .tgz - const tgzName = result.trim().split('\n').pop()?.trim(); - // pnpm pack outputs the path to the tgz - if (tgzName && existsSync(tgzName)) { - tgzPath = tgzName; - } else { - // fallback: find in packOutDir - const files = readdirSync(packOutDir).filter((f) => f.endsWith('.tgz')); - expect(files.length, 'expected exactly one .tgz in pack output dir').toBe(1); - tgzPath = join(packOutDir, files[0]!); - } + const files = readdirSync(packOutDir).filter((f) => f.endsWith('.tgz')); + expect(files.length, 'expected exactly one .tgz in pack output dir').toBe(1); + tgzPath = join(packOutDir, files[0]!); // Extract the tarball execFileSync('tar', ['-xzf', tgzPath, '-C', extractDir], { timeout: 30_000 }); diff --git a/packages/townhouse/src/compose-loader.test.ts b/packages/townhouse/src/compose-loader.test.ts index 36ed48e2..4079364c 100644 --- a/packages/townhouse/src/compose-loader.test.ts +++ b/packages/townhouse/src/compose-loader.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { mkdtempSync, statSync, rmSync, mkdirSync, copyFileSync } from 'node:fs'; +import { mkdtempSync, statSync, rmSync, mkdirSync, copyFileSync, readFileSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { tmpdir } from 'node:os'; import { fileURLToPath } from 'node:url'; @@ -100,13 +100,21 @@ describe('materializeComposeTemplate', () => { expect(mode).toBe(0o700); }); - it('is idempotent — second call overwrites first, mode stays 0o600, no errors', () => { + it('is idempotent — second call overwrites first, mode stays 0o600, content matches, no errors', () => { const opts = { distDir: FIXTURE_DIR, townhouseHome: tmpHome }; const first = materializeComposeTemplate('hs', opts); + const firstContent = readFileSync(first.composePath, 'utf-8'); + const firstManifest = readFileSync(first.manifestPath, 'utf-8'); const second = materializeComposeTemplate('hs', opts); expect(first.composePath).toBe(second.composePath); + expect(first.manifestPath).toBe(second.manifestPath); const mode = statSync(second.composePath).mode & 0o777; expect(mode).toBe(0o600); + // R2-MINOR fix: assert content equality, not just path equality. + // A regression that wrote different bytes (truncated, wrong template, + // mid-write garbage) would have passed the previous version of this test. + expect(readFileSync(second.composePath, 'utf-8')).toBe(firstContent); + expect(readFileSync(second.manifestPath, 'utf-8')).toBe(firstManifest); }); it('throws ComposeLoaderError for hs profile when manifest is absent', () => { diff --git a/packages/townhouse/src/compose-loader.ts b/packages/townhouse/src/compose-loader.ts index 3b1df583..f2aa24f9 100644 --- a/packages/townhouse/src/compose-loader.ts +++ b/packages/townhouse/src/compose-loader.ts @@ -1,9 +1,10 @@ -import { readFileSync, writeFileSync, mkdirSync, chmodSync, existsSync } from 'node:fs'; -import { dirname, join, resolve } from 'node:path'; +import { readFileSync, writeFileSync, mkdirSync, chmodSync, statSync, lstatSync, existsSync } from 'node:fs'; +import { dirname, join, resolve, isAbsolute } from 'node:path'; import { fileURLToPath } from 'node:url'; import { homedir } from 'node:os'; export type ComposeProfile = 'dev' | 'hs'; +const VALID_PROFILES: readonly ComposeProfile[] = ['dev', 'hs'] as const; export interface ComposeLoaderOptions { /** Override default `~/.townhouse/` write target. Used by tests. */ @@ -31,6 +32,70 @@ function defaultDistDir(): string { return resolve(here, '..', 'dist'); } +function assertValidProfile(profile: string): asserts profile is ComposeProfile { + if (!(VALID_PROFILES as readonly string[]).includes(profile)) { + throw new ComposeLoaderError( + `invalid compose profile: '${profile}'. Must be one of: ${VALID_PROFILES.join(', ')}.` + ); + } +} + +// Reject townhouseHome paths that target system directories. Internal callers +// (CLI, API, Story 45.4) pass `~/.townhouse` or test tmpdirs; an attacker +// reaching this code path with `townhouseHome: '/etc'` would otherwise write +// `/etc/compose/townhouse-hs.yml` and `chmod /etc 0o700`. The list is a +// belt-and-suspenders defense — it doesn't replace caller validation, but it +// turns a silent privilege escalation into a loud error. +const SYSTEM_PATH_PREFIXES = [ + '/etc', '/usr', '/bin', '/sbin', '/lib', '/lib64', + '/proc', '/sys', '/dev', '/boot', '/root', +] as const; + +function assertValidTownhouseHome(home: string): void { + if (!home) { + throw new ComposeLoaderError( + 'townhouseHome resolved to an empty path. Set $HOME or pass options.townhouseHome explicitly.' + ); + } + if (!isAbsolute(home)) { + throw new ComposeLoaderError( + `townhouseHome must be an absolute path; got '${home}'.` + ); + } + if (home === '/' || home === '\\') { + throw new ComposeLoaderError( + `townhouseHome must not be the filesystem root; got '${home}'. ` + + `This usually means $HOME is unset and homedir() returned '/'.` + ); + } + for (const prefix of SYSTEM_PATH_PREFIXES) { + if (home === prefix || home.startsWith(prefix + '/')) { + throw new ComposeLoaderError( + `townhouseHome must not target a system directory; got '${home}'. ` + + `Allowed paths: under $HOME, under tmpdir(), or any user-writable location.` + ); + } + } +} + +// Refuse to write through a symlink at composePath/manifestPath. The dir-level +// guard above only protects the directory itself; this protects the file path. +function assertNotSymlink(filePath: string): void { + try { + const lst = lstatSync(filePath); + if (lst.isSymbolicLink()) { + throw new ComposeLoaderError( + `${filePath} is a symlink; refusing to write through it. ` + + `If this is intentional, remove the symlink and re-run.` + ); + } + } catch (err) { + // ENOENT is expected (file doesn't exist yet — fresh write); rethrow others. + const code = (err as NodeJS.ErrnoException).code; + if (code !== 'ENOENT') throw err; + } +} + /** * Returns the rendered compose YAML for the requested profile. * For 'hs', digest substitutions are already applied (resolved at build time). @@ -41,6 +106,7 @@ export function loadComposeTemplate( profile: ComposeProfile, options: ComposeLoaderOptions = {} ): string { + assertValidProfile(profile); const distDir = options.distDir ?? defaultDistDir(); const composePath = join(distDir, 'compose', `townhouse-${profile}.yml`); if (!existsSync(composePath)) { @@ -62,40 +128,76 @@ export function materializeComposeTemplate( profile: ComposeProfile, options: ComposeLoaderOptions = {} ): { composePath: string; manifestPath: string } { - const home = options.townhouseHome ?? join(homedir(), '.townhouse'); - const composeDir = join(home, 'compose'); + assertValidProfile(profile); + const home = options.townhouseHome || join(homedir(), '.townhouse'); + assertValidTownhouseHome(home); - mkdirSync(composeDir, { recursive: true }); - // chmod after mkdir for already-existing dirs (mkdir's mode arg is only - // honored on creation). Defensive re-chmod enforces 0o700 on every call. - chmodSync(home, 0o700); - chmodSync(composeDir, 0o700); + const distDir = options.distDir ?? defaultDistDir(); + const manifestSrc = join(distDir, 'image-manifest.json'); + // Validate inputs BEFORE any writes so a failure leaves disk untouched. + // HS profile cannot succeed without a manifest — fail loudly up-front + // rather than after writing a stale/torn compose file. + if (profile === 'hs' && !existsSync(manifestSrc)) { + throw new ComposeLoaderError( + `image-manifest.json not found at ${manifestSrc}. ` + + `HS mode requires a digest-pinned image manifest. ` + + `Reinstall @toon-protocol/townhouse from npm to restore the manifest.` + ); + } + // loadComposeTemplate also throws ENOENT if the source is missing — surface + // it now (read-only) so we don't mkdir or chmod for a doomed call. const yaml = loadComposeTemplate(profile, options); + + const composeDir = join(home, 'compose'); + // Pass mode: 0o700 so newly-created intermediates start tight (closes the + // mkdir → chmod TOCTOU window that allowed brief world-readable state). + mkdirSync(composeDir, { recursive: true, mode: 0o700 }); + + // Refuse to chmod symlink targets — operator may have placed `~/.townhouse` + // as a symlink to an encrypted volume, and we should not silently flip the + // mode of a path we did not create. lstatSync inspects the link itself. + for (const dir of [home, composeDir]) { + const lst = lstatSync(dir); + if (lst.isSymbolicLink()) { + // Resolve the link target and confirm it's a directory; do not chmod. + const target = statSync(dir); + if (!target.isDirectory()) { + throw new ComposeLoaderError( + `${dir} is a symlink to a non-directory; refusing to materialize.` + ); + } + continue; + } + // Only narrow the mode if it is currently broader than 0o700. Operators + // who deliberately set 0o700 OR tighter (e.g. 0o500) keep their setting. + // Bug fix R2: previous `!== 0o700` widened 0o500 to 0o700 — now we only + // chmod if the existing mode grants any permission outside the owner. + const currentMode = lst.mode & 0o777; + if ((currentMode & 0o077) !== 0) { + chmodSync(dir, 0o700); + } + } + const composePath = join(composeDir, `townhouse-${profile}.yml`); + // R2 file-symlink guard — refuse to write through a planted symlink. + assertNotSymlink(composePath); writeFileSync(composePath, yaml, { mode: 0o600, encoding: 'utf-8' }); - // Defensive re-chmod: writeFileSync's mode option is masked by process.umask() - // on some Linux filesystems (notably WSL2). chmodSync is the load-bearing call. + // Defensive re-chmod: writeFileSync's mode option is honored only on file + // creation — if composePath already existed at e.g. 0o644 (stale state from + // a prior interrupted run), the mode is unchanged by writeFileSync. + // chmodSync corrects both that case AND the WSL2 umask-masking edge case. chmodSync(composePath, 0o600); - const distDir = options.distDir ?? defaultDistDir(); - const manifestSrc = join(distDir, 'image-manifest.json'); const manifestPath = join(home, 'image-manifest.json'); - if (existsSync(manifestSrc)) { + assertNotSymlink(manifestPath); const manifest = readFileSync(manifestSrc, 'utf-8'); writeFileSync(manifestPath, manifest, { mode: 0o600, encoding: 'utf-8' }); chmodSync(manifestPath, 0o600); - } else { - // Manifest is required for HS mode — fail loudly. Dev mode tolerates absence. - if (profile === 'hs') { - throw new ComposeLoaderError( - `image-manifest.json not found at ${manifestSrc}. ` + - `HS mode requires a digest-pinned image manifest. ` + - `Reinstall @toon-protocol/townhouse from npm to restore the manifest.` - ); - } } + // (Manifest absence for 'dev' profile is silently tolerated — dev mode + // doesn't need digest pinning. HS profile already failed at the entry guard.) return { composePath, manifestPath }; } diff --git a/packages/townhouse/src/docker/orchestrator.ts b/packages/townhouse/src/docker/orchestrator.ts index 71474876..8f9416f5 100644 --- a/packages/townhouse/src/docker/orchestrator.ts +++ b/packages/townhouse/src/docker/orchestrator.ts @@ -482,20 +482,20 @@ export class DockerOrchestrator extends EventEmitter { imagesToPull.add(normalizeImageTag(RELAY_ATOR_SIDECAR_IMAGE)); } - // Check which images exist locally + // Check which images exist locally. Match against both RepoTags (tag-form + // refs) and RepoDigests (digest-form refs); since DEFAULT_CONNECTOR_IMAGE + // flipped to digest form (Story 45.2), RepoTags alone never matches it + // and we'd re-pull on every up(). const existingImages = await this.docker.listImages(); - const existingTags = new Set(); + const existingRefs = new Set(); for (const img of existingImages) { - if (img.RepoTags) { - for (const tag of img.RepoTags) { - existingTags.add(tag); - } - } + for (const tag of img.RepoTags ?? []) existingRefs.add(tag); + for (const digest of img.RepoDigests ?? []) existingRefs.add(digest); } // Pull missing images for (const image of imagesToPull) { - if (existingTags.has(image)) { + if (existingRefs.has(image)) { continue; } diff --git a/packages/townhouse/tsup.config.ts b/packages/townhouse/tsup.config.ts index 539858a5..9baed4a6 100644 --- a/packages/townhouse/tsup.config.ts +++ b/packages/townhouse/tsup.config.ts @@ -1,7 +1,15 @@ import { defineConfig } from 'tsup'; -import { cp, mkdir, readFile, writeFile, access } from 'node:fs/promises'; +import { cp, mkdir, readFile, writeFile, access, chmod } from 'node:fs/promises'; import { join } from 'node:path'; +// Shared digest extractor + validator. Single source of truth for what makes +// a manifest entry valid — used by this hook AND by scripts/render-compose-template.mjs +// (the CI-side renderer that runs after download-artifact). Round-2 review +// flagged that the previous duplicate substitution arrays had drifted error +// contracts; consolidating here closes that gap. +// @ts-expect-error — JS module, no type declarations. +import { getImageDigest } from '../../scripts/lib/image-manifest-digest.mjs'; + export default defineConfig({ entry: ['src/index.ts', 'src/cli.ts'], format: ['esm'], @@ -21,43 +29,49 @@ export default defineConfig({ await cp('compose/townhouse-dev.yml', join(composeDistDir, 'townhouse-dev.yml')); // Render HS template — substitute digest placeholders from image-manifest.json - // if present. When absent (typical local dev), emit a warning and ship the - // unsubstituted template. CI calls scripts/render-compose-template.mjs AFTER - // download-artifact places the manifest, so the authoritative substitution - // happens there (not here). This path is belt-and-suspenders for local builds - // where the developer has manually placed the manifest. + // if present. When the manifest file is absent (typical local dev), emit a + // warning and ship the unsubstituted template. CI calls + // scripts/render-compose-template.mjs AFTER download-artifact places the + // manifest, so the authoritative substitution happens there. + // + // IMPORTANT: only ENOENT (manifest absent) is tolerated. JSON parse errors, + // schema mismatches, and malformed digests all fail the build — silent + // emission of an unsubstituted template under those conditions would mask + // real bugs and rely on CI's tarball-content gate as the only safety net. const manifestPath = 'dist/image-manifest.json'; const hsTemplateRaw = await readFile('compose/townhouse-hs.yml', 'utf-8'); let hsRendered = hsTemplateRaw; + let manifestPresent = false; try { await access(manifestPath); + manifestPresent = true; + } catch { + console.warn( + '[tsup] dist/image-manifest.json not found — shipping unsubstituted ' + + 'townhouse-hs.yml. This is fine for local dev but invalid for npm publish.' + ); + } + + if (manifestPresent) { const manifestRaw = await readFile(manifestPath, 'utf-8'); - const manifest = JSON.parse(manifestRaw) as { - images: Record; - }; + const manifest = JSON.parse(manifestRaw); // throws SyntaxError on malformed JSON const subs: Array<[string, string]> = [ - ['${TOON_TOWNHOUSE_API_DIGEST}', `@${manifest.images['townhouse-api'].digest}`], - ['${TOON_TOWN_DIGEST}', `@${manifest.images.town.digest}`], - ['${TOON_MILL_DIGEST}', `@${manifest.images.mill.digest}`], - ['${TOON_DVM_DIGEST}', `@${manifest.images.dvm.digest}`], - ['${TOON_CONNECTOR_DIGEST}', `@${manifest.images.connector.digest}`], + ['${TOON_TOWNHOUSE_API_DIGEST}', `@${getImageDigest(manifest, 'townhouse-api')}`], + ['${TOON_TOWN_DIGEST}', `@${getImageDigest(manifest, 'town')}`], + ['${TOON_MILL_DIGEST}', `@${getImageDigest(manifest, 'mill')}`], + ['${TOON_DVM_DIGEST}', `@${getImageDigest(manifest, 'dvm')}`], + ['${TOON_CONNECTOR_DIGEST}', `@${getImageDigest(manifest, 'connector')}`], ]; for (const [placeholder, replacement] of subs) { hsRendered = hsRendered.replaceAll(placeholder, replacement); } - } catch { - // Manifest absent — ship the unsubstituted template. This is the normal - // local-dev path. The CI tarball-content verification step catches - // unsubstituted placeholders before pnpm publish runs. - console.warn( - '[tsup] dist/image-manifest.json not found — shipping unsubstituted ' + - 'townhouse-hs.yml. This is fine for local dev but invalid for npm publish.' - ); } - await writeFile(join(composeDistDir, 'townhouse-hs.yml'), hsRendered, 'utf-8'); + const hsOutPath = join(composeDistDir, 'townhouse-hs.yml'); + await writeFile(hsOutPath, hsRendered, 'utf-8'); + await chmod(hsOutPath, 0o600); // NFR8 — operator-secret file mode (R2-MINOR fix) }, }); diff --git a/scripts/lib/image-manifest-digest.mjs b/scripts/lib/image-manifest-digest.mjs new file mode 100644 index 00000000..e7d3c658 --- /dev/null +++ b/scripts/lib/image-manifest-digest.mjs @@ -0,0 +1,33 @@ +/** + * Shared digest extraction + validation for the townhouse image manifest. + * + * Both `scripts/render-compose-template.mjs` and + * `packages/townhouse/tsup.config.ts` import this so a single source of truth + * defines (a) what makes a manifest entry valid, and (b) what error messages + * the operator sees when it isn't. Round-1 review deferred this consolidation + * (#8 in deferred-work.md); Round-2 review observed that the patch round + * actually worsened the drift by introducing parallel digest-validation logic. + */ + +export const DIGEST_RE = /^sha256:[a-f0-9]{64}$/; + +/** + * Returns the digest string for a given image key (e.g. 'connector', 'town') + * after validating that the manifest contains the entry and the digest is a + * well-formed sha256 ref. Throws a descriptive Error on any failure. + */ +export function getImageDigest(manifest, key) { + const entry = manifest?.images?.[key]; + if (!entry) { + throw new Error( + `image-manifest digest lookup failed: manifest missing image entry images.${key}` + ); + } + const digest = entry.digest; + if (typeof digest !== 'string' || !DIGEST_RE.test(digest)) { + throw new Error( + `image-manifest digest for '${key}' is not a valid sha256 ref: '${digest}'` + ); + } + return digest; +} diff --git a/scripts/render-compose-template.mjs b/scripts/render-compose-template.mjs index 71c0d4da..94028df0 100644 --- a/scripts/render-compose-template.mjs +++ b/scripts/render-compose-template.mjs @@ -20,10 +20,12 @@ * node scripts/render-compose-template.mjs */ -import { readFile, writeFile, cp, mkdir, access } from 'node:fs/promises'; +import { readFile, writeFile, cp, mkdir, access, chmod } from 'node:fs/promises'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { getImageDigest } from './lib/image-manifest-digest.mjs'; + const __dirname = dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = join(__dirname, '..'); const PKG_DIR = join(REPO_ROOT, 'packages', 'townhouse'); @@ -43,36 +45,50 @@ async function run() { const hsTemplateRaw = await readFile(HS_TEMPLATE_PATH, 'utf-8'); let hsRendered = hsTemplateRaw; + // Only ENOENT on the manifest is tolerated (warn + ship unsubstituted). + // JSON-parse errors, missing image keys, and malformed digests all fail + // hard — silent emission of an unsubstituted template under those + // conditions would mask real bugs. + let manifestPresent = false; try { await access(MANIFEST_PATH); + manifestPresent = true; + } catch (err) { + if (err && err.code !== 'ENOENT') throw err; + console.warn( + '[render-compose-template] WARNING: dist/image-manifest.json not found — ' + + 'shipping unsubstituted townhouse-hs.yml. ' + + 'This is fine for local dev but invalid for npm publish.' + ); + } + + if (manifestPresent) { const manifestRaw = await readFile(MANIFEST_PATH, 'utf-8'); - const manifest = JSON.parse(manifestRaw); + const manifest = JSON.parse(manifestRaw); // throws SyntaxError on malformed JSON const subs = [ - ['${TOON_TOWNHOUSE_API_DIGEST}', `@${manifest.images['townhouse-api'].digest}`], - ['${TOON_TOWN_DIGEST}', `@${manifest.images.town.digest}`], - ['${TOON_MILL_DIGEST}', `@${manifest.images.mill.digest}`], - ['${TOON_DVM_DIGEST}', `@${manifest.images.dvm.digest}`], - ['${TOON_CONNECTOR_DIGEST}', `@${manifest.images.connector.digest}`], + ['${TOON_TOWNHOUSE_API_DIGEST}', getImageDigest(manifest, 'townhouse-api')], + ['${TOON_TOWN_DIGEST}', getImageDigest(manifest, 'town')], + ['${TOON_MILL_DIGEST}', getImageDigest(manifest, 'mill')], + ['${TOON_DVM_DIGEST}', getImageDigest(manifest, 'dvm')], + ['${TOON_CONNECTOR_DIGEST}', getImageDigest(manifest, 'connector')], ]; - for (const [placeholder, replacement] of subs) { - hsRendered = hsRendered.replaceAll(placeholder, replacement); + for (const [placeholder, digest] of subs) { + hsRendered = hsRendered.replaceAll(placeholder, `@${digest}`); } console.log('[render-compose-template] HS template rendered with 5 digest substitutions.'); - } catch (err) { - // Manifest absent — ship unsubstituted template with a loud warning. - // Acceptable for local dev; the tarball-content verification step in CI - // catches unsubstituted placeholders before pnpm publish runs. - console.warn( - '[render-compose-template] WARNING: dist/image-manifest.json not found — ' + - 'shipping unsubstituted townhouse-hs.yml. ' + - 'This is fine for local dev but invalid for npm publish.' - ); } - await writeFile(join(COMPOSE_DIST_DIR, 'townhouse-hs.yml'), hsRendered, 'utf-8'); + const hsOutPath = join(COMPOSE_DIST_DIR, 'townhouse-hs.yml'); + await writeFile(hsOutPath, hsRendered, 'utf-8'); + // NFR8 — operator-secret file mode (the rendered HS YAML embeds env-var + // references that may include private keys at deploy time). The mode + // applies to the build artifact in dist/ as well as the materialized copy + // at ~/.townhouse/compose/, so an untrusted local user on the CI runner + // cannot read between render and pack. + await chmod(hsOutPath, 0o600); console.log('[render-compose-template] Done — dist/compose/{townhouse-hs,townhouse-dev}.yml written.'); } From d5129fe7c00eb10fdb10e6b163d0982b1367a9c2 Mon Sep 17 00:00:00 2001 From: Jonathan Green Date: Sat, 9 May 2026 17:44:48 -0400 Subject: [PATCH 3/5] fix(townhouse): switch tsup config to T[] array form (eslint array-type) CI Lint & Build on PR #43 caught `Array<[string, string]>` at packages/townhouse/tsup.config.ts:60 violating @typescript-eslint/array-type "Use 'T[]' instead". One-line fix. Co-Authored-By: Claude Sonnet 4.6 --- packages/townhouse/tsup.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/townhouse/tsup.config.ts b/packages/townhouse/tsup.config.ts index 9baed4a6..412a8e5a 100644 --- a/packages/townhouse/tsup.config.ts +++ b/packages/townhouse/tsup.config.ts @@ -57,7 +57,7 @@ export default defineConfig({ const manifestRaw = await readFile(manifestPath, 'utf-8'); const manifest = JSON.parse(manifestRaw); // throws SyntaxError on malformed JSON - const subs: Array<[string, string]> = [ + const subs: [string, string][] = [ ['${TOON_TOWNHOUSE_API_DIGEST}', `@${getImageDigest(manifest, 'townhouse-api')}`], ['${TOON_TOWN_DIGEST}', `@${getImageDigest(manifest, 'town')}`], ['${TOON_MILL_DIGEST}', `@${getImageDigest(manifest, 'mill')}`], From b3d8ae39f2b74f67eef5c99cad942b9564d7d1b7 Mon Sep 17 00:00:00 2001 From: Jonathan Green Date: Sat, 9 May 2026 17:50:09 -0400 Subject: [PATCH 4/5] fix(townhouse): apply prettier formatting on 5 review-patched files CI Format Check on PR #43 caught style drift in: - src/__integration__/compose-template-validity.test.ts - src/__integration__/connector-image-contract.test.ts - src/__integration__/tarball-contents.test.ts - src/compose-loader.test.ts - src/compose-loader.ts Whitespace-only - pnpm format:check clean; compose-loader unit tests remain 12/12 green. Co-Authored-By: Claude Sonnet 4.6 --- .../compose-template-validity.test.ts | 117 +++++++++++++----- .../connector-image-contract.test.ts | 37 ++++-- .../__integration__/tarball-contents.test.ts | 104 ++++++++++++---- packages/townhouse/src/compose-loader.test.ts | 20 ++- packages/townhouse/src/compose-loader.ts | 39 ++++-- 5 files changed, 237 insertions(+), 80 deletions(-) diff --git a/packages/townhouse/src/__integration__/compose-template-validity.test.ts b/packages/townhouse/src/__integration__/compose-template-validity.test.ts index dbf91d85..b77aa022 100644 --- a/packages/townhouse/src/__integration__/compose-template-validity.test.ts +++ b/packages/townhouse/src/__integration__/compose-template-validity.test.ts @@ -36,7 +36,10 @@ function isDockerAvailable(): boolean { if (process.env['DOCKER_AVAILABLE'] === '1') return true; // Auto-detect: check if docker binary exists and responds try { - execSync('docker info --format "{{.ID}}"', { stdio: 'ignore', timeout: 5000 }); + execSync('docker info --format "{{.ID}}"', { + stdio: 'ignore', + timeout: 5000, + }); return true; } catch { return false; @@ -66,9 +69,10 @@ describe.skipIf(!renderedHsExists)( .filter((line) => /^\s+image:\s/.test(line)); expect(imageLines.length).toBeGreaterThan(0); for (const line of imageLines) { - expect(line, `image line should use @sha256: form: ${line.trim()}`).toMatch( - /@sha256:[a-f0-9]{64}/ - ); + expect( + line, + `image line should use @sha256: form: ${line.trim()}` + ).toMatch(/@sha256:[a-f0-9]{64}/); } }); @@ -77,7 +81,9 @@ describe.skipIf(!renderedHsExists)( const nonCommentLines = renderedYaml .split('\n') .filter((line) => !line.trimStart().startsWith('#')); - const buildLines = nonCommentLines.filter((line) => /^\s+build:/.test(line)); + const buildLines = nonCommentLines.filter((line) => + /^\s+build:/.test(line) + ); expect(buildLines).toHaveLength(0); }); @@ -85,12 +91,19 @@ describe.skipIf(!renderedHsExists)( // Explicit reject: no `0.0.0.0:` anywhere in the file (Task 2.7 / 8.2). // Catches stray non-port bindings (e.g. expose entries, long-form `host_ip`) // that the line-by-line port-mapping regex below would miss. - expect(renderedYaml, 'rendered HS template must not bind 0.0.0.0').not.toMatch(/\b0\.0\.0\.0:/); + expect( + renderedYaml, + 'rendered HS template must not bind 0.0.0.0' + ).not.toMatch(/\b0\.0\.0\.0:/); // Then the structured per-line check on short-form `- 'host:container'` mappings. const allPortMappings = renderedYaml .split('\n') - .filter((line) => /^\s+-\s+['"]?\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}:|^\s+-\s+['"]?\d+:\d+/.test(line)); + .filter((line) => + /^\s+-\s+['"]?\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}:|^\s+-\s+['"]?\d+:\d+/.test( + line + ) + ); for (const line of allPortMappings) { const clean = line.trim().replace(/^-\s*/, '').replace(/['"]/g, ''); @@ -112,24 +125,47 @@ describe.skipIf(!renderedHsExists)( try { // Use --profile flags so profiled services (town, mill, dvm) appear in config output. // Docker Compose v5+ requires explicit --profile to include profile-restricted services. - stdout = execFileSync('docker', [ - 'compose', '-f', RENDERED_HS_PATH, - '--profile', 'town', '--profile', 'mill', '--profile', 'dvm', - 'config', - ], { - encoding: 'utf-8', - timeout: 30_000, - env: { ...process.env, TOWNHOUSE_WALLET_PASSWORD: 'compose-config-validation-only' }, - }); + stdout = execFileSync( + 'docker', + [ + 'compose', + '-f', + RENDERED_HS_PATH, + '--profile', + 'town', + '--profile', + 'mill', + '--profile', + 'dvm', + 'config', + ], + { + encoding: 'utf-8', + timeout: 30_000, + env: { + ...process.env, + TOWNHOUSE_WALLET_PASSWORD: 'compose-config-validation-only', + }, + } + ); } catch (err) { const msg = err instanceof Error ? err.message : String(err); throw new Error(`docker compose config failed: ${msg}`); } // All five services should appear in the validated config - const expectedServices = ['connector', 'townhouse-api', 'town', 'mill', 'dvm']; + const expectedServices = [ + 'connector', + 'townhouse-api', + 'town', + 'mill', + 'dvm', + ]; for (const svc of expectedServices) { - expect(stdout, `service '${svc}' should be in docker compose config output`).toContain(svc); + expect( + stdout, + `service '${svc}' should be in docker compose config output` + ).toContain(svc); } }, 30_000 @@ -138,15 +174,29 @@ describe.skipIf(!renderedHsExists)( it.skipIf(!dockerAvailable)( 'docker compose config output has no build: directives for any service', () => { - const stdout = execFileSync('docker', [ - 'compose', '-f', RENDERED_HS_PATH, - '--profile', 'town', '--profile', 'mill', '--profile', 'dvm', - 'config', - ], { - encoding: 'utf-8', - timeout: 30_000, - env: { ...process.env, TOWNHOUSE_WALLET_PASSWORD: 'compose-config-validation-only' }, - }); + const stdout = execFileSync( + 'docker', + [ + 'compose', + '-f', + RENDERED_HS_PATH, + '--profile', + 'town', + '--profile', + 'mill', + '--profile', + 'dvm', + 'config', + ], + { + encoding: 'utf-8', + timeout: 30_000, + env: { + ...process.env, + TOWNHOUSE_WALLET_PASSWORD: 'compose-config-validation-only', + }, + } + ); // In the resolved config output, no service should have a build key expect(stdout).not.toMatch(/^\s+build:/m); }, @@ -163,13 +213,18 @@ describe.skipIf(!renderedHsExists)( delete env['TOWNHOUSE_WALLET_PASSWORD']; let exitCode = 0; try { - execFileSync('docker', [ - 'compose', '-f', RENDERED_HS_PATH, 'config', - ], { encoding: 'utf-8', timeout: 30_000, env }); + execFileSync( + 'docker', + ['compose', '-f', RENDERED_HS_PATH, 'config'], + { encoding: 'utf-8', timeout: 30_000, env } + ); } catch (err) { exitCode = (err as { status?: number }).status ?? 1; } - expect(exitCode, 'compose config must fail when password is unset').not.toBe(0); + expect( + exitCode, + 'compose config must fail when password is unset' + ).not.toBe(0); }, 30_000 ); diff --git a/packages/townhouse/src/__integration__/connector-image-contract.test.ts b/packages/townhouse/src/__integration__/connector-image-contract.test.ts index 73a63c1f..e89d7ae7 100644 --- a/packages/townhouse/src/__integration__/connector-image-contract.test.ts +++ b/packages/townhouse/src/__integration__/connector-image-contract.test.ts @@ -19,7 +19,13 @@ */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { writeFileSync, mkdirSync, rmSync, existsSync, readFileSync } from 'node:fs'; +import { + writeFileSync, + mkdirSync, + rmSync, + existsSync, + readFileSync, +} from 'node:fs'; import { join, dirname } from 'node:path'; import { tmpdir } from 'node:os'; import { randomBytes } from 'node:crypto'; @@ -30,7 +36,11 @@ import { DEFAULT_CONNECTOR_IMAGE } from '../constants.js'; import { ConnectorAdminClient } from '../connector/admin-client.js'; /** Parse a Docker image reference into its name, optional tag, and optional digest. */ -function parseConnectorImage(ref: string): { name: string; tag?: string; digest?: string } { +function parseConnectorImage(ref: string): { + name: string; + tag?: string; + digest?: string; +} { const digestMatch = ref.match(/^(.+)@(sha256:[a-f0-9]+)$/); if (digestMatch) return { name: digestMatch[1]!, digest: digestMatch[2] }; const tagMatch = ref.match(/^(.+):([^:]+)$/); @@ -52,7 +62,13 @@ function parseConnectorImage(ref: string): { name: string; tag?: string; digest? // R2-MAJOR fix: the previous skip-when-absent semantics defeated the // drift-detection purpose in the very scenario the test was meant to police. const __filename = fileURLToPath(import.meta.url); -const MANIFEST_PATH = join(dirname(__filename), '..', '..', 'dist', 'image-manifest.json'); +const MANIFEST_PATH = join( + dirname(__filename), + '..', + '..', + 'dist', + 'image-manifest.json' +); const isCI = process.env['CI'] === 'true' || process.env['CI'] === '1'; const manifestExists = existsSync(MANIFEST_PATH); @@ -61,9 +77,9 @@ describe('DEFAULT_CONNECTOR_IMAGE manifest alignment', () => { it('CI invariant: dist/image-manifest.json must be present (place via download-artifact before running canary)', () => { throw new Error( `Manifest missing at ${MANIFEST_PATH}. In CI the publish workflow ` + - `must place this artifact via actions/download-artifact BEFORE the ` + - `canary runs. If you are running this locally, set CI=0 or copy the ` + - `manifest from a Story 45.1 publish workflow run.` + `must place this artifact via actions/download-artifact BEFORE the ` + + `canary runs. If you are running this locally, set CI=0 or copy the ` + + `manifest from a Story 45.1 publish workflow run.` ); }); return; @@ -73,7 +89,10 @@ describe('DEFAULT_CONNECTOR_IMAGE manifest alignment', () => { images: { connector: { digest: string } }; }; const parsed = parseConnectorImage(DEFAULT_CONNECTOR_IMAGE); - expect(parsed.digest, 'DEFAULT_CONNECTOR_IMAGE must be in digest form').toBeTruthy(); + expect( + parsed.digest, + 'DEFAULT_CONNECTOR_IMAGE must be in digest form' + ).toBeTruthy(); expect(parsed.digest).toBe(manifest.images.connector.digest); }); }); @@ -116,7 +135,9 @@ describe.skipIf(isTruthyEnv(process.env['SKIP_DOCKER']))( const parsedRef = parseConnectorImage(DEFAULT_CONNECTOR_IMAGE); const alreadyPulled = images.some((img) => { if (parsedRef.digest) { - return (img.RepoDigests ?? []).some((d) => d.includes(parsedRef.digest!)); + return (img.RepoDigests ?? []).some((d) => + d.includes(parsedRef.digest!) + ); } return (img.RepoTags ?? []).includes(DEFAULT_CONNECTOR_IMAGE); }); diff --git a/packages/townhouse/src/__integration__/tarball-contents.test.ts b/packages/townhouse/src/__integration__/tarball-contents.test.ts index 4607b0ff..dd0d0bac 100644 --- a/packages/townhouse/src/__integration__/tarball-contents.test.ts +++ b/packages/townhouse/src/__integration__/tarball-contents.test.ts @@ -20,7 +20,13 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { execFileSync } from 'node:child_process'; -import { existsSync, readFileSync, mkdtempSync, rmSync, readdirSync } from 'node:fs'; +import { + existsSync, + readFileSync, + mkdtempSync, + rmSync, + readdirSync, +} from 'node:fs'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { tmpdir } from 'node:os'; @@ -48,10 +54,10 @@ describe.skipIf(skipPackTest)('tarball-contents', () => { if (!existsSync(DIST_COMPOSE_HS) || !existsSync(DIST_COMPOSE_DEV)) { throw new Error( `tarball-contents test requires built dist/compose/. Missing:\n` + - ` ${DIST_COMPOSE_HS} ${existsSync(DIST_COMPOSE_HS) ? '✓' : '✗'}\n` + - ` ${DIST_COMPOSE_DEV} ${existsSync(DIST_COMPOSE_DEV) ? '✓' : '✗'}\n` + - `Run 'pnpm --filter @toon-protocol/townhouse build' first ` + - `(or set SKIP_PACK_TEST=1 to skip this test).` + ` ${DIST_COMPOSE_HS} ${existsSync(DIST_COMPOSE_HS) ? '✓' : '✗'}\n` + + ` ${DIST_COMPOSE_DEV} ${existsSync(DIST_COMPOSE_DEV) ? '✓' : '✗'}\n` + + `Run 'pnpm --filter @toon-protocol/townhouse build' first ` + + `(or set SKIP_PACK_TEST=1 to skip this test).` ); } @@ -61,18 +67,22 @@ describe.skipIf(skipPackTest)('tarball-contents', () => { // Run pnpm pack from the package directory. We do NOT parse pnpm's stdout // for the tgz path — output format varies across pnpm versions. The tmpdir // is created fresh by mkdtempSync, so readdirSync is the authoritative source. - execFileSync( - 'pnpm', - ['pack', '--pack-destination', packOutDir], - { cwd: PKG_DIR, encoding: 'utf-8', timeout: 60_000 } - ); + execFileSync('pnpm', ['pack', '--pack-destination', packOutDir], { + cwd: PKG_DIR, + encoding: 'utf-8', + timeout: 60_000, + }); const files = readdirSync(packOutDir).filter((f) => f.endsWith('.tgz')); - expect(files.length, 'expected exactly one .tgz in pack output dir').toBe(1); + expect(files.length, 'expected exactly one .tgz in pack output dir').toBe( + 1 + ); tgzPath = join(packOutDir, files[0]!); // Extract the tarball - execFileSync('tar', ['-xzf', tgzPath, '-C', extractDir], { timeout: 30_000 }); + execFileSync('tar', ['-xzf', tgzPath, '-C', extractDir], { + timeout: 30_000, + }); }, 90_000); afterAll(() => { @@ -81,44 +91,84 @@ describe.skipIf(skipPackTest)('tarball-contents', () => { }); it('tarball contains package/dist/compose/townhouse-hs.yml', () => { - const hsPath = join(extractDir, 'package', 'dist', 'compose', 'townhouse-hs.yml'); - expect(existsSync(hsPath), `expected ${hsPath} to exist in tarball`).toBe(true); + const hsPath = join( + extractDir, + 'package', + 'dist', + 'compose', + 'townhouse-hs.yml' + ); + expect(existsSync(hsPath), `expected ${hsPath} to exist in tarball`).toBe( + true + ); }); it('tarball contains package/dist/compose/townhouse-dev.yml', () => { - const devPath = join(extractDir, 'package', 'dist', 'compose', 'townhouse-dev.yml'); - expect(existsSync(devPath), `expected ${devPath} to exist in tarball`).toBe(true); + const devPath = join( + extractDir, + 'package', + 'dist', + 'compose', + 'townhouse-dev.yml' + ); + expect(existsSync(devPath), `expected ${devPath} to exist in tarball`).toBe( + true + ); }); it.skipIf(!manifestPresent)( 'tarball contains package/dist/image-manifest.json (skipped when manifest absent locally)', () => { - const manifestInTarball = join(extractDir, 'package', 'dist', 'image-manifest.json'); - expect(existsSync(manifestInTarball), `expected ${manifestInTarball} to exist in tarball`).toBe(true); + const manifestInTarball = join( + extractDir, + 'package', + 'dist', + 'image-manifest.json' + ); + expect( + existsSync(manifestInTarball), + `expected ${manifestInTarball} to exist in tarball` + ).toBe(true); } ); it('tarball HS YAML has no unsubstituted placeholders', () => { - const hsPath = join(extractDir, 'package', 'dist', 'compose', 'townhouse-hs.yml'); + const hsPath = join( + extractDir, + 'package', + 'dist', + 'compose', + 'townhouse-hs.yml' + ); if (!existsSync(hsPath)) return; // covered by previous test const content = readFileSync(hsPath, 'utf-8'); - expect(content, 'HS YAML in tarball must not contain unsubstituted placeholders').not.toMatch( - /\$\{TOON_[A-Z_]+_DIGEST\}/ - ); + expect( + content, + 'HS YAML in tarball must not contain unsubstituted placeholders' + ).not.toMatch(/\$\{TOON_[A-Z_]+_DIGEST\}/); }); it.skipIf(!manifestPresent)( 'tarball HS YAML has @sha256: digest form for every image: line (skipped when manifest absent)', () => { - const hsPath = join(extractDir, 'package', 'dist', 'compose', 'townhouse-hs.yml'); + const hsPath = join( + extractDir, + 'package', + 'dist', + 'compose', + 'townhouse-hs.yml' + ); if (!existsSync(hsPath)) return; const content = readFileSync(hsPath, 'utf-8'); - const imageLines = content.split('\n').filter((l) => /^\s+image:\s/.test(l)); + const imageLines = content + .split('\n') + .filter((l) => /^\s+image:\s/.test(l)); expect(imageLines.length).toBeGreaterThan(0); for (const line of imageLines) { - expect(line, `image line must use @sha256: form: ${line.trim()}`).toMatch( - /@sha256:[a-f0-9]{64}/ - ); + expect( + line, + `image line must use @sha256: form: ${line.trim()}` + ).toMatch(/@sha256:[a-f0-9]{64}/); } } ); diff --git a/packages/townhouse/src/compose-loader.test.ts b/packages/townhouse/src/compose-loader.test.ts index 4079364c..7d680582 100644 --- a/packages/townhouse/src/compose-loader.test.ts +++ b/packages/townhouse/src/compose-loader.test.ts @@ -1,10 +1,21 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { mkdtempSync, statSync, rmSync, mkdirSync, copyFileSync, readFileSync } from 'node:fs'; +import { + mkdtempSync, + statSync, + rmSync, + mkdirSync, + copyFileSync, + readFileSync, +} from 'node:fs'; import { join, dirname } from 'node:path'; import { tmpdir } from 'node:os'; import { fileURLToPath } from 'node:url'; -import { loadComposeTemplate, materializeComposeTemplate, ComposeLoaderError } from './compose-loader.js'; +import { + loadComposeTemplate, + materializeComposeTemplate, + ComposeLoaderError, +} from './compose-loader.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -32,8 +43,9 @@ describe('loadComposeTemplate', () => { it('throws ComposeLoaderError when template file is missing', () => { const missingDir = join(tmpdir(), 'nonexistent-fixture-dir-' + Date.now()); - expect(() => loadComposeTemplate('hs', { distDir: missingDir })) - .toThrowError(ComposeLoaderError); + expect(() => + loadComposeTemplate('hs', { distDir: missingDir }) + ).toThrowError(ComposeLoaderError); }); it('thrown ComposeLoaderError contains the missing path', () => { diff --git a/packages/townhouse/src/compose-loader.ts b/packages/townhouse/src/compose-loader.ts index f2aa24f9..b7383009 100644 --- a/packages/townhouse/src/compose-loader.ts +++ b/packages/townhouse/src/compose-loader.ts @@ -1,4 +1,12 @@ -import { readFileSync, writeFileSync, mkdirSync, chmodSync, statSync, lstatSync, existsSync } from 'node:fs'; +import { + readFileSync, + writeFileSync, + mkdirSync, + chmodSync, + statSync, + lstatSync, + existsSync, +} from 'node:fs'; import { dirname, join, resolve, isAbsolute } from 'node:path'; import { fileURLToPath } from 'node:url'; import { homedir } from 'node:os'; @@ -32,7 +40,9 @@ function defaultDistDir(): string { return resolve(here, '..', 'dist'); } -function assertValidProfile(profile: string): asserts profile is ComposeProfile { +function assertValidProfile( + profile: string +): asserts profile is ComposeProfile { if (!(VALID_PROFILES as readonly string[]).includes(profile)) { throw new ComposeLoaderError( `invalid compose profile: '${profile}'. Must be one of: ${VALID_PROFILES.join(', ')}.` @@ -47,8 +57,17 @@ function assertValidProfile(profile: string): asserts profile is ComposeProfile // belt-and-suspenders defense — it doesn't replace caller validation, but it // turns a silent privilege escalation into a loud error. const SYSTEM_PATH_PREFIXES = [ - '/etc', '/usr', '/bin', '/sbin', '/lib', '/lib64', - '/proc', '/sys', '/dev', '/boot', '/root', + '/etc', + '/usr', + '/bin', + '/sbin', + '/lib', + '/lib64', + '/proc', + '/sys', + '/dev', + '/boot', + '/root', ] as const; function assertValidTownhouseHome(home: string): void { @@ -65,14 +84,14 @@ function assertValidTownhouseHome(home: string): void { if (home === '/' || home === '\\') { throw new ComposeLoaderError( `townhouseHome must not be the filesystem root; got '${home}'. ` + - `This usually means $HOME is unset and homedir() returned '/'.` + `This usually means $HOME is unset and homedir() returned '/'.` ); } for (const prefix of SYSTEM_PATH_PREFIXES) { if (home === prefix || home.startsWith(prefix + '/')) { throw new ComposeLoaderError( `townhouseHome must not target a system directory; got '${home}'. ` + - `Allowed paths: under $HOME, under tmpdir(), or any user-writable location.` + `Allowed paths: under $HOME, under tmpdir(), or any user-writable location.` ); } } @@ -86,7 +105,7 @@ function assertNotSymlink(filePath: string): void { if (lst.isSymbolicLink()) { throw new ComposeLoaderError( `${filePath} is a symlink; refusing to write through it. ` + - `If this is intentional, remove the symlink and re-run.` + `If this is intentional, remove the symlink and re-run.` ); } } catch (err) { @@ -112,7 +131,7 @@ export function loadComposeTemplate( if (!existsSync(composePath)) { throw new ComposeLoaderError( `compose template not found: ${composePath}. ` + - `Did you run 'pnpm --filter @toon-protocol/townhouse build' first?` + `Did you run 'pnpm --filter @toon-protocol/townhouse build' first?` ); } return readFileSync(composePath, 'utf-8'); @@ -141,8 +160,8 @@ export function materializeComposeTemplate( if (profile === 'hs' && !existsSync(manifestSrc)) { throw new ComposeLoaderError( `image-manifest.json not found at ${manifestSrc}. ` + - `HS mode requires a digest-pinned image manifest. ` + - `Reinstall @toon-protocol/townhouse from npm to restore the manifest.` + `HS mode requires a digest-pinned image manifest. ` + + `Reinstall @toon-protocol/townhouse from npm to restore the manifest.` ); } // loadComposeTemplate also throws ENOENT if the source is missing — surface From f2ec7466740408e1cbcfa677927b6c2e49154a19 Mon Sep 17 00:00:00 2001 From: Jonathan Green Date: Sat, 9 May 2026 18:49:20 -0400 Subject: [PATCH 5/5] chore(townhouse): bump version 0.1.0 -> 0.1.0-rc2 for live-publish smoke First @toon-protocol/townhouse npm publish. RC tag chosen per Story 45.2 spec ("v0.1.0-rc2 or whatever the next test tag is") so the live-publish path can be smoke-tested against the patched tarball-verify gate without burning the v0.1.0 version namespace. Promote to v0.1.0 after the rc2 publish surfaces clean and an operator install of npx @toon-protocol/townhouse@0.1.0-rc2 round-trips the compose templates + manifest correctly. Co-Authored-By: Claude Sonnet 4.6 --- packages/townhouse/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/townhouse/package.json b/packages/townhouse/package.json index 213270a8..0b9cedff 100644 --- a/packages/townhouse/package.json +++ b/packages/townhouse/package.json @@ -1,6 +1,6 @@ { "name": "@toon-protocol/townhouse", - "version": "0.1.0", + "version": "0.1.0-rc2", "description": "TOON Townhouse — host-native orchestrator + dashboard for Docker-containerized TOON nodes", "type": "module", "main": "./dist/index.js",