Compare commits

..
Author SHA1 Message Date
jason.woltjeandClaude Opus 4.8 d752db93e4 fix(web): scope model list to provider and resolve composite catalog identity
ci/woodpecker/pr/ci Pipeline was successful
Fix-in-lane for the two Task-4 review findings, red-first TDD, inside the
existing nine-file fence.

M-A: the composer now filters model <option>s to the intentionally selected
provider (harness.providerId). With no provider chosen the model select offers
only its placeholder, so a user can never pick a model that belongs to a
different provider.

M-B: the model <option> value is now the collision-safe composite
`${providerId}:${modelId}` (was the bare modelId), the controlled select value
mirrors that same identity so the exact catalog row highlights, and the change
handler resolves the composite back to the exact catalog row and persists that
row's own {harnessId, providerId, modelId}. selectModel(providerId, modelId) no
longer combines a bare model id with ambient provider state, so two providers
exposing the same modelId stay distinct.

New red-first tests prove: cross-provider models absent, identical modelIds
under two providers stay distinct and resolve to the intended tuple, a provider
change invalidates the old model and keeps send disabled until the new tuple
persists, and a model pick does not enable send until its exact PUT resolves.
The four existing anti-masking invariants and the 422 tuple-preservation test
are intact.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01ESFAnh2t9HmLwng8oW95St
2026-08-11 21:13:40 -05:00
jason.woltjeandClaude Opus 4.8 ef66b0e7d6 feat(web): use structured harness catalog selection
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01ESFAnh2t9HmLwng8oW95St
2026-08-11 20:36:48 -05:00
2238 changed files with 8373 additions and 370165 deletions
+153 -13
View File
@@ -1,14 +1,154 @@
# Mosaic Stack standalone deployment (compose `stack` profile)
# Copy to .env and adjust. Port overrides exist because the defaults
# collide with common host services (and with the dev compose itself).
PG_HOST_PORT=5433
VALKEY_HOST_PORT=6380
GATEWAY_HOST_PORT=14242
# Registry image override (defaults to a local build of docker/gateway.Dockerfile):
# GATEWAY_IMAGE=git.mosaicstack.dev/mosaicstack/stack/gateway:sha-acf640d
# ─────────────────────────────────────────────────────────────────────────────
# Mosaic — Environment Variables Reference
# Copy this file to .env and fill in the values for your deployment.
# Lines beginning with # are comments; optional vars are commented out.
# ─────────────────────────────────────────────────────────────────────────────
# Optional explicit dogfood overlay (docker-compose.dogfood.yml).
# Both paths are required when that overlay is used. Use a dedicated next-based
# worktree and the external home of the unprivileged stack-dogfood seat.
# MOSAIC_DOGFOOD_WORKTREE=/home/example/src/mosaic-stack-worktrees/dogfood-1487
# MOSAIC_DOGFOOD_SEAT_HOME=/home/example/.mosaic/fleet/agents/stack-dogfood
# ─── Database (PostgreSQL 17 + pgvector) ─────────────────────────────────────
# Full connection string used by the gateway, ORM, and migration runner.
# Port 5433 avoids conflict with a host-side PostgreSQL instance.
DATABASE_URL=postgresql://mosaic:mosaic@localhost:5433/mosaic
# Docker Compose host-port override for the PostgreSQL container (default: 5433)
# PG_HOST_PORT=5433
# ─── Queue (Valkey 8 / Redis-compatible) ─────────────────────────────────────
# Port 6380 avoids conflict with a host-side Redis/Valkey instance.
VALKEY_URL=redis://localhost:6380
# Docker Compose host-port override for the Valkey container (default: 6380)
# VALKEY_HOST_PORT=6380
# ─── Gateway ─────────────────────────────────────────────────────────────────
# TCP port the NestJS/Fastify gateway listens on (default: 14242)
GATEWAY_PORT=14242
# Comma-separated list of allowed CORS origins.
# Must include the web app origin in production.
GATEWAY_CORS_ORIGIN=http://localhost:3000
# ─── Auth (BetterAuth) ───────────────────────────────────────────────────────
# REQUIRED — random secret used to sign sessions and tokens.
# Generate with: openssl rand -base64 32
BETTER_AUTH_SECRET=change-me-to-a-random-32-char-string
# Public base URL of the gateway (used by BetterAuth for callback URLs)
BETTER_AUTH_URL=http://localhost:14242
# ─── Web App (Next.js) ───────────────────────────────────────────────────────
# Public gateway URL — accessible from the browser, not just the server.
NEXT_PUBLIC_GATEWAY_URL=http://localhost:14242
# ─── OpenTelemetry ───────────────────────────────────────────────────────────
# OTLP HTTP endpoint (otel-collector or any OpenTelemetry-compatible backend)
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
# Service name shown in traces
OTEL_SERVICE_NAME=mosaic-gateway
# ─── AI Providers ────────────────────────────────────────────────────────────
# Ollama (local models — set OLLAMA_BASE_URL to enable)
# OLLAMA_BASE_URL=http://localhost:11434
# OLLAMA_HOST is a legacy alias for OLLAMA_BASE_URL
# OLLAMA_HOST=http://localhost:11434
# Comma-separated list of Ollama model IDs to register (default: llama3.2,codellama,mistral)
# OLLAMA_MODELS=llama3.2,codellama,mistral
# Anthropic (claude-sonnet-4-6, claude-opus-4-6, claude-haiku-4-5)
# ANTHROPIC_API_KEY=sk-ant-...
# OpenAI (gpt-4o, gpt-4o-mini, o3-mini)
# OPENAI_API_KEY=sk-...
# Z.ai / GLM (glm-4.5, glm-4.5-air, glm-4.5-flash)
# ZAI_API_KEY=...
# Custom providers — JSON array of provider configs
# Format: [{"id":"<id>","baseUrl":"<url>","apiKey":"<key>","models":[{"id":"<model-id>","name":"<label>"}]}]
# MOSAIC_CUSTOM_PROVIDERS=
# ─── Embedding Service ───────────────────────────────────────────────────────
# OpenAI-compatible embeddings endpoint (default: OpenAI)
# EMBEDDING_API_URL=https://api.openai.com/v1
# EMBEDDING_MODEL=text-embedding-3-small
# ─── Log Summarization Service ───────────────────────────────────────────────
# OpenAI-compatible chat completions endpoint for log summarization (default: OpenAI)
# SUMMARIZATION_API_URL=https://api.openai.com/v1
# SUMMARIZATION_MODEL=gpt-4o-mini
# Cron schedule for summarization job (default: every 6 hours)
# SUMMARIZATION_CRON=0 */6 * * *
# Cron schedule for log tier management (default: daily at 03:00)
# TIER_MANAGEMENT_CRON=0 3 * * *
# ─── Agent ───────────────────────────────────────────────────────────────────
# Filesystem sandbox root for agent file tools (default: process.cwd())
# AGENT_FILE_SANDBOX_DIR=/var/lib/mosaic/sandbox
# Comma-separated list of tool names available to non-admin users.
# Leave unset to allow all tools for all authenticated users.
# AGENT_USER_TOOLS=read_file,list_directory,search_files
# System prompt injected into every agent session (optional)
# AGENT_SYSTEM_PROMPT=You are a helpful assistant.
# ─── MCP Servers ─────────────────────────────────────────────────────────────
# JSON array of MCP server configs — set to enable MCP tool integration.
# Each entry: {"name":"<id>","url":"<http-or-sse-url>"}
# MCP_SERVERS=[{"name":"my-mcp","url":"http://localhost:3100/sse"}]
# ─── Coordinator ─────────────────────────────────────────────────────────────
# Root directory used to scope coordinator (worktree/repo) operations.
# Defaults to the monorepo root auto-detected from process.cwd().
# MOSAIC_WORKSPACE_ROOT=/home/user/projects/mosaic
# ─── Discord Plugin (optional — set DISCORD_BOT_TOKEN to enable) ─────────────
# DISCORD_BOT_TOKEN=
# DISCORD_GUILD_ID=
# DISCORD_GATEWAY_URL=http://localhost:14242
# ─── Telegram Plugin (optional — set TELEGRAM_BOT_TOKEN to enable) ───────────
# TELEGRAM_BOT_TOKEN=
# TELEGRAM_GATEWAY_URL=http://localhost:14242
# ─── SSO Providers (add credentials to enable) ───────────────────────────────
# --- Authentik (optional — set AUTHENTIK_CLIENT_ID to enable) ---
# AUTHENTIK_ISSUER=https://auth.example.com/application/o/mosaic/
# AUTHENTIK_CLIENT_ID=
# AUTHENTIK_CLIENT_SECRET=
# --- WorkOS (optional — set WORKOS_CLIENT_ID to enable) ---
# WORKOS_ISSUER=https://your-company.authkit.app
# WORKOS_CLIENT_ID=client_...
# WORKOS_CLIENT_SECRET=sk_live_...
# --- Keycloak (optional — set KEYCLOAK_CLIENT_ID to enable) ---
# KEYCLOAK_ISSUER=https://auth.example.com/realms/master
# Legacy alternative if you prefer to compose the issuer from separate vars:
# KEYCLOAK_URL=https://auth.example.com
# KEYCLOAK_REALM=master
# KEYCLOAK_CLIENT_ID=mosaic
# KEYCLOAK_CLIENT_SECRET=
# Feature flags — set to true alongside provider credentials to show SSO buttons in the UI
# NEXT_PUBLIC_WORKOS_ENABLED=true
# NEXT_PUBLIC_KEYCLOAK_ENABLED=true
+1 -5
View File
@@ -9,7 +9,7 @@ coverage
*.tsbuildinfo
.pnpm-store
__pycache__/
docs/.obsidian
docs/reports/
# Step-CA dev password — real file is gitignored; commit only the .example
infra/step-ca/dev-password
@@ -23,7 +23,3 @@ infra/step-ca/dev-password
# traversal error: ... .timestamp-*.mjs: No such file or directory" when the
# file vanished mid-scan. Ignoring them removes the race.
*.timestamp-*.mjs
# Playwright run artifacts (#1445, P6 E2E gate)
apps/web/test-results/
apps/web/playwright-report/
-11
View File
@@ -1,11 +0,0 @@
{
"schema_version": 2,
"integration_trunk": "next",
"release_branch": "main",
"flow": "trunk-release",
"canonical_remote": "https://git.mosaicstack.dev/mosaicstack/stack",
"canonical_clone": "host:/src/mosaic-stack",
"worktree_root": "host:/src/mosaic-stack-worktrees",
"worktree_policy": "orchestrator-precreated",
"notes": "next=development/integration; main=production release. Never branch work off main. worktree_policy is TRANSITIONAL: the wrapper worktree consumer is BLOCKED on the J3/#1174 amendment (checked roots + capacity guard); pre-creation is the interim orchestration choice, not closed policy — it becomes a timing choice only after the wrapper can validate this root."
}
+2 -2
View File
@@ -22,9 +22,9 @@ steps:
image: gcr.io/kaniko-project/executor:debug
environment:
REGISTRY_USER:
from_secret: REGISTRY_USERNAME
from_secret: gitea_username
REGISTRY_PASS:
from_secret: REGISTRY_PASSWORD
from_secret: gitea_password
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
CI_COMMIT_TAG: ${CI_COMMIT_TAG}
CI_COMMIT_SHA: ${CI_COMMIT_SHA}
+11 -150
View File
@@ -2,48 +2,24 @@
# node:24-alpine + python3/make/g++/postgresql-client + pnpm + a warm pnpm
# store. The install step resolves from the baked store (--prefer-offline)
# instead of paying a ~731s cold fetch + native compile every run.
#
# PINNED to an immutable lock-tag (#1328, brain D27): ci-image.yml pushes
# lock-<sha256(pnpm-lock.yaml)[:12]> atomically with :latest, so the two are
# byte-identical at push time. A mutable :latest resolves per-pod at pull time
# on the k8s backend, which made CI verdicts non-reproducible (same tree, same
# config, different images across runs; see #1324 comment 23382/23386). The pin
# changes ONLY through reviewed commits; a wrong tag fails loudly at image pull.
#
# Bump procedure: when a recipe change (pnpm-lock.yaml / Dockerfile.ci) lands on
# main, ci-image.yml pushes lock-<new>; a follow-up PR updates this anchor.
# Until then pipelines keep the old pin: reproducible, with the documented
# network-fallback lag (frozen-lockfile resolves missing packages from network).
# Known limitation: lock- addresses the lockfile only, so a Dockerfile-only
# change re-pushes the same tag with new content (#1328 follow-up: recipe-hash).
variables:
- &node_image 'git.mosaicstack.dev/mosaicstack/stack/ci-base:lock-9cb7ffcd8828'
- &node_image 'git.mosaicstack.dev/mosaicstack/stack/ci-base:latest'
- &enable_pnpm 'corepack enable'
when:
# PR + manual CI run on any branch: the pull_request pipeline is the merge
# gate (next is protected and the default branch since 2026-08-19).
# Push CI runs on main only. next deliberately runs NO push ci: post-merge
# verification on next is carried by publish.yml's `verify` step
# (pnpm verify:release), which mirrors this pipeline's complete mandatory
# set step-for-step, enforced by scripts/verify-release.test.mjs. PR CI
# tests the PR HEAD tree (refs/pull/N/head, measured 2026-08-19), not a
# merge ref, so if next advances before a merge the landed tree differs
# from the tested one; publish verify re-runs the full set on the landed
# tree (PGlite path). Measured 2026-08-19: the 21 most recent push events
# on next each ran exactly one pipeline (publish), zero ci.
# Keeping push ci off next also avoids a redundant second full-suite run
# per merge on the storage-constrained runner.
# PR + manual CI run on any branch the pull_request pipeline is the merge gate.
# push CI is restricted to protected branches (main) so a feature-branch push no
# longer fires a redundant SECOND pipeline alongside its PR pipeline. This ~halves
# CI load on the storage-constrained runner with zero loss of gating (branch
# protection requires no push/ci status context; main still gets full push CI).
- event: [pull_request, manual]
- event: push
branch: main
# Turbo remote cache (turbo.mosaicstack.dev) is wired in publish.yml via the
# org-level Woodpecker secret `turbo_token` (events: push/tag/cron/manual/
# deployment — never pull_request). This PR pipeline deliberately gets no
# remote-cache credentials: an untrusted PR must not be able to write to (or
# poison) the shared cache. Without TURBO_* env vars turbo falls back to
# local cache only, which is the intended behavior here.
# Turbo remote cache (turbo.mosaicstack.dev) is configured via Woodpecker
# repository-level environment variables (TURBO_API, TURBO_TEAM, TURBO_TOKEN).
# This avoids from_secret which is blocked on pull_request events.
# If the env vars aren't set, turbo falls back to local cache only.
steps:
install:
@@ -54,19 +30,6 @@ steps:
# the baked pnpm store.
- pnpm install --frozen-lockfile --prefer-offline
# ---------------------------------------------------------------------------
# The steps below (sanitization, upgrade-guard, typecheck, lint, format,
# test) are the COMPLETE mandatory verification set. SDLC-D-034 mirrors them
# one-for-one in the canonical terminal verification command — root
# `pnpm verify:release` (scripts/verify-release.mjs) — which the publish
# pipeline (.woodpecker/publish.yml `verify` step) runs before ANY publish
# effect. These lines stay direct (not routed through the runner) because the
# #1017 test-enumeration guard audits framework tool paths through THIS
# surface; scripts/verify-release.test.mjs enforces that the runner's stage
# table keeps matching these commands exactly, so the two cannot drift.
# ---------------------------------------------------------------------------
# Canonical verify:release stage `sanitization`.
# Blocking gate: public framework package must contain no operator-specific
# personal data or private $HOME defaults. Runs early (no node_modules needed).
sanitization:
@@ -83,73 +46,7 @@ steps:
# [0] of the pnpm chain, so severing that chain would silence it together
# with everything it guards; this direct line keeps one instrument running.
- bash packages/mosaic/framework/tools/quality/scripts/check-test-enumeration.sh
# Tool-index gate: a shipped wrapper that appears in no resident index doc
# is undiscoverable from inside a session, and an agent that cannot learn a
# wrapper exists reaches for raw curl instead — which is how a Gitea review
# got filed PENDING three times. Ships-and-documented is one commit, or red.
- bash packages/mosaic/framework/tools/quality/scripts/check-tools-index.sh --self-test
- bash packages/mosaic/framework/tools/quality/scripts/check-tools-index.sh
# Hermetic regression for issue-close.sh (#1081): mocks tea/curl onto PATH
# and sandboxes a throwaway git repo, so it resolves no real credentials and
# joins CI directly rather than the exclusions file.
- bash packages/mosaic/framework/tools/git/test-issue-close-fail-closed.sh
# Hermetic regression for the git identity ladder (#1356): mock tea on PATH,
# sandboxed repo, no real credentials (3/3 green under an empty HOME). Pins
# fail-closed: a seat whose login is missing gets a named error, never a
# borrowed identity. Joins CI directly; its #1007 exclusion is burned down.
- bash packages/mosaic/framework/tools/git/test-gitea-login-resolution.sh
# Hermetic regression for issue-view.sh (#1357): mock tea/curl, sandboxed
# repo. Pins that comment BODIES render on both paths and that a tea
# failure is named as what it was (git-config vs credential).
- bash packages/mosaic/framework/tools/git/test-issue-view-comments.sh
# Hermetic behavioural regression for the PreToolUse wrapper guard: proves
# it still blocks the three mistakes AND still lets reads, unwrapped
# endpoints and ordinary commands through. Both directions are asserted —
# a guard that over-blocks gets routed around, which fails just as hard.
- bash packages/mosaic/framework/tools/git/test-wrapper-guard.sh
# Hermetic regression for mosaic-worktree.sh at fleet scale: stubs git onto
# PATH so `list` faces ~450 KB of porcelain. The defect it pins is invisible
# at small size — `git … | awk '…exit'` gives the producer SIGPIPE, which
# under `set -euo pipefail` aborts the caller silently with rc=141 and no
# output. A repo only reaches that once it has enough worktrees, so the
# stub supplies the scale instead of the host's own checkout.
- bash packages/mosaic/framework/tools/git/test-mosaic-worktree-large-repo.sh
# Canonical repo-structure declaration gate (T51 WP5c, spec §5.4 point 2):
# .mosaic/repo.json is the machine-readable structure SSOT consumed by git
# wrappers and the T32 gate seat; this is its repo-side CI enforcement.
# Path-conditional: runs when the declaration, the vendored validator, or this
# pipeline config changes (manual runs always include it). Fails the pipeline
# on any VALIDATION_ERROR and enforces the schema_version 2 authoring rule
# (--require-v2: edited/new declarations may not stay v1). The validator is
# vendored into the framework tree (spec §5.1 final home) — provenance in its
# header; the hostile-input suite (101 arms, hermetic) runs alongside so the
# gate's own instrument ships in the same commit as the gate.
structure-declaration:
image: *node_image
commands:
- apk add --no-cache bash git
# MOSAIC_HOST_ROOT is a runtime anchor (spec §1.2a: unset fails closed
# for managed validation). CI has no host, so the step provisions an
# EXPLICIT fixture root — honest configuration for the resolution path,
# never a guess about a real host; the per-host containment checks are
# runtime concerns and do not run against a fixture. Grammar, schema,
# refs, flow, remote normalization, and path grammar all prove here.
- mkdir -p /tmp/t51-ci-hostroot
- bash packages/mosaic/framework/tools/structure/validate-repo-json.sh .mosaic/repo.json --require-v2
- bash packages/mosaic/framework/tools/structure/test-validate-repo-json.sh
environment:
MOSAIC_HOST_ROOT: /tmp/t51-ci-hostroot
when:
- event: pull_request
path:
include:
- '.mosaic/repo.json'
- 'packages/mosaic/framework/tools/structure/**'
- '.woodpecker/ci.yml'
- event: manual
# Canonical verify:release stage `upgrade-guard`.
# Blocking gate (#791): a framework upgrade must never write or delete an
# operator-owned path. The HARD GATE proves an unanticipated operator sentinel
# survives a keep-mode reseed byte-identical (with rsync present AND absent —
@@ -171,8 +68,6 @@ steps:
- bash packages/mosaic/framework/tools/quality/scripts/test-upgrade-durable-snapshot.sh
- bash packages/mosaic/framework/tools/quality/scripts/test-install-migration.sh
# Canonical verify:release stage `typecheck` — the same `pnpm typecheck`
# invocation (which runs the checkout preflight first, then turbo).
typecheck:
image: *node_image
commands:
@@ -183,8 +78,7 @@ steps:
- sanitization
- upgrade-guard
# lint, format, and test are independent — run in parallel after typecheck.
# Each runs exactly its canonical verify:release stage command.
# lint, format, and test are independent — run in parallel after typecheck
lint:
image: *node_image
commands:
@@ -201,12 +95,6 @@ steps:
depends_on:
- typecheck
# Canonical verify:release stage `test` — the `pnpm test` line below is the
# shared command; everything else in this step is PIPELINE-LEVEL
# prerequisite the canonical command expects its caller to provide (SDLC-D-034):
# the ci-postgres service + pg_isready wait + db:migrate (postgres path),
# `apk add openssl`, and the pinned pi install. None of those can move into
# the runner (it must also work locally on the PGlite path with no database).
test:
image: *node_image
environment:
@@ -221,16 +109,6 @@ steps:
# `apk add` guarantees openssl is present on PR pipelines too (and is a
# fast no-op once the rebuilt image already ships it).
- apk add --no-cache openssl
# Pi runtime (Invariant R): invariant_r_unittest.py hard-requires an
# installed `pi` binary at exactly this measured version — the test
# boots Pi's real tool registry to prove the read-only carve-out
# resolves to real, unshadowed builtins, and fails loud (by design)
# when the runtime is absent or drifts. The canonical Pi is
# @earendil-works/[email protected] exactly (@mariozechner/* is
# embedded-legacy). Step-level install because ci-base image publishes
# are currently blocked on registry auth; fold into Dockerfile.ci once
# that is fixed, keeping this as a fast no-op guard.
- npm install -g @earendil-works/[email protected]
# postgresql-client (pg_isready) is baked into ci-base.
# Wait up to 60s for CI postgres to be ready; fail fast if it never comes up.
- |
@@ -254,23 +132,6 @@ steps:
depends_on:
- typecheck
# Canonical verify:release stage `build` (#1445, P6): every PR proves the
# full workspace build — including the SPA `vite build` — before merge,
# instead of leaving build breakage to surface post-merge in publish.yml's
# verify step. Same canonical command the publish pipeline's build step runs.
build:
image: *node_image
commands:
- *enable_pnpm
- pnpm build
depends_on:
# after test, not typecheck: turbo gives `test` a ^build dependency, so
# running this step concurrently with test would put two independent
# turbo builds on the same shared-workspace dist/ and turbo cache with
# no cross-process locking — the same serialization invariant
# publish.yml documents for #1411.
- test
services:
ci-postgres:
image: pgvector/pgvector:pg17
+50 -360
View File
@@ -1,29 +1,10 @@
# Build, publish npm packages, and push Docker images
# Runs on main for stable publishes and on next for integration-line prereleases/images
#
# SDLC-D-034 publish gate: every publish effect (publish-npm, publish-next-npm,
# and every image build/push step) depends DIRECTLY on the `verify` step below.
# `verify` (a) asserts the provider's commit identity matches the actual
# checkout (CI_COMMIT_SHA == git rev-parse HEAD, fail closed on mismatch or
# emptiness) and (b) runs the canonical terminal verification command
# (`pnpm verify:release`), which mirrors the PR CI pipeline's complete
# mandatory set (sanitization, upgrade-guard, preflight+typecheck, lint,
# format:check, test, build) — see scripts/verify-release.mjs. A missing,
# failed, skipped, cancelled, or inconclusive verification therefore skips the
# dependent publish effects (fail closed). Path-filtered short-circuits may
# skip publish EFFECTS (e.g. docs-only merges) but never bypass `verify` for a
# publish that does run: `verify` itself carries no path filter.
# scripts/verify-release.test.mjs enforces this DAG invariant at checkout time.
variables:
# Pre-baked CI base (see .woodpecker/ci-image.yml): node:24-alpine +
# toolchain + warm pnpm store. Kills the second cold install publish pays.
# PINNED to the immutable lock-tag, not :latest (#1328, brain D27): a mutable
# tag resolves per-pod at pull time on the k8s backend and made CI verdicts
# non-reproducible (#1324). Byte-identical to :latest at pin time (pushed
# atomically by the same kaniko run, main 712c770, 2026-07-26). Bump only via
# reviewed PR, per the procedure in .woodpecker/ci.yml's header comment.
- &node_image 'git.mosaicstack.dev/mosaicstack/stack/ci-base:lock-9cb7ffcd8828'
- &node_image 'git.mosaicstack.dev/mosaicstack/stack/ci-base:latest'
- &enable_pnpm 'corepack enable'
# Heavy kaniko image builds (~25 min) — gate them so a merge that only touches
# the npm-only CLI (@mosaicstack/mosaic) or docs does NOT rebuild the platform
@@ -32,11 +13,6 @@ variables:
# non-excluded change still builds, so no transitive dep can silently go stale.
# (Woodpecker: `when` entries are OR'd; `path` applies to push/PR only — hence
# the separate `event: tag` entry.)
# #1407: ONE shared anchor for all three image steps. A second main-only
# anchor previously gated build-web/build-appservice, so next-lane pushes
# published gateway sha images with no web/appservice counterpart — no
# sha-parity set existed for next-lane containerized deploys. Every image
# step now builds on next too (sha-only destinations, enforced per step).
- &image_build_when
- event: tag
- event: [push, manual]
@@ -49,6 +25,16 @@ variables:
- '.woodpecker/**'
- event: [push, manual]
branch: next
- &main_image_build_when
- event: tag
- event: [push, manual]
branch: main
path:
exclude:
- 'packages/mosaic/**'
- 'docs/**'
- '**/*.md'
- '.woodpecker/**'
when:
- branch: [main, next]
@@ -62,67 +48,13 @@ steps:
# Resolve from the baked pnpm store instead of a cold network fetch.
- pnpm install --frozen-lockfile --prefer-offline
# SDLC-D-034 exact-commit publish gate. No `when`/path filter on purpose: it
# runs for every event this pipeline serves so no publish effect can ever
# start without it. Fails closed on commit-identity mismatch (or either SHA
# being empty) and on any incomplete verification.
verify:
image: *node_image
environment:
# Turbo remote cache (see .woodpecker/ci.yml header comment): org-level
# secret, exposed only on trusted events (push/tag/cron/manual/deployment).
TURBO_API: https://turbo.mosaicstack.dev
TURBO_TEAM: mosaic
TURBO_TOKEN:
from_secret: turbo_token
commands:
- *enable_pnpm
# (a) Commit identity: the provider's claimed SHA must equal the actual
# checkout HEAD — verification of anything else must never authorize a
# publish of this commit.
- |
if [ -z "$CI_COMMIT_SHA" ]; then
echo "[verify] FATAL: CI_COMMIT_SHA is empty — cannot certify commit identity" >&2
exit 1
fi
CHECKOUT_SHA="$(git rev-parse HEAD 2>/dev/null || true)"
if [ -z "$CHECKOUT_SHA" ]; then
echo "[verify] FATAL: git rev-parse HEAD returned nothing — cannot certify commit identity" >&2
exit 1
fi
if [ "$CI_COMMIT_SHA" != "$CHECKOUT_SHA" ]; then
echo "[verify] FATAL: provider commit ($CI_COMMIT_SHA) != checkout HEAD ($CHECKOUT_SHA)" >&2
exit 1
fi
echo "[verify] commit identity confirmed: $CHECKOUT_SHA"
# (b) Canonical terminal verification. Caller-provided prerequisites the
# runner expects (see .woodpecker/ci.yml comments): bash/rsync for the
# guard stages, openssl + the pinned pi binary for the test stage. git is
# baked into ci-base but re-asserted here so the identity check above can
# never silently depend on a stale baked image. DATABASE_URL is
# deliberately NOT set: the canonical command must hold on the PGlite
# path too and never sets or requires a database itself.
- apk add --no-cache bash rsync openssl git
- npm install -g @earendil-works/[email protected]
- pnpm verify:release
depends_on:
- install
build:
image: *node_image
environment:
# Turbo remote cache (see .woodpecker/ci.yml header comment): org-level
# secret, exposed only on trusted events (push/tag/cron/manual/deployment).
TURBO_API: https://turbo.mosaicstack.dev
TURBO_TEAM: mosaic
TURBO_TOKEN:
from_secret: turbo_token
commands:
- *enable_pnpm
- pnpm build
depends_on:
- install
- verify
publish-npm:
image: *node_image
@@ -182,7 +114,6 @@ steps:
exit 1
depends_on:
- build
- verify
publish-next-npm:
image: *node_image
@@ -211,20 +142,6 @@ steps:
echo "@mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaicstack/npm/" >> ~/.npmrc
DIST_TAGS_JSON="$(npm view @mosaicstack/mosaic dist-tags --registry https://git.mosaicstack.dev/api/packages/mosaicstack/npm/ --json)"
DIST_TAGS_JSON="$DIST_TAGS_JSON" node -e 'const tags = JSON.parse(process.env.DIST_TAGS_JSON || "{}"); if (!tags || typeof tags !== "object" || !Object.hasOwn(tags, "latest")) { throw new Error("Gitea npm registry did not return a usable dist-tags object"); } console.log("[publish-next] registry dist-tags OK: latest=" + tags.latest);'
# #1404: snapshot every publishable manifest BEFORE the transform so the
# workspace can be restored byte-exact after publish. The transform
# rewrites package.json in place (needed: pnpm publish reads the
# workspace manifests); without restore, later steps in this pipeline
# (build-gateway kaniko COPY + pnpm install --frozen-lockfile) see
# manifests that no longer match pnpm-lock.yaml and fail
# ERR_PNPM_OUTDATED_LOCKFILE. Snapshot dir is step-local tmp.
SNAPSHOT_DIR="$(mktemp -d /tmp/publish-next-manifests.XXXXXX)"
export SNAPSHOT_DIR
find apps packages plugins -name package.json -not -path "*/node_modules/*" -not -path "*/dist/*" | while read -r mf; do
mkdir -p "$SNAPSHOT_DIR/$(dirname "$mf")"
cp -p "$mf" "$SNAPSHOT_DIR/$mf"
done
echo "[publish-next] snapshotted $(find "$SNAPSHOT_DIR" -name package.json | wc -l) manifests to $SNAPSHOT_DIR"
node <<'NODE'
const fs = require('node:fs');
const path = require('node:path');
@@ -232,38 +149,23 @@ steps:
const pipelineNumber = process.env.CI_PIPELINE_NUMBER;
const roots = ['apps', 'packages', 'plugins'];
const updated = [];
const exactVersions = new Map(); // name -> bumped next version
function walk(dir, visit) {
function walk(dir) {
if (!fs.existsSync(dir)) return;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name === '.turbo') continue;
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
const packagePath = path.join(fullPath, 'package.json');
if (fs.existsSync(packagePath)) {
const manifest = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
if (manifest.name?.startsWith('@mosaicstack/') && !manifest.private) {
visit(manifest, packagePath);
}
}
walk(fullPath, visit);
if (fs.existsSync(packagePath)) updatePackage(packagePath);
walk(fullPath);
}
}
}
// #1389: two passes. Pass 1 bumps every publishable manifest to
// <stable+1>-next.<pipeline> exactly as before, recording name ->
// bumped version. Pass 2 rewrites every published manifest's
// @mosaicstack/* dependency entries (dependencies, devDependencies,
// peerDependencies, optionalDependencies) to the EXACT same-pipeline
// build. A caret range like ^0.0.3-next.2636 leaves the resolver free
// to pick any later build — and on a host with a stale cache, an
// installer-side scaffold pinned at stable, or a registry hiccup, that
// freedom is how a "next" install ends up executing stable-era code
// (web1 evidence: old tier validator, missing migrations). Exact pins
// make the defect class unrepresentable regardless of resolver path.
function bump(manifest, packagePath) {
function updatePackage(packagePath) {
const manifest = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
if (!manifest.name?.startsWith('@mosaicstack/') || manifest.private) return;
const stableMatch = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(manifest.version);
if (!stableMatch) {
throw new Error(manifest.name + " has unsupported semver version '" + manifest.version + "'");
@@ -272,40 +174,13 @@ steps:
const oldVersion = manifest.version;
manifest.version = major + '.' + minor + '.' + (Number(patch) + 1) + '-next.' + pipelineNumber;
fs.writeFileSync(packagePath, JSON.stringify(manifest, null, 2) + '\n');
exactVersions.set(manifest.name, manifest.version);
updated.push(manifest.name + ' ' + oldVersion + ' -> ' + manifest.version);
}
const DEP_FIELDS = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'];
let pinnedEntries = 0;
function pin(manifest, packagePath) {
let changed = false;
for (const field of DEP_FIELDS) {
const deps = manifest[field];
if (!deps || typeof deps !== 'object') continue;
for (const [name, range] of Object.entries(deps)) {
if (!name.startsWith('@mosaicstack/')) continue;
const exact = exactVersions.get(name);
if (!exact) {
throw new Error(
manifest.name + ' depends on ' + name +
' which has no bumped version in this publish set — cannot pin');
}
if (range === exact) continue;
deps[name] = exact;
pinnedEntries++;
changed = true;
}
}
if (changed) fs.writeFileSync(packagePath, JSON.stringify(manifest, null, 2) + '\n');
}
for (const root of roots) walk(root, bump);
for (const root of roots) walk(root, pin);
for (const root of roots) walk(root);
if (updated.length === 0) throw new Error('No publishable @mosaicstack/* packages found');
console.log('[publish-next] computed prerelease versions for ' + updated.length + ' packages:');
for (const line of updated) console.log('[publish-next] ' + line);
console.log('[publish-next] pinned ' + pinnedEntries + ' @mosaicstack/* dep entries to exact same-pipeline versions across ' + updated.length + ' manifests');
NODE
pnpm --filter "@mosaicstack/*" --filter "!@mosaicstack/web" --filter "!@mosaicstack/mosaic-as" publish --no-git-checks --access public --tag next
EXPECTED_VERSION="$(node -p "require('./packages/mosaic/package.json').version")"
@@ -315,187 +190,8 @@ steps:
exit 1
fi
echo "[publish-next] @mosaicstack/mosaic@next resolves to $RESOLVED_VERSION"
# #1389 post-publish guard: every freshly published manifest must carry
# EXACT same-pipeline @mosaicstack/* dep pins (no ranges, no stable
# fallback). A leak here fails the pipeline instead of shipping.
node <<'GUARD'
const { execFileSync } = require('node:child_process');
const fs = require('node:fs');
const path = require('node:path');
const pipelineNumber = process.env.CI_PIPELINE_NUMBER;
const registry = 'https://git.mosaicstack.dev/api/packages/mosaicstack/npm/';
const roots = ['apps', 'packages', 'plugins'];
const published = [];
function walk(dir) {
if (!fs.existsSync(dir)) return;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name === '.turbo') continue;
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
const packagePath = path.join(fullPath, 'package.json');
if (fs.existsSync(packagePath)) {
const m = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
if (m.name?.startsWith('@mosaicstack/') && !m.private) published.push(m.name);
}
walk(fullPath);
}
}
}
for (const root of roots) walk(root);
let failures = 0;
for (const name of published) {
let manifest;
try {
const out = execFileSync('npm', ['view', name + '@next', '--json', '--registry', registry],
{ encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 });
const arr = JSON.parse(out);
manifest = Array.isArray(arr) ? arr[arr.length - 1] : arr;
} catch (e) {
console.error('[publish-next-guard] FAIL ' + name + ': npm view failed: ' + e.message);
failures++;
continue;
}
const fields = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'];
for (const field of fields) {
const deps = manifest[field];
if (!deps || typeof deps !== 'object') continue;
for (const [dep, range] of Object.entries(deps)) {
if (!dep.startsWith('@mosaicstack/')) continue;
const expected = dep === name ? manifest.version : null;
const isExactPin = /^\d+\.\d+\.\d+-next\./.test(range);
const samePipeline = range.endsWith('-next.' + pipelineNumber);
if (!isExactPin) {
console.error('[publish-next-guard] FAIL ' + name + ' -> ' + dep + ' range "' + range + '" is not an exact -next pin (stable-leak class, #1389)');
failures++;
} else if (!samePipeline) {
console.error('[publish-next-guard] FAIL ' + name + ' -> ' + dep + ' pinned "' + range + '" but this pipeline published -next.' + pipelineNumber + ' (cross-pipeline pin)');
failures++;
}
}
}
}
if (failures > 0) {
console.error('[publish-next-guard] FATAL: ' + failures + ' dep-pin violation(s) — stable-dep leak into next publish (#1389)');
process.exit(1);
}
console.log('[publish-next-guard] OK: all ' + published.length + ' published manifests carry exact same-pipeline @mosaicstack/* dep pins');
GUARD
# #1404 restore: put the workspace manifests back byte-exact so later
# steps (build-gateway frozen-lockfile install) see the committed tree.
RESTORE_FAIL=0
while read -r mf; do
if [ -f "$SNAPSHOT_DIR/$mf" ]; then
cp -p "$SNAPSHOT_DIR/$mf" "$mf"
else
echo "[publish-next] FATAL: no snapshot for $mf — cannot restore (snapshot incomplete?)" >&2
RESTORE_FAIL=1
fi
done < <(find apps packages plugins -name package.json -not -path "*/node_modules/*" -not -path "*/dist/*")
# Pristine guard (#1404 red-first control): the publish step must leave
# the workspace byte-identical to the checkout for every manifest.
# git diff is the arbiter — any residual mutation fails THIS step
# instead of surfacing as ERR_PNPM_OUTDATED_LOCKFILE in build-gateway.
if ! git diff --exit-code -- '**/package.json' >/dev/null 2>&1; then
echo "[publish-next] FATAL: workspace package.json files still differ from HEAD after restore (#1404 class)" >&2
git diff --stat -- '**/package.json' >&2 || true
RESTORE_FAIL=1
fi
rm -rf "$SNAPSHOT_DIR"
if [ "$RESTORE_FAIL" -ne 0 ]; then exit 1; fi
echo "[publish-next] workspace manifests restored byte-exact (git diff clean); later steps see the committed tree"
depends_on:
- build
- verify
# #1445 (P6): headless Playwright E2E gate on every trunk merge. Boots the
# real gateway on the embedded PGlite path (no DATABASE_URL, no services)
# serving the built SPA bundle via WEB_DIST_DIR — the exact serving path the
# gateway image ships (docker/gateway.Dockerfile sets WEB_DIST_DIR to the
# baked bundle), which keeps #1407's parity guarantee: the image build steps
# below depend on this gate, so a bundle that fails E2E never publishes.
#
# Image pinned to the @playwright/test version in pnpm-lock.yaml so the
# image's bundled browsers match the workspace driver exactly (bump the two
# together). The step installs no workspace packages (corepack does fetch
# the pinned pnpm itself): it reuses the workspace node_modules
# from `install` and the dist outputs from `build` — the gateway's runtime
# dependency path is pure JS/WASM (PGlite is WASM, postgres-js is pure JS),
# so the alpine-installed modules run unchanged under this glibc image.
# depends_on publish-next-npm per the #1411 serialization invariant: this
# step reads the workspace and must never run inside the manifest-transform
# window.
e2e:
image: mcr.microsoft.com/playwright:v1.58.2-noble
environment:
GATEWAY_PORT: '14242'
PLAYWRIGHT_BASE_URL: http://localhost:14242
# The database is seeded by Playwright's globalSetup in this step, so
# login failures are real failures: without this flag the suite's
# skip-when-login-fails guards (a live-environment affordance) could
# skip every authenticated spec and go green while proving nothing.
E2E_REQUIRE_SEEDED_AUTH: '1'
commands:
- corepack enable
- |
# Throwaway signing secret for this step's ephemeral embedded database
# (the gateway refuses to boot without one). Generated per run so no
# usable literal lives in the tree.
export BETTER_AUTH_SECRET="$(head -c 32 /dev/urandom | base64)"
export WEB_DIST_DIR="$(pwd)/apps/web/dist"
if [ ! -f "$WEB_DIST_DIR/index.html" ]; then
echo "[e2e] FATAL: $WEB_DIST_DIR/index.html missing — did the build step run?" >&2
exit 1
fi
# Boot the gateway from the built dist, cwd- AND HOME-isolated: the
# local-tier PGlite database lives under $HOME/.config/mosaic/gateway/
# (database.module.ts), not under cwd, so HOME must point at the
# throwaway dir too or the run would share a database with anything
# else in the container's home.
GATEWAY_RUN_DIR="$(mktemp -d /tmp/e2e-gateway.XXXXXX)"
(cd "$GATEWAY_RUN_DIR" && export HOME="$GATEWAY_RUN_DIR" && exec node "$OLDPWD/apps/gateway/dist/main.js") > /tmp/gateway.log 2>&1 &
GATEWAY_PID=$!
ready=0
for i in $(seq 1 90); do
if node -e "fetch('http://localhost:' + process.env.GATEWAY_PORT + '/health', { signal: AbortSignal.timeout(2000) }).then((r) => process.exit(r.ok ? 0 : 1), () => process.exit(1))"; then
ready=1
break
fi
if ! kill -0 "$GATEWAY_PID" 2>/dev/null; then
echo "[e2e] FATAL: gateway process exited during startup" >&2
cat /tmp/gateway.log >&2
exit 1
fi
echo "[e2e] waiting for gateway ($i/90)..."
sleep 1
done
if [ "$ready" -ne 1 ]; then
echo "[e2e] FATAL: gateway did not become ready in 90s" >&2
cat /tmp/gateway.log >&2
exit 1
fi
echo "[e2e] gateway ready; running Playwright suite"
set +e
pnpm --filter @mosaicstack/web exec playwright test
E2E_EXIT=$?
set -e
kill "$GATEWAY_PID" 2>/dev/null || true
if [ "$E2E_EXIT" -ne 0 ]; then
echo "[e2e] FATAL: Playwright suite failed (exit $E2E_EXIT); gateway log follows" >&2
tail -100 /tmp/gateway.log >&2
echo "[e2e] browser-side traces/screenshots are under apps/web/test-results/ in the step workspace (not persisted past the pod)" >&2
fi
exit "$E2E_EXIT"
# Same filter as the image builds it gates: a merge that publishes no
# image (docs-only on main) pays no browser suite, and a skipped e2e does
# not block anything (skipped-dependency semantics, same as
# publish-next-npm on tag events).
when: *image_build_when
depends_on:
- build
- verify
# #1411: never read the workspace inside publish-next-npm's
# manifest-transform window.
- publish-next-npm
# TODO: Uncomment when ready to publish to npmjs.org
# publish-npmjs:
@@ -509,7 +205,6 @@ steps:
# - bash scripts/publish-npmjs.sh
# depends_on:
# - build
# - verify
# when:
# - event: [tag]
@@ -518,9 +213,9 @@ steps:
when: *image_build_when
environment:
REGISTRY_USER:
from_secret: REGISTRY_USERNAME
from_secret: gitea_username
REGISTRY_PASS:
from_secret: REGISTRY_PASSWORD
from_secret: gitea_password
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
CI_COMMIT_TAG: ${CI_COMMIT_TAG}
CI_COMMIT_SHA: ${CI_COMMIT_SHA}
@@ -547,26 +242,15 @@ steps:
/kaniko/executor --context . --dockerfile docker/gateway.Dockerfile $DESTINATIONS
depends_on:
- build
- verify
# #1411: publish-next-npm mutates workspace manifests in place during
# its transform window and restores them at step end. Any step that
# reads the pipeline workspace (kaniko COPY of manifests, later
# installs) must run AFTER publish-next-npm, never concurrently —
# pipeline 2648 raced a COPY inside the window and failed
# ERR_PNPM_OUTDATED_LOCKFILE despite a clean restore. This edge is the
# serialization invariant; add it to every new workspace consumer.
- publish-next-npm
# #1445 (P6): a bundle that fails the E2E gate never publishes an image.
- e2e
build-appservice:
image: gcr.io/kaniko-project/executor:debug
when: *image_build_when
when: *main_image_build_when
environment:
REGISTRY_USER:
from_secret: REGISTRY_USERNAME
from_secret: gitea_username
REGISTRY_PASS:
from_secret: REGISTRY_PASSWORD
from_secret: gitea_password
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
CI_COMMIT_TAG: ${CI_COMMIT_TAG}
CI_COMMIT_SHA: ${CI_COMMIT_SHA}
@@ -575,17 +259,8 @@ steps:
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
- |
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/appservice:sha-${CI_COMMIT_SHA:0:7}"
if [ "$CI_COMMIT_BRANCH" = "next" ]; then
if [ -n "$CI_COMMIT_TAG" ]; then
echo "[publish] FATAL: next appservice publish must be sha-only; refusing tag '$CI_COMMIT_TAG'" >&2
exit 1
fi
echo "[publish] next appservice publish is sha-only"
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
if [ "$CI_COMMIT_BRANCH" = "main" ]; then
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/appservice:latest"
elif [ -z "$CI_COMMIT_TAG" ]; then
echo "[publish] FATAL: appservice image publish may only run for main, next, or tag events" >&2
exit 1
fi
if [ -n "$CI_COMMIT_TAG" ]; then
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/appservice:$CI_COMMIT_TAG"
@@ -593,14 +268,29 @@ steps:
/kaniko/executor --context . --dockerfile docker/appservice.Dockerfile $DESTINATIONS
depends_on:
- build
- verify
# #1411: publish-next-npm mutates workspace manifests in place during
# its transform window and restores them at step end. Any step that
# reads the pipeline workspace (kaniko COPY of manifests, later
# installs) must run AFTER publish-next-npm, never concurrently —
# pipeline 2648 raced a COPY inside the window and failed
# ERR_PNPM_OUTDATED_LOCKFILE despite a clean restore. This edge is the
# serialization invariant; add it to every new workspace consumer.
- publish-next-npm
# #1445 (P6): a bundle that fails the E2E gate never publishes an image.
- e2e
build-web:
image: gcr.io/kaniko-project/executor:debug
when: *main_image_build_when
environment:
REGISTRY_USER:
from_secret: gitea_username
REGISTRY_PASS:
from_secret: gitea_password
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
CI_COMMIT_TAG: ${CI_COMMIT_TAG}
CI_COMMIT_SHA: ${CI_COMMIT_SHA}
commands:
- mkdir -p /kaniko/.docker
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
- |
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/web:sha-${CI_COMMIT_SHA:0:7}"
if [ "$CI_COMMIT_BRANCH" = "main" ]; then
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/web:latest"
fi
if [ -n "$CI_COMMIT_TAG" ]; then
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/web:$CI_COMMIT_TAG"
fi
/kaniko/executor --context . --dockerfile docker/web.Dockerfile $DESTINATIONS
depends_on:
- build
-67
View File
@@ -81,73 +81,6 @@ pnpm format:check # Prettier check
pnpm build # Build all packages and applications
```
## Branch Model and Merge Process — `main` and `next` (CANONICAL)
**Every contribution targets `next` first. No exceptions.** Features, fixes, tests,
docs, and policy changes all take the same route; urgency changes queue priority,
never the route. Agents never commit to or merge into `main`.
| Branch | Role | Who merges into it |
| ------ | ---------------------------------------------------------------- | --------------------------------------------------------------------------- |
| `next` | Integration trunk — the only PR target for contributions | The designated merge-gate agent, after all gates pass. Never the PR author. |
| `main` | Stable/release line — receives promotion merges from `next` only | Jason only (or an agent he explicitly delegates for a named promotion). |
### Contribution sequencing (in order, no skipping)
1. **Issue first.** Work is tracked in a Gitea issue before a branch exists. The
issue number appears in the branch name and the PR body.
2. **Branch from the current `origin/next` head.** Name it
`feat/…`, `fix/…`, `docs/…`, or `test/…` with the issue number
(e.g. `docs/1214-branch-process`). Record the base SHA in the PR body.
3. **Develop with evidence.** Applicable tests accompany the change. Hooks are
never bypassed (`--no-verify` is prohibited). Stage explicit paths — never
`git add -A`.
4. **Open the PR against `next`.** The body states: scope, base SHA,
verification commands with results, and any known pre-existing failures on
the base — documented, not retried to green and not absorbed silently.
5. **CI must be terminal-green on the exact head.** All bounded Woodpecker
steps succeed (`verify-terminal-green` contract). Pipelines for fork PRs
start `blocked`; a maintainer approves the run — approving CI is not
approving the PR.
6. **Independent review. Self-merge is prohibited** — for every agent, on every
PR, including trivial ones. Where the change touches protected or
contract-bearing content, the reviewer verifies the exact head
(exact-byte/exact-blob comparison), not a description of it. An `AMEND`
verdict returns the PR to its author; the reviewer's gate stays held until
a fresh exact head passes.
7. **Merge into `next`** happens only after CI green + review pass, pinned to
the reviewed head SHA (a post-review push voids the review).
8. **Promotion `next` → `main`** is a deliberate, Jason-owned reconciliation
merge — not part of any contribution's lifecycle. Contributors are done at
step 7.
### Responsibilities
- **Contributor** — base pinning, green CI, evidence in the PR body,
responding to AMEND verdicts, never merging own work.
- **Reviewer / merge gate** — independent verification on the exact head;
holds and lifts gates; executes the merge into `next`.
- **Orchestrator / adjudicator** — cross-PR sequencing, disposition when PRs
collide, conflict adjudication.
- **Jason** — `next``main` promotions, merge-authority grants, collaborator
and token provisioning. Agents cannot grant themselves or each other any of
these.
### Hotfixes and divergence
- A hotfix follows the same path: branch from `next`, PR to `next`, gates,
merge, then an expedited Jason-owned promotion if `main` needs it urgently.
Committing the fix to `main` directly is prohibited even under pressure.
- **Never land work on `main` that is not on `next`.** This has happened
(issue #1152's goal controller reached `main` without reaching `next`) and
every later PR paid for it. If it happens anyway: transplant the work onto
a `next`-based branch with provenance-preserving commits
(`git cherry-pick -x` or explicit SHA references in the messages), PR it
through the normal gates, and let promotion re-align `main`. Do not
hand-patch `main` to compensate.
- Force-pushing a branch you do not own is prohibited; rebasing your own PR
branch is fine before review, and voids any review already given.
## Database and Local Runtime Safety
- Current local data-layer work uses in-process PGlite; leave `DATABASE_URL` unset.
+3 -51
View File
@@ -74,14 +74,6 @@ The launcher verifies your config, checks for `SOUL.md`, injects your `AGENTS.md
Pi launches default to a token-lean skill posture: `mosaic pi` passes `--no-skills` so Pi does not preload every global skill description into the system prompt. Use `MOSAIC_PI_SKILL_MODE=all mosaic pi` for the legacy all-skills catalog, or `MOSAIC_PI_SKILL_MODE=discover mosaic pi` to let Pi use its native settings/project skill discovery.
Mosaic also loads its Pi extensions from `~/.config/mosaic/runtime/pi/`. Inside Pi,
`/goal set <statement>` starts a bounded persistent loop that checks every turn and successful
compaction, requires two evidence-bearing completion reports, and can be inspected or stopped with
`/goal status`, `/goal pause`, `/goal resume`, and `/goal cancel`. Controller-owned goal-state
entries redact common credential shapes, but Pi's model/tool-call history is separate, so goals and
evidence must never contain secrets or raw sensitive output. Mosaic does not install this extension
into `~/.pi/agent/extensions/`.
### TUI & Gateway
```bash
@@ -146,9 +138,9 @@ mosaic brain tasks
mosaic brain conversations
# Agent forge pipeline
mosaic forge run [--simulate] # fails closed (FORGE_NO_EXECUTOR) with no executor wired; --simulate for typed simulated runs
mosaic forge run
mosaic forge status
mosaic forge resume [--simulate] # same fail-closed rule as forge run
mosaic forge resume
mosaic forge personas
# Structured logging
@@ -208,46 +200,6 @@ mosaic telemetry upload # Dry-run unless opted in
Consent state is persisted in config. Remote upload is a no-op until you run `mosaic telemetry opt-in`.
## Standalone container deployment
The `stack` profile runs PostgreSQL, Valkey, the gateway, and the bundled webUI. Copy
`.env.example` to `.env`, generate `BETTER_AUTH_SECRET`, then start the profile:
```bash
cp .env.example .env
printf 'BETTER_AUTH_SECRET=%s\n' "$(openssl rand -hex 32)" >> .env
docker compose --profile stack up -d
```
The optional dogfood overlay gives one dedicated in-stack agent a writable stack
worktree and its own read-only credential slot. It does not mount the fleet brain or
any other seat. Prepare a `next`-based worktree and an unprivileged `stack-dogfood`
seat outside the container, then set these paths in `.env`:
```dotenv
MOSAIC_DOGFOOD_WORKTREE=/path/to/mosaic-stack-worktrees/dogfood-1487
MOSAIC_DOGFOOD_SEAT_HOME=/path/to/.mosaic/fleet/agents/stack-dogfood
```
The seat home must contain only that seat's credential at
`secrets/gitea-mosaicstack-stack-dogfood.token`. Never place the token value in
`.env`. Start the overlay with:
```bash
docker compose \
-f docker-compose.yml \
-f docker-compose.dogfood.yml \
--profile stack up -d
```
The overlay scopes regular-agent tools to the mounted checkout. For issue and PR
operations, instruct the agent to use `/opt/mosaic/tools/git/`. The gateway image
configures `git-credential-mosaic` as Git's system credential helper, so pushes and
`pr-create.sh` resolve only the `stack-dogfood` slot and fail if it is absent.
This deployment route is separate from the local source-development restrictions
below.
## Development
### Prerequisites
@@ -379,7 +331,7 @@ The framework is the bash-based standards layer installed to every developer mac
├── bin/mosaic ← Unified launcher (claude, codex, opencode, pi, yolo)
├── guides/ ← E2E delivery, orchestrator protocol, PRD, etc.
├── runtime/ ← Per-runtime configs (claude/, codex/, opencode/, pi/)
├── skills/ ← Universal skills (shipped with the framework package)
├── skills/ ← Universal skills (synced from agent-skills repo)
├── tools/ ← Tool suites (orchestrator, git, quality, prdy, etc.)
└── memory/ ← Persistent agent memory (preserved across upgrades)
```
-71
View File
@@ -1,71 +0,0 @@
# REPORT A1207
Date: 2026-08-13
Branch: `fix/869-lease-probe-timeout`
Starting head: `2373a5ad345fb316ad2460f6390baab1f45ba08f`
Base: `216cd72226cd9ee17eea461cfe7cd0e010a22f02`
## What changed
- Added Python behavior tests using isolated temporary directories and marker-writing fake `mosaic` executables. They prove that the supplied `PATH` wins over ambient `os.environ["PATH"]`, and that absent or empty supplied `PATH` values do not search ambient paths, platform defaults, or the current directory.
- Bound Python override behavior with executable fakes: a valid `MOSAIC_LEASE_VERSION_PROBE_COMMAND` wins over supplied and ambient `PATH`; an invalid override returns `None` without PATH fallback.
- Added a Python runner binding test that captures kwargs and requires `timeout=10.0`. Existing timeout, transport-error, and nonzero-exit checks remain fail-closed with `None`.
- Added the optional TypeScript dependency-injection seam `CapabilityProbeExecFile`, defaulting to the existing real `execFileSync` implementation. Production callers have no behavior change.
- Added TypeScript tests that capture child-process options and require exactly `timeout: 10_000`. Injected timeout, spawn-error, nonzero-exit, unparseable JSON, and malformed-object cases all return `null`.
- Removed the ambient no-dependency TypeScript smoke case that could execute a built checkout's real CLI. Default resolver and supervisor behavior retain their isolated tests, while capability transport tests now use an isolated artifact or the injected transport.
No Python production code changed relative to `2373a5ad`. The only production delta is the optional TypeScript child-process injection seam.
## Hermeticity incident and correction
An initial ambient-lookup mutation run exposed that the pre-existing Python "not resolvable" test left ambient process PATH uncontrolled. On this host, that mutation resolved and executed the host `mosaic` capability probe. A post-build intermediate TypeScript run also let the pre-existing no-dependency smoke case execute the checkout's built `dist/cli.js` capability probe. No `claude` process was run. I then isolated the Python test's ambient PATH, removed the TypeScript ambient smoke case, repeated the PATH mutation using only marker-writing temporary fakes, and repeated the final suites without either real probe path.
## Mutation evidence
Each mutation was applied independently, its focused suite was run, and the production source was restored before the final run.
| Mutation | Result | Reddened test name(s) |
| ------------------------------------------------------------------------------------------ | ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `shutil.which("mosaic", path=environ.get("PATH", ""))` to ambient `shutil.which("mosaic")` | RED, three failures | `ProbeActivationCapabilityTest.test_supplied_path_wins_over_ambient_process_path`; `ProbeActivationCapabilityTest.test_absent_or_empty_supplied_path_never_falls_back_or_executes` for both absent and empty PATH subtests |
| Python `PROBE_TIMEOUT_SECONDS: 10.0` to `2.0` | RED, one failure | `ProbeActivationCapabilityTest.test_probe_passes_ten_second_timeout_to_runner` |
| TypeScript `LEASE_CAPABILITY_PROBE_TIMEOUT_MS: 10_000` to `2_000` | RED, one failure | `defaultCapabilityProbe > passes the exact ten-second timeout to the injected child-process transport` |
## Final test run
Dependencies were installed first with `pnpm install --frozen-lockfile`. Workspace dependencies were then built with `pnpm --filter '@mosaicstack/mosaic...' run build` so package type declarations were available.
```text
$ cd packages/mosaic && python3 src/mutator-gate/version_coupling_unittest.py
...................
----------------------------------------------------------------------
Ran 19 tests in 0.007s
OK
$ pnpm exec vitest run src/commands/lease-activation-probe.spec.ts
✓ src/commands/lease-activation-probe.spec.ts (20 tests) 80ms
Test Files 1 passed (1)
Tests 20 passed (20)
```
```text
$ pnpm exec prettier --check packages/mosaic/src/commands/lease-activation-probe.ts packages/mosaic/src/commands/lease-activation-probe.spec.ts
Checking formatting...
All matched files use Prettier code style!
$ pnpm --filter @mosaicstack/mosaic lint
> eslint src
$ pnpm --filter @mosaicstack/mosaic typecheck
> tsc --noEmit
$ python3 -m py_compile packages/mosaic/src/mutator-gate/version_coupling_unittest.py packages/mosaic/framework/tools/lease-broker/activation_version_gate.py
$ git diff --check
```
All commands above exited zero.
## Ambiguities skipped
None.
-1
View File
@@ -28,7 +28,6 @@
"dependencies": {
"@anthropic-ai/sdk": "^0.80.0",
"@fastify/helmet": "^13.0.2",
"@fastify/static": "^8.3.0",
"@mariozechner/pi-ai": "^0.65.0",
"@mariozechner/pi-coding-agent": "^0.65.0",
"@modelcontextprotocol/sdk": "^1.27.1",
@@ -417,7 +417,7 @@ describe('ConversationsController — search endpoint', () => {
},
];
brain = createMockBrain({ searchResults });
controller = new ConversationsController(brain as never, { runtimeMode: 'legacy' });
controller = new ConversationsController(brain as never);
});
it('returns matching messages for a valid search query', async () => {
@@ -479,7 +479,7 @@ describe('ConversationsController — search endpoint', () => {
describe('ConversationsController — message CRUD', () => {
it('listMessages returns 404 when conversation is not owned by user', async () => {
const brain = createMockBrain({ conversation: undefined });
const controller = new ConversationsController(brain as never, { runtimeMode: 'legacy' });
const controller = new ConversationsController(brain as never);
await expect(controller.listMessages(CONV_ID, { id: USER_ID })).rejects.toBeInstanceOf(
NotFoundException,
@@ -489,7 +489,7 @@ describe('ConversationsController — message CRUD', () => {
it('listMessages returns the messages for an owned conversation', async () => {
const msgs = [makeMessage('user', 'Test message'), makeMessage('assistant', 'Test reply')];
const brain = createMockBrain({ conversation: makeConversation(), messages: msgs });
const controller = new ConversationsController(brain as never, { runtimeMode: 'legacy' });
const controller = new ConversationsController(brain as never);
const result = await controller.listMessages(CONV_ID, { id: USER_ID });
@@ -500,7 +500,7 @@ describe('ConversationsController — message CRUD', () => {
it('addMessage returns the persisted message', async () => {
const brain = createMockBrain({ conversation: makeConversation() });
const controller = new ConversationsController(brain as never, { runtimeMode: 'legacy' });
const controller = new ConversationsController(brain as never);
const result = await controller.addMessage(
CONV_ID,
@@ -190,13 +190,7 @@ beforeEach((ctx) => {
});
afterAll(async () => {
// Cleanup only when the fixture actually installed rows. `handle` is set
// before the first query (createDb connects lazily), so on an unreachable
// database `handle` is truthy while nothing was inserted — cleanup must
// honor `dbAvailable` or the skip path fails the file with ECONNREFUSED in
// afterAll (caught live by the publish pipeline's no-DATABASE_URL verify
// step, pipeline 2486).
if (!handle || !dbAvailable) return;
if (!handle) return;
const db = handle.db;
// Delete in dependency order (FK constraints)
@@ -1,133 +0,0 @@
import { RequestMethod, type Type } from '@nestjs/common';
import { describe, expect, it } from 'vitest';
import { AppModule } from '../app.module.js';
import { HierarchyModule } from '../hierarchy/hierarchy.module.js';
/**
* Hierarchy route inventory (contract 1 §6.3).
*
* The hierarchy command family is a CLOSED enumeration asserted here, not a
* prose claim: every hierarchy-flavored route the AppModule graph declares
* must appear in HIERARCHY_COMMAND_FAMILY, and vice versa. Adding or
* removing a hierarchy route without updating this inventory (and its
* witnesses) fails CI first. This replaces the M4-1b-i zero-routes
* baseline.
*/
interface RouteEntry {
method: string;
path: string;
controller: string;
}
/** Module-metadata entry: a module class or a DynamicModule-shaped object. */
type ModuleEntry =
| Type<unknown>
| { module: Type<unknown>; imports?: unknown[]; controllers?: Type<unknown>[] };
function collectControllers(root: ModuleEntry): Type<unknown>[] {
const visited = new Set<unknown>();
const controllers: Type<unknown>[] = [];
const walk = (entry: ModuleEntry | undefined | null): void => {
if (!entry || visited.has(entry)) return;
visited.add(entry);
const moduleClass = typeof entry === 'function' ? entry : entry.module;
// Entries with no resolvable class (forwardRef wrappers, async dynamic
// modules) carry no decorator metadata to read here.
if (typeof moduleClass !== 'function') return;
if (visited.has(moduleClass) && typeof entry !== 'function') return;
visited.add(moduleClass);
// 'controllers' / 'imports' are the metadata keys the @Module decorator writes.
const declared = (Reflect.getMetadata('controllers', moduleClass) ?? []) as Type<unknown>[];
controllers.push(...declared);
if (typeof entry !== 'function' && entry.controllers) controllers.push(...entry.controllers);
const imports = [
...((Reflect.getMetadata('imports', moduleClass) ?? []) as ModuleEntry[]),
...(typeof entry !== 'function' ? ((entry.imports ?? []) as ModuleEntry[]) : []),
];
for (const imported of imports) walk(imported);
};
walk(root);
return controllers;
}
function routesOf(controller: Type<unknown>): RouteEntry[] {
// 'path' on the class is the @Controller prefix; 'path'/'method' on a
// handler are written by the @Get/@Post/... route decorators.
const base = (Reflect.getMetadata('path', controller) ?? '') as string | string[];
const bases = Array.isArray(base) ? base : [base];
const routes: RouteEntry[] = [];
const prototype = controller.prototype as Record<string, unknown>;
for (const name of Object.getOwnPropertyNames(prototype)) {
if (name === 'constructor') continue;
const handler = Object.getOwnPropertyDescriptor(prototype, name)?.value;
if (typeof handler !== 'function') continue;
const method = Reflect.getMetadata('method', handler) as number | undefined;
if (method === undefined) continue;
const sub = (Reflect.getMetadata('path', handler) ?? '/') as string;
for (const prefix of bases) {
const path = `/${prefix}/${sub}`.replace(/\/+/g, '/').replace(/(.)\/$/, '$1');
routes.push({
method: RequestMethod[method] ?? String(method),
path,
controller: controller.name,
});
}
}
return routes;
}
/**
* The closed command family (contract 1 §5, M4-1b-ii). Every entry is a
* mutation audited via the M4-1b-i path or one of the two ratified reads
* (granted companies, the §2.8 directory carve-out).
*/
const HIERARCHY_COMMAND_FAMILY = [
'POST /api/hierarchy/companies',
'GET /api/hierarchy/companies',
'GET /api/hierarchy/companies/directory',
'POST /api/hierarchy/companies/:id/rename',
'POST /api/hierarchy/companies/:id/visibility',
'DELETE /api/hierarchy/companies/:id',
'POST /api/hierarchy/estates',
'POST /api/hierarchy/estates/:id/rename',
'POST /api/hierarchy/estates/:id/transfer',
'DELETE /api/hierarchy/estates/:id',
'POST /api/hierarchy/platform-projects',
'POST /api/hierarchy/platform-projects/:id/rename',
'POST /api/hierarchy/platform-projects/:id/transfer',
'DELETE /api/hierarchy/platform-projects/:id',
'POST /api/hierarchy/grants',
'POST /api/hierarchy/grants/:id/change',
'DELETE /api/hierarchy/grants/:id',
] as const;
describe('hierarchy route inventory (§6.3)', () => {
const inventory = collectControllers(AppModule).flatMap(routesOf);
it('control: the enumeration sees the known route surface', () => {
const paths = inventory.map((r) => `${r.method} ${r.path}`);
expect(paths).toContain('GET /health');
expect(paths).toContain('POST /api/workspaces');
expect(paths).toContain('GET /api/teams');
expect(inventory.length).toBeGreaterThan(20);
});
it('the hierarchy surface is exactly the declared command family', () => {
const hierarchyRoutes = inventory
.filter((r) => /hierarch|compan|estate|platform[-_]?project/i.test(r.path))
.map((r) => `${r.method} ${r.path}`)
.sort();
expect(hierarchyRoutes).toEqual([...HIERARCHY_COMMAND_FAMILY].sort());
});
it('every command-family route lives on HierarchyController inside HierarchyModule', () => {
const controllers = collectControllers(HierarchyModule);
expect(controllers.map((c) => c.name)).toEqual(['HierarchyController']);
const declared = controllers
.flatMap(routesOf)
.map((r) => `${r.method} ${r.path}`)
.sort();
expect(declared).toEqual([...HIERARCHY_COMMAND_FAMILY].sort());
});
});
@@ -35,25 +35,6 @@ function payload(content: string, messageId: string, correlationId: string): Dis
};
}
/**
* The chat runtime router must never be exercised on the Discord approval/stop control paths —
* those paths run entirely through the command-authorization, runtime-provider and durable-session
* dependencies. Placed in the gateway's chat-runtime-router slot (the former direct `AgentService`
* slot) so any accidental chat-runtime dispatch throws loudly instead of silently passing. Because
* approval/stop never resolve a chat runtime, this fixture is never triggered and the integration
* stays a GREEN cross-surface control.
*/
function failIfUsedChatRuntimeRouter() {
return {
onModuleInit: () => {
throw new Error('chat runtime router must not initialise on the Discord control path');
},
get active(): never {
throw new Error('chat runtime must not be resolved on the Discord approval/stop path');
},
};
}
function authorization(): CommandAuthorizationService {
const entries = new Map<string, string>();
return new CommandAuthorizationService(
@@ -132,7 +113,7 @@ describe('interaction Discord/CLI durable-session integration', () => {
},
);
const gateway = new ChatGateway(
failIfUsedChatRuntimeRouter() as never,
{} as never,
{} as never,
{} as never,
{} as never,
@@ -1,332 +0,0 @@
import { type Type } from '@nestjs/common';
import { Test, type TestingModule } from '@nestjs/testing';
import type { SlashCommandPayload } from '@mosaicstack/types';
import { describe, expect, it, vi } from 'vitest';
import { AgentService, type AgentSession } from '../agent/agent.service.js';
import { ProviderService } from '../agent/provider.service.js';
import { AppModule } from '../app.module.js';
import { CommandAuthorizationService } from '../commands/command-authorization.service.js';
import { CommandExecutorService } from '../commands/command-executor.service.js';
import { CommandsModule } from '../commands/commands.module.js';
import { CommandRuntimeApprovalVerifier } from '../commands/runtime-approval-verifier.js';
import { PreferencesModule } from '../preferences/preferences.module.js';
import { SystemOverrideService } from '../preferences/system-override.service.js';
const fakeDb = {
$client: { exec: async (): Promise<void> => {} },
execute: async (): Promise<{ rows: unknown[] }> => ({ rows: [] }),
select: () => ({
from: () => ({
where: async (): Promise<Array<{ count: number }>> => [{ count: 1 }],
}),
}),
insert: () => ({ values: async (): Promise<void> => {} }),
};
const fakeProviderService = {
onModuleInit: async (): Promise<void> => {},
onModuleDestroy: (): void => {},
getRegistry: () => ({ getAvailable: () => [], getAll: () => [], find: () => undefined }),
getDefaultModel: () => undefined,
listAvailableModels: () => [],
listProviders: () => [],
getAdapter: () => undefined,
getProvidersHealth: () => [],
};
function compileRealAppGraph(): Promise<TestingModule> {
return Test.createTestingModule({ imports: [AppModule] })
.overrideProvider('DB_HANDLE')
.useValue({ db: fakeDb, close: async (): Promise<void> => {} })
.overrideProvider('DB')
.useValue(fakeDb)
.overrideProvider('STORAGE_ADAPTER')
.useValue({
name: 'required-security-wiring-test',
migrate: async (): Promise<void> => {},
close: async (): Promise<void> => {},
})
.overrideProvider('AUTH')
.useValue({})
.overrideProvider('BRAIN')
.useValue({ conversations: {}, agents: {} })
.overrideProvider('LOG_SERVICE')
.useValue({})
.overrideProvider('MEMORY')
.useValue({})
.overrideProvider('MEMORY_ADAPTER')
.useValue({})
.overrideProvider(ProviderService)
.useValue(fakeProviderService)
.compile();
}
function providerToken(provider: unknown): unknown {
return typeof provider === 'function' ? provider : (provider as { provide?: unknown })?.provide;
}
interface MaskingConsumer {
moduleType: Type<unknown>;
token: Type<unknown>;
useValue: object;
}
async function compileWithoutProvider(
moduleType: Type<unknown>,
missingToken: Type<unknown>,
maskingConsumer: MaskingConsumer,
): Promise<{ error: unknown; moduleRef: TestingModule | undefined }> {
const touchedModules = new Set([moduleType, maskingConsumer.moduleType]);
const originals = Array.from(touchedModules, (touchedModule: Type<unknown>) => ({
moduleType: touchedModule,
providers: (Reflect.getMetadata('providers', touchedModule) ?? []) as unknown[],
exports: (Reflect.getMetadata('exports', touchedModule) ?? []) as unknown[],
}));
for (const original of originals) {
const providers = original.providers.flatMap((provider: unknown): unknown[] => {
const token = providerToken(provider);
if (original.moduleType === moduleType && token === missingToken) return [];
if (original.moduleType === maskingConsumer.moduleType && token === maskingConsumer.token) {
return [{ provide: maskingConsumer.token, useValue: maskingConsumer.useValue }];
}
return [provider];
});
const exports = original.exports.filter(
(exported: unknown): boolean =>
original.moduleType !== moduleType || providerToken(exported) !== missingToken,
);
Reflect.defineMetadata('providers', providers, original.moduleType);
Reflect.defineMetadata('exports', exports, original.moduleType);
}
let moduleRef: TestingModule | undefined;
let error: unknown;
try {
moduleRef = await compileRealAppGraph();
} catch (caught: unknown) {
error = caught;
} finally {
for (const original of originals) {
Reflect.defineMetadata('providers', original.providers, original.moduleType);
Reflect.defineMetadata('exports', original.exports, original.moduleType);
}
}
return { error, moduleRef };
}
async function closeIfCompiled(moduleRef: TestingModule | undefined): Promise<void> {
if (moduleRef) await moduleRef.close();
}
describe('required security wiring — real AppModule startup refusal', () => {
it('FL-01 positive control: the real graph compiles when CommandAuthorizationService is bound', async () => {
const moduleRef = await compileRealAppGraph();
try {
expect(moduleRef.get(CommandAuthorizationService, { strict: false })).toBeInstanceOf(
CommandAuthorizationService,
);
} finally {
await moduleRef.close();
}
});
it('FL-01 negative control: absence read as permission is refused at module compilation', async () => {
const { error, moduleRef } = await compileWithoutProvider(
CommandsModule,
CommandAuthorizationService,
{
moduleType: CommandsModule,
token: CommandRuntimeApprovalVerifier,
useValue: {},
},
);
await closeIfCompiled(moduleRef);
expect(
error,
'absence read as permission: AppModule compilation accepted a missing CommandAuthorizationService binding',
).toBeInstanceOf(Error);
if (!(error instanceof Error)) return;
expect(error.message).toContain('CommandExecutorService');
expect(error.message).toContain('CommandAuthorizationService');
});
it('FL-11 positive control: the real graph compiles when SystemOverrideService is bound', async () => {
const moduleRef = await compileRealAppGraph();
try {
expect(moduleRef.get(SystemOverrideService, { strict: false })).toBeInstanceOf(
SystemOverrideService,
);
} finally {
await moduleRef.close();
}
});
it('FL-11 negative control: absence read as permission is refused at module compilation', async () => {
const { error, moduleRef } = await compileWithoutProvider(
PreferencesModule,
SystemOverrideService,
{
moduleType: CommandsModule,
token: CommandExecutorService,
useValue: {},
},
);
await closeIfCompiled(moduleRef);
expect(
error,
'absence read as permission: AppModule compilation accepted a missing SystemOverrideService binding',
).toBeInstanceOf(Error);
if (!(error instanceof Error)) return;
expect(error.message).toContain('AgentService');
expect(error.message).toContain('SystemOverrideService');
});
});
const actorScope = { userId: 'security-user', tenantId: 'security-tenant' };
const conversationId = 'security-conversation';
function directExecutorWithoutAuthorization(systemOverrideSet: ReturnType<typeof vi.fn>) {
const registry = {
getManifest: vi.fn(() => ({
version: 1,
commands: [
{
name: 'system',
aliases: [],
description: 'Set instruction authority',
scope: 'agent' as const,
execution: 'socket' as const,
available: true,
},
],
skills: [],
})),
};
return new CommandExecutorService(
registry as never,
{ getSession: vi.fn() } as never,
{ set: systemOverrideSet, clear: vi.fn() } as never,
{ collect: vi.fn() } as never,
null,
{ agents: {} } as never,
null,
null,
{ getServerStatuses: vi.fn(() => []), getToolDefinitions: vi.fn(() => []) } as never,
undefined as never,
);
}
function directAgentWithoutSystemOverride(piPrompt: ReturnType<typeof vi.fn>): {
service: AgentService;
session: AgentSession;
} {
const service = new AgentService(
{
getDefaultModel: vi.fn(() => null),
getRegistry: vi.fn(() => ({})),
findModel: vi.fn(),
listAvailableModels: vi.fn(() => []),
} as never,
{} as never,
{} as never,
{ available: false } as never,
{} as never,
{ getToolDefinitions: vi.fn(() => []) } as never,
{ loadForSession: vi.fn(async () => ({ metaTools: [], promptAdditions: [] })) } as never,
undefined as never,
null,
{ collect: vi.fn().mockResolvedValue(undefined) } as never,
null,
);
const session = {
id: conversationId,
provider: 'test-provider',
modelId: 'test-model',
piSession: { prompt: piPrompt },
listeners: new Set(),
unsubscribe: vi.fn(),
createdAt: Date.now(),
promptCount: 0,
channels: new Set(),
skillPromptAdditions: [],
sandboxDir: process.cwd(),
allowedTools: null,
userId: actorScope.userId,
tenantId: actorScope.tenantId,
metrics: {
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
modelSwitches: 0,
messageCount: 0,
lastActivityAt: new Date(0).toISOString(),
},
} as unknown as AgentSession;
const internals = service as unknown as { sessions: Map<string, AgentSession> };
internals.sessions.set(conversationId, session);
return { service, session };
}
describe('required security wiring — malformed direct absence has zero effects', () => {
it('FL-01 refuses command execution before any command effect when authorization is absent', async () => {
const systemOverrideSet = vi.fn().mockResolvedValue(undefined);
const executor = directExecutorWithoutAuthorization(systemOverrideSet);
const payload: SlashCommandPayload = {
command: 'system',
args: 'authority that must not be stored',
conversationId,
};
let error: unknown;
try {
await executor.execute(payload, actorScope);
} catch (caught: unknown) {
error = caught;
}
expect
.soft(
error,
'absence read as permission: direct executor accepted missing command authorization',
)
.toBeInstanceOf(Error);
expect
.soft(
systemOverrideSet,
'absence read as permission: command effect occurred without command authorization',
)
.not.toHaveBeenCalled();
});
it('FL-11 refuses prompt execution before any provider or session effect when system override authority is absent', async () => {
const piPrompt = vi.fn().mockResolvedValue(undefined);
const { service, session } = directAgentWithoutSystemOverride(piPrompt);
let error: unknown;
try {
await service.prompt(conversationId, 'must not reach provider', actorScope);
} catch (caught: unknown) {
error = caught;
}
expect
.soft(
error,
'absence read as permission: direct session accepted missing system override authority',
)
.toBeInstanceOf(Error);
expect
.soft(
piPrompt,
'absence read as permission: provider prompt occurred without system override authority',
)
.not.toHaveBeenCalled();
expect
.soft(
session.promptCount,
'absence read as permission: session state changed without system override authority',
)
.toBe(0);
});
});
@@ -60,7 +60,7 @@ describe('Resource ownership checks', () => {
// The repo enforces ownership via the WHERE clause; it returns undefined when the
// conversation does not belong to the requesting user.
brain.conversations.findById.mockResolvedValue(undefined);
const controller = new ConversationsController(brain as never, { runtimeMode: 'legacy' });
const controller = new ConversationsController(brain as never);
await expect(controller.findOne('conv-1', { id: 'user-1' })).rejects.toBeInstanceOf(
NotFoundException,
@@ -26,7 +26,7 @@ function makeService(operatorMemory: unknown = null): AgentService {
{} as never,
{ getToolDefinitions: vi.fn(() => []) } as never,
{ loadForSession: vi.fn(async () => ({ metaTools: [], promptAdditions: [] })) } as never,
{ get: vi.fn().mockResolvedValue(null), renew: vi.fn().mockResolvedValue(undefined) } as never,
null,
null,
{ collect: vi.fn().mockResolvedValue(undefined) } as never,
operatorMemory as never,
@@ -1,8 +1,6 @@
import 'reflect-metadata';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { ForbiddenException, NotFoundException } from '@nestjs/common';
import { Test, type TestingModule } from '@nestjs/testing';
import { describe, expect, it, vi } from 'vitest';
vi.mock('../agent.service.js', () => ({ AgentService: class AgentService {} }));
@@ -14,25 +12,10 @@ vi.mock('../routing/routing-engine.service.js', () => ({
}));
import { SessionsController } from '../sessions.controller.js';
import { AgentService } from '../agent.service.js';
import { ChatController } from '../../chat/chat.controller.js';
import { ChatGateway } from '../../chat/chat.gateway.js';
import type { AgentSession } from '../agent.service.js';
import type { SessionInfoDto } from '../session.dto.js';
import type { HarnessAdapter, HarnessConversationService } from '@mosaicstack/types';
import { AuthGuard } from '../../auth/auth.guard.js';
import { AUTH } from '../../auth/auth.tokens.js';
import { BRAIN } from '../../brain/brain.tokens.js';
import { CommandRegistryService } from '../../commands/command-registry.service.js';
import { CommandExecutorService } from '../../commands/command-executor.service.js';
import { RoutingEngineService } from '../routing/routing-engine.service.js';
import { ChatRuntimeRouter } from '../../chat/chat-runtime-router.js';
import { EmbeddedChatRuntime } from '../../chat/embedded-chat.runtime.js';
import { ownConversation } from '../../chat/chat-runtime.js';
import type { LegacyRuntimeStream } from '../../chat/chat-runtime.js';
import { HarnessChatRuntime } from '../../chat/harness-chat.runtime.js';
import { HarnessRegistry } from '../../harness/harness.registry.js';
import { HARNESS_CONVERSATION_SERVICE_UNAVAILABLE } from '../../harness/harness.tokens.js';
const USER_A = { id: 'user-a', tenantId: 'tenant-a' };
const USER_B = { id: 'user-b', tenantId: 'tenant-b' };
@@ -91,12 +74,6 @@ function makeAgentSession(owner = USER_A): AgentSession {
};
}
/**
* A shape-complete, non-throwing AgentService fake scoped so that USER_B (a foreign owner guessing
* USER_A's conversation id) is never granted the session. Because every method exists and no method
* throws for a wrong shape, production runs to its real ownership decision — the RED never comes from
* a `getSession is not a function` TypeError, only from a router-boundary/scope assertion mismatch.
*/
function makeScopedAgentService() {
const foreign = makeAgentSession(USER_A);
return {
@@ -110,7 +87,7 @@ function makeScopedAgentService() {
getSession: vi.fn((_id: string, scope?: { userId: string; tenantId?: string }) =>
scope?.userId === USER_B.id ? undefined : foreign,
),
createSession: vi.fn().mockRejectedValue(new NotFoundException('Session scope mismatch')),
createSession: vi.fn().mockRejectedValue(new ForbiddenException('Session scope mismatch')),
onEvent: vi.fn(() => vi.fn()),
addChannel: vi.fn(),
removeChannel: vi.fn(),
@@ -119,201 +96,6 @@ function makeScopedAgentService() {
};
}
type ScopedAgentService = ReturnType<typeof makeScopedAgentService>;
/**
* A structurally-complete harness conversation service that throws if any method is invoked.
* Fronted behind the legacy runtime's harness slot: the legacy path must never reach it.
*/
const failIfUsedConversationService = {
attach: () => {
throw new Error('harness conversation service must not be reached on the legacy path');
},
detach: () => {
throw new Error('harness conversation service must not be reached on the legacy path');
},
send: () => {
throw new Error('harness conversation service must not be reached on the legacy path');
},
subscribeFrom: async function* () {
throw new Error('harness conversation service must not be reached on the legacy path');
},
} as unknown as HarnessConversationService;
/** A structurally-complete, non-sentinel conversation service used to satisfy the pi-rpc readiness gate. */
const boundConversationService = {
attach: () => Promise.reject(new Error('unused')),
detach: () => Promise.reject(new Error('unused')),
send: () => Promise.reject(new Error('unused')),
subscribeFrom: async function* () {
throw new Error('unused');
},
} as unknown as HarnessConversationService;
function registryWith(adapterIds: readonly string[]): HarnessRegistry {
const registry = new HarnessRegistry();
for (const id of adapterIds) {
registry.register({
id,
describe: () => Promise.reject(new Error('unused')),
catalog: () => Promise.reject(new Error('unused')),
create: () => Promise.reject(new Error('unused')),
resume: () => Promise.reject(new Error('unused')),
} as HarnessAdapter);
}
return registry;
}
/**
* Build the real legacy-mode {@link ChatRuntimeRouter} fronting a real {@link EmbeddedChatRuntime}
* that holds the scoped AgentService fake. This is the ONLY path server-derived scope may travel to
* reach an AgentService: controller/gateway → ChatRuntimeRouter → EmbeddedChatRuntime → AgentService.
* The `embeddedAgentService` handed here is a SEPARATE instance from the directly-injected fake, so a
* call landing on it proves the router-delegation redesign is live rather than the old direct path.
*/
function legacyRouterFronting(agentService: unknown): ChatRuntimeRouter {
const embedded = new EmbeddedChatRuntime(agentService as never);
const harness = new HarnessChatRuntime(failIfUsedConversationService);
const router = new ChatRuntimeRouter(
new HarnessRegistry(),
HARNESS_CONVERSATION_SERVICE_UNAVAILABLE,
embedded,
harness,
'legacy',
);
router.onModuleInit();
return router;
}
/**
* The AgentService method names the controller/gateway must NEVER drive on the runtime at the
* delegation boundary. An AgentService-shaped router shim (a method-for-method mirror) would record
* one of these instead of the frozen legacy op, so asserting their ABSENCE from the observed runtime
* call set defeats the shim on INVOCATION evidence — never satisfiable by dead source text.
*/
const FORBIDDEN_AGENT_OPS = [
'getSession',
'createSession',
'onEvent',
'addChannel',
'prompt',
'setThinking',
'abort',
] as const;
/**
* Wrap a real {@link ChatRuntimeRouter} in a call-recording Proxy. Every property access that yields
* an OWN/inherited callable is returned as a thin wrapper that appends the method name to `calls` at
* INVOCATION time and forwards to the real method (bound to the real target, so the router's internal
* delegation to the embedded runtime runs untouched below this boundary). Non-function and MISSING
* properties are returned verbatim via Reflect.get — the observer NEVER fabricates a value, returns a
* canned outcome, or delegates a not-yet-implemented named op, so it cannot itself become a shim.
*
* The result is a RUNTIME call set of exactly the methods the controller/gateway invoke ON the router
* at the delegation seam. Only an actual call can enter it; a dead method, comment, or string in the
* production source cannot. This replaces the earlier `source.toContain('<frozen op>')` proof — which
* a dead declaration could satisfy while production still executed a shim — with invocation evidence.
*/
function makeRecordingRouter(target: ChatRuntimeRouter, calls: string[]): ChatRuntimeRouter {
return new Proxy(target, {
get(t, prop) {
const value = Reflect.get(t, prop);
if (typeof value === 'function' && typeof prop === 'string') {
return (...args: unknown[]) => {
calls.push(prop);
return (value as (...a: unknown[]) => unknown).apply(t, args);
};
}
return value;
},
}) as ChatRuntimeRouter;
}
/**
* Real Nest DI dual-provider fixture (mirrors the blessed group-3 pattern in chat-security.test.ts).
*
* BOTH an `AgentService` provider (the FORBIDDEN direct dependency) and a `ChatRuntimeRouter` provider
* (fronting a real EmbeddedChatRuntime over a SEPARATE scoped AgentService) are registered. Production
* resolves whichever its constructor declares:
* - RED today: the controller/gateway `@Inject(AgentService)` → the direct fake is consulted, the
* router (and its embedded fake) is never reached.
* - GREEN later: the controller/gateway inject `ChatRuntimeRouter` → the direct fake is never
* touched (stays at zero) and scope is observed inside the embedded fake behind the router.
* The SAME test body reds today and greens later; a method-for-method AgentService shim on the router
* records a FORBIDDEN op (and never the frozen legacy op) in the observed runtime call set, and
* restoring the direct injection cannot satisfy the "direct fake at zero" / "embedded fake observed
* scope" / "frozen op invoked on the router" anchors. The router is wrapped by {@link
* makeRecordingRouter} so those anchors are runtime invocation evidence, not source substrings.
*/
function buildRestModule(
directAgentService: ScopedAgentService,
embeddedAgentService: ScopedAgentService,
routerCalls: string[],
): Promise<TestingModule> {
return (
Test.createTestingModule({
controllers: [ChatController],
providers: [
{ provide: AgentService, useValue: directAgentService },
{
provide: ChatRuntimeRouter,
useFactory: () =>
makeRecordingRouter(legacyRouterFronting(embeddedAgentService), routerCalls),
},
],
})
// ChatController's @UseGuards(AuthGuard) is resolved during instance loading; AuthGuard injects
// AUTH, an HTTP-only concern never exercised by a direct handler call. Stub it so the graph
// resolves and the test reds on BEHAVIOUR, not on a DI collection error.
.overrideGuard(AuthGuard)
.useValue({ canActivate: () => true })
.compile()
);
}
function buildGatewayModule(
directAgentService: ScopedAgentService,
embeddedAgentService: ScopedAgentService,
routerCalls: string[],
): Promise<TestingModule> {
const brain = {
conversations: {
// The sender OWNS this durable conversation, so the browser-send admission gate lets the turn
// reach the router seam. Foreignness is asserted downstream at the in-memory agent session
// (getSession({USER_B}) -> undefined), not at durable admission — the admission-rejection
// property has its own dedicated coverage.
findById: vi.fn().mockResolvedValue({ id: CONVERSATION_ID, userId: USER_B.id }),
create: vi.fn().mockResolvedValue(undefined),
update: vi.fn().mockResolvedValue(undefined),
findMessages: vi.fn().mockResolvedValue([]),
addMessage: vi.fn().mockResolvedValue({ id: 'persisted-turn' }),
},
};
return Test.createTestingModule({
providers: [
ChatGateway,
{ provide: AgentService, useValue: directAgentService },
{ provide: AUTH, useValue: { api: { getSession: vi.fn().mockResolvedValue(null) } } },
{ provide: BRAIN, useValue: brain },
{ provide: CommandRegistryService, useValue: { getManifest: vi.fn().mockReturnValue([]) } },
{ provide: CommandExecutorService, useValue: { execute: vi.fn() } },
{
provide: RoutingEngineService,
useValue: {
resolve: vi.fn().mockResolvedValue({ provider: 'test', model: 'test-model' }),
},
},
{
provide: ChatRuntimeRouter,
useFactory: () =>
makeRecordingRouter(legacyRouterFronting(embeddedAgentService), routerCalls),
},
],
}).compile();
}
describe('TESS-M1-SEC-002 AgentService ownership boundary', () => {
it('requires explicit owner+tenant scope on protected session operations', () => {
const source = readFileSync(resolve('src/agent/agent.service.ts'), 'utf8');
@@ -370,66 +152,50 @@ describe('TESS-M1-SEC-002 REST session ownership and tenant binding', () => {
});
});
describe('TESS-M1-SEC-002 REST chat send ownership and tenant binding (router-delegated legacy runtime)', () => {
// TESS test A — REST /api/chat send. The genuine RED is the router-delegation redesign, not a slot
// swap: the forbidden directly-injected AgentService must go UNtouched while the server-derived
// scope is observed inside the real ChatRuntimeRouter → EmbeddedChatRuntime → AgentService path.
it('routes a REST send through completeLegacyRestTurn and never the directly-injected AgentService', async () => {
const directAgentService = makeScopedAgentService(); // FORBIDDEN direct dependency
const embeddedAgentService = makeScopedAgentService(); // reached ONLY via router → embedded delegation
const routerCalls: string[] = []; // runtime call set observed AT the controller → router seam
const moduleRef = await buildRestModule(directAgentService, embeddedAgentService, routerCalls);
try {
const controller = moduleRef.get(ChatController, { strict: false });
describe('TESS-M1-SEC-002 REST chat send ownership and tenant binding', () => {
it('does not send a prompt into another owner/tenant session by guessed conversationId', async () => {
const agentService = makeScopedAgentService();
const controller = new ChatController(agentService as never);
// Foreign ownership is denied (never resolves) — a control that holds today AND at GREEN.
await expect(
controller.chat({ conversationId: CONVERSATION_ID, content: 'take over' }, USER_B),
).rejects.toBeDefined();
await expect(
controller.chat({ conversationId: CONVERSATION_ID, content: 'take over' }, USER_B),
).rejects.toMatchObject({ status: 404 });
// Soft anchors so EVERY anchor is evaluated under each mutation, not just the first to fail.
// RUNTIME anchor A1 — delegation: the controller must INVOKE the frozen legacy op on the router.
// Only an actual call enters routerCalls; a dead method/comment/string cannot. RED today (the
// controller @Inject(AgentService) and never calls the router). GREEN once it drives the op.
expect
.soft(routerCalls, 'controller must invoke completeLegacyRestTurn on the router')
.toContain('completeLegacyRestTurn');
// RUNTIME anchor A2 — nondelegation: the controller must not drive any AgentService-shaped op on
// the router. An AgentService-shaped router shim records one of these → RED, defeating the shim
// on invocation evidence (not source text). A dead named method added alongside the shim does not
// help: it is never invoked, so it never enters routerCalls while a forbidden op still does.
for (const op of FORBIDDEN_AGENT_OPS) {
expect
.soft(routerCalls, `router seam must not invoke AgentService.${op}`)
.not.toContain(op);
}
// RUNTIME anchor A3 — the forbidden directly-injected AgentService stays at zero (fails today;
// restoring the direct injection keeps it failing).
expect.soft(directAgentService.getSession).not.toHaveBeenCalled();
// RUNTIME anchor A4 — server-derived scope observed INSIDE the separate embedded fake behind the
// router (fails today; the router path is never taken).
expect.soft(embeddedAgentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
userId: USER_B.id,
tenantId: USER_B.tenantId,
});
// Zero foreign mutation on either path (holds today and at GREEN).
expect.soft(directAgentService.prompt).not.toHaveBeenCalled();
expect.soft(embeddedAgentService.prompt).not.toHaveBeenCalled();
// Defense-in-depth (NOT load-bearing; the runtime anchors above carry the anti-mask): the
// controller no longer declares the direct embedded AgentService dependency. A negative source
// check cannot be satisfied by dead text — it only fails when the injection is present.
const controllerSource = readFileSync(resolve('src/chat/chat.controller.ts'), 'utf8');
expect.soft(controllerSource).not.toContain('@Inject(AgentService)');
} finally {
await moduleRef.close();
}
expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
userId: USER_B.id,
tenantId: USER_B.tenantId,
});
expect(agentService.prompt).not.toHaveBeenCalled();
});
});
describe('TESS-M1-SEC-002 WebSocket session ownership and tenant binding (router-delegated legacy runtime)', () => {
describe('TESS-M1-SEC-002 WebSocket session ownership and tenant binding', () => {
function makeGateway(agentService = makeScopedAgentService()) {
const brain = {
conversations: {
findById: vi.fn().mockResolvedValue(undefined),
create: vi.fn().mockResolvedValue(undefined),
update: vi.fn().mockResolvedValue(undefined),
findMessages: vi.fn().mockResolvedValue([]),
addMessage: vi.fn().mockResolvedValue(undefined),
},
};
const commandRegistry = { getManifest: vi.fn().mockReturnValue([]) };
const commandExecutor = { execute: vi.fn() };
const routingEngine = {
resolve: vi.fn().mockResolvedValue({ provider: 'test', model: 'test-model' }),
};
const gateway = new ChatGateway(
agentService as never,
{} as never,
brain as never,
commandRegistry as never,
commandExecutor as never,
routingEngine as never,
);
return { gateway, agentService };
}
function makeSocket() {
return {
id: 'socket-b',
@@ -440,519 +206,57 @@ describe('TESS-M1-SEC-002 WebSocket session ownership and tenant binding (router
};
}
// TESS test B — WebSocket send/attach.
it('routes a WebSocket send through prepareLegacySocketTurn and never the directly-injected AgentService', async () => {
const directAgentService = makeScopedAgentService();
const embeddedAgentService = makeScopedAgentService();
const routerCalls: string[] = [];
const moduleRef = await buildGatewayModule(
directAgentService,
embeddedAgentService,
routerCalls,
);
try {
const gateway = moduleRef.get(ChatGateway, { strict: false });
const socket = makeSocket();
it('does not attach or send to another owner/tenant session by guessed conversationId', async () => {
const { gateway, agentService } = makeGateway();
const socket = makeSocket();
await Promise.resolve(
gateway.handleMessage(socket as never, {
conversationId: CONVERSATION_ID,
content: 'attach to foreign session',
}),
).catch(() => undefined);
// RUNTIME anchor B1 — delegation: the gateway must invoke the frozen socket op on the router.
expect
.soft(routerCalls, 'gateway must invoke prepareLegacySocketTurn on the router')
.toContain('prepareLegacySocketTurn');
// RUNTIME anchor B2 — nondelegation: no AgentService-shaped op on the router (defeats the shim).
for (const op of FORBIDDEN_AGENT_OPS) {
expect
.soft(routerCalls, `router seam must not invoke AgentService.${op}`)
.not.toContain(op);
}
// RED anchor B3 — forbidden direct AgentService untouched (fails today, gateway injects it).
expect.soft(directAgentService.getSession).not.toHaveBeenCalled();
// RED anchor B4 — scope observed inside router → embedded delegation (fails today, never reached).
expect.soft(embeddedAgentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
userId: USER_B.id,
tenantId: USER_B.tenantId,
});
// Foreign session gets zero lease/listener/channel/prompt on EITHER path (holds today and GREEN).
expect.soft(directAgentService.onEvent).not.toHaveBeenCalled();
expect.soft(directAgentService.addChannel).not.toHaveBeenCalled();
expect.soft(directAgentService.prompt).not.toHaveBeenCalled();
expect.soft(embeddedAgentService.onEvent).not.toHaveBeenCalled();
expect.soft(embeddedAgentService.addChannel).not.toHaveBeenCalled();
expect.soft(embeddedAgentService.prompt).not.toHaveBeenCalled();
expect
.soft(socket.emit)
.toHaveBeenCalledWith(
'error',
expect.objectContaining({ conversationId: CONVERSATION_ID }),
);
// Defense-in-depth (NOT load-bearing): gateway no longer declares the direct dependency.
const gatewaySource = readFileSync(resolve('src/chat/chat.gateway.ts'), 'utf8');
expect.soft(gatewaySource).not.toContain('@Inject(AgentService)');
} finally {
await moduleRef.close();
}
});
// TESS test C — WebSocket set:thinking.
it('routes set:thinking through setLegacyThinking and never the directly-injected AgentService', async () => {
const directAgentService = makeScopedAgentService();
const embeddedAgentService = makeScopedAgentService();
const routerCalls: string[] = [];
const moduleRef = await buildGatewayModule(
directAgentService,
embeddedAgentService,
routerCalls,
);
try {
const gateway = moduleRef.get(ChatGateway, { strict: false });
const socket = makeSocket();
await Promise.resolve(
gateway.handleSetThinking(socket as never, {
conversationId: CONVERSATION_ID,
level: 'high',
}),
).catch(() => undefined);
// RUNTIME anchor C1 — delegation: the gateway must invoke the frozen thinking op on the router.
expect
.soft(routerCalls, 'gateway must invoke setLegacyThinking on the router')
.toContain('setLegacyThinking');
// RUNTIME anchor C2 — nondelegation: no AgentService-shaped op on the router (defeats the shim).
for (const op of FORBIDDEN_AGENT_OPS) {
expect
.soft(routerCalls, `router seam must not invoke AgentService.${op}`)
.not.toContain(op);
}
expect.soft(directAgentService.getSession).not.toHaveBeenCalled();
expect.soft(embeddedAgentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
userId: USER_B.id,
tenantId: USER_B.tenantId,
});
expect
.soft(socket.emit)
.toHaveBeenCalledWith(
'error',
expect.objectContaining({ conversationId: CONVERSATION_ID }),
);
} finally {
await moduleRef.close();
}
});
// TESS test D — WebSocket abort.
it('routes abort through abortLegacyTurn and never the directly-injected AgentService', async () => {
const directAgentService = makeScopedAgentService();
const embeddedAgentService = makeScopedAgentService();
const routerCalls: string[] = [];
const moduleRef = await buildGatewayModule(
directAgentService,
embeddedAgentService,
routerCalls,
);
try {
const gateway = moduleRef.get(ChatGateway, { strict: false });
const socket = makeSocket();
await Promise.resolve(
gateway.handleAbort(socket as never, { conversationId: CONVERSATION_ID }),
).catch(() => undefined);
// RUNTIME anchor D1 — delegation: the gateway must invoke the frozen abort op on the router.
expect
.soft(routerCalls, 'gateway must invoke abortLegacyTurn on the router')
.toContain('abortLegacyTurn');
// RUNTIME anchor D2 — nondelegation: no AgentService-shaped op on the router (defeats the shim).
for (const op of FORBIDDEN_AGENT_OPS) {
expect
.soft(routerCalls, `router seam must not invoke AgentService.${op}`)
.not.toContain(op);
}
expect.soft(directAgentService.getSession).not.toHaveBeenCalled();
expect.soft(embeddedAgentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
userId: USER_B.id,
tenantId: USER_B.tenantId,
});
expect
.soft(socket.emit)
.toHaveBeenCalledWith(
'error',
expect.objectContaining({ conversationId: CONVERSATION_ID }),
);
} finally {
await moduleRef.close();
}
});
// TESS test E (genuine, unchanged) — pi-rpc browser-legacy refusal.
it('rejects a browser legacy raw message in pi-rpc mode with a fixed typed unsupported and executes nothing', async () => {
// pi-rpc: the harness runtime is live. The browser legacy `message` path is unsupported and
// must be refused with a fixed typed code, touching neither the embedded AgentService nor the
// harness conversation service.
const agentService = makeScopedAgentService();
const embedded = new EmbeddedChatRuntime(agentService as never);
const harnessConversation = {
attach: vi.fn(),
detach: vi.fn(),
send: vi.fn(),
subscribeFrom: vi.fn(),
};
const harness = new HarnessChatRuntime(harnessConversation as never);
const router = new ChatRuntimeRouter(
registryWith(['pi']),
boundConversationService,
embedded,
harness,
'pi-rpc',
);
router.onModuleInit();
const brain = {
conversations: {
findById: vi.fn().mockResolvedValue(undefined),
create: vi.fn().mockResolvedValue(undefined),
update: vi.fn().mockResolvedValue(undefined),
findMessages: vi.fn().mockResolvedValue([]),
addMessage: vi.fn().mockResolvedValue(undefined),
},
};
const gateway = new ChatGateway(
router as never,
{} as never,
brain as never,
{ getManifest: vi.fn().mockReturnValue([]) } as never,
{ execute: vi.fn() } as never,
{ resolve: vi.fn() } as never,
);
const socket = {
id: 'socket-b',
connected: true,
data: { user: USER_B, session: { id: 'auth-session-b', userId: USER_B.id } },
emit: vi.fn(),
disconnect: vi.fn(),
};
await Promise.resolve(
gateway.handleMessage(socket as never, {
conversationId: CONVERSATION_ID,
content: 'route me',
}),
).catch(() => undefined);
await gateway.handleMessage(socket as never, {
conversationId: CONVERSATION_ID,
content: 'attach to foreign session',
});
expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
userId: USER_B.id,
tenantId: USER_B.tenantId,
});
expect(agentService.onEvent).not.toHaveBeenCalled();
expect(agentService.addChannel).not.toHaveBeenCalled();
expect(agentService.prompt).not.toHaveBeenCalled();
expect(socket.emit).toHaveBeenCalledWith(
'error',
expect.objectContaining({ code: 'runtime_unsupported' }),
expect.objectContaining({ conversationId: CONVERSATION_ID }),
);
expect(agentService.getSession).not.toHaveBeenCalled();
expect(agentService.prompt).not.toHaveBeenCalled();
expect(harnessConversation.attach).not.toHaveBeenCalled();
expect(harnessConversation.send).not.toHaveBeenCalled();
});
});
// ---------------------------------------------------------------------------
// Task-5 AMEND — embedded runtime lease lifecycle (G1) + ownership collapse (G5).
// These drive the real EmbeddedChatRuntime directly over a shape-complete AgentService
// fake (every touched method exists, so a RED can only come from behavior, never a
// `getSession is not a function` TypeError). Ownership context is minted through the
// real `ownConversation` factory — the only sanctioned way to reach a port op.
// ---------------------------------------------------------------------------
const EMBEDDED_SCOPE = { userId: USER_A.id, tenantId: USER_A.tenantId };
const CONVERSATION_UNAVAILABLE_RESULT = {
ok: false,
code: 'conversation_unavailable',
retryable: false,
} as const;
/** A stream sink; `channelId` is server-derived, `onEvent` records nothing here. */
function makeStream(): LegacyRuntimeStream {
return { channelId: 'websocket:test-1', onEvent: vi.fn() };
}
/**
* getSession → undefined (session missing), createSession → rejects with `err`. Exercises the
* `resolveOrCreate` collapse branch. `prompt` exists so its ABSENCE from the call record proves
* the turn short-circuited before any dispatch.
*/
function makeCollapsingAgentService(err: Error) {
return {
getSession: vi.fn(() => undefined),
createSession: vi.fn().mockRejectedValue(err),
onEvent: vi.fn(() => vi.fn()),
addChannel: vi.fn(),
removeChannel: vi.fn(),
prompt: vi.fn().mockResolvedValue(undefined),
recordTokenUsage: vi.fn(),
};
}
/** getSession → a live owned session, so `resolveOrCreate` succeeds and a lease is built. */
function makeLeaseAgentService() {
const session = makeAgentSession(USER_A);
const unsubscribe = vi.fn();
const svc = {
getSession: vi.fn(() => session),
createSession: vi.fn(),
onEvent: vi.fn(() => unsubscribe),
addChannel: vi.fn(),
removeChannel: vi.fn(),
prompt: vi.fn().mockResolvedValue(undefined),
recordTokenUsage: vi.fn(),
};
return { svc, unsubscribe, session };
}
/**
* getSession → a live owned session (REST resolveOrCreate succeeds), onEvent returns a `detach`
* spy, and `prompt` REJECTS with a non-timeout error. Drives the REST-turn catch path so the single
* idempotent teardown must clear the 120s timeout and detach the listener exactly once.
*/
function makeRejectingPromptAgentService() {
const session = makeAgentSession(USER_A);
const detach = vi.fn();
const svc = {
getSession: vi.fn(() => session),
createSession: vi.fn(),
onEvent: vi.fn(() => detach),
addChannel: vi.fn(),
removeChannel: vi.fn(),
prompt: vi.fn().mockRejectedValue(new Error('agent backend exploded')),
recordTokenUsage: vi.fn(),
};
return { svc, detach };
}
describe('TESS Task-5 embedded ownership collapse (missing and foreign are indistinguishable, never throw)', () => {
const ctx = ownConversation(CONVERSATION_ID, EMBEDDED_SCOPE);
it('collapses a foreign (Forbidden) create to conversation_unavailable and never throws', async () => {
const svc = makeCollapsingAgentService(new ForbiddenException('foreign owner'));
const runtime = new EmbeddedChatRuntime(svc as never);
const result = await runtime.completeLegacyRestTurn(ctx, { content: 'take over' });
expect(result).toEqual(CONVERSATION_UNAVAILABLE_RESULT);
expect(svc.prompt).not.toHaveBeenCalled();
});
it('collapses a missing (NotFound) create to conversation_unavailable and never throws', async () => {
const svc = makeCollapsingAgentService(new NotFoundException('no such conversation'));
const runtime = new EmbeddedChatRuntime(svc as never);
it('does not mutate thinking level on another owner/tenant session', () => {
const { gateway, agentService } = makeGateway();
const socket = makeSocket();
const result = await runtime.completeLegacyRestTurn(ctx, { content: 'hello' });
gateway.handleSetThinking(socket as never, { conversationId: CONVERSATION_ID, level: 'high' });
expect(result).toEqual(CONVERSATION_UNAVAILABLE_RESULT);
expect(svc.prompt).not.toHaveBeenCalled();
});
it('returns the IDENTICAL collapse for foreign and missing so neither can be distinguished', async () => {
const foreign = new EmbeddedChatRuntime(
makeCollapsingAgentService(new ForbiddenException('foreign owner')) as never,
);
const missing = new EmbeddedChatRuntime(
makeCollapsingAgentService(new NotFoundException('no such conversation')) as never,
);
const foreignResult = await foreign.completeLegacyRestTurn(ctx, { content: 'x' });
const missingResult = await missing.completeLegacyRestTurn(ctx, { content: 'x' });
expect(foreignResult).toEqual(missingResult);
expect(foreignResult).toEqual(CONVERSATION_UNAVAILABLE_RESULT);
});
});
describe('TESS Task-5 embedded socket lease lifecycle (one-shot dispatch, idempotent dispose, partial-setup rollback)', () => {
const ctx = ownConversation(CONVERSATION_ID, EMBEDDED_SCOPE);
it('dispatches the turn exactly once; a second dispatch is a no-op turn_already_dispatched', async () => {
const { svc } = makeLeaseAgentService();
const runtime = new EmbeddedChatRuntime(svc as never);
const prepared = await runtime.prepareLegacySocketTurn(ctx, { content: 'first' }, makeStream());
expect(prepared.ok).toBe(true);
if (!prepared.ok) throw new Error('prepareLegacySocketTurn should succeed');
const lease = prepared.value;
const first = await lease.dispatch();
expect(first).toEqual({ ok: true, value: undefined });
expect(svc.prompt).toHaveBeenCalledTimes(1);
const second = await lease.dispatch();
expect(second).toEqual({ ok: false, code: 'turn_already_dispatched', retryable: false });
// Zero additional effect — the second dispatch must not prompt again.
expect(svc.prompt).toHaveBeenCalledTimes(1);
});
it('disposes once; a second dispose is a silent no-op that never re-detaches or destroys the session', async () => {
const { svc, unsubscribe, session } = makeLeaseAgentService();
const runtime = new EmbeddedChatRuntime(svc as never);
const prepared = await runtime.prepareLegacySocketTurn(ctx, { content: 'x' }, makeStream());
expect(prepared.ok).toBe(true);
if (!prepared.ok) throw new Error('prepareLegacySocketTurn should succeed');
const lease = prepared.value;
await lease.dispose();
await lease.dispose();
// Listener + channel torn down exactly once across two dispose calls.
expect(unsubscribe).toHaveBeenCalledTimes(1);
expect(svc.removeChannel).toHaveBeenCalledTimes(1);
// Disposal never terminates the underlying session or process.
expect(session.piSession.abort).not.toHaveBeenCalled();
expect(session.piSession.dispose).not.toHaveBeenCalled();
});
it('rolls back the acquired listener and returns a total safe failure when channel attach fails mid-setup', async () => {
const { svc, unsubscribe } = makeLeaseAgentService();
svc.addChannel = vi.fn(() => {
throw new Error('channel attach failed');
expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
userId: USER_B.id,
tenantId: USER_B.tenantId,
});
const runtime = new EmbeddedChatRuntime(svc as never);
// Must NOT throw out of the port — a partial setup collapses to a total safe failure.
const prepared = await runtime.prepareLegacySocketTurn(ctx, { content: 'x' }, makeStream());
expect(prepared.ok).toBe(false);
// Exactly what was acquired (the event listener) is rolled back.
expect(unsubscribe).toHaveBeenCalledTimes(1);
});
});
describe('TESS Task-5 embedded REST turn teardown (a prompt rejection frees the timer + listener exactly once)', () => {
const ctx = ownConversation(CONVERSATION_ID, EMBEDDED_SCOPE);
it('clears the 120s timeout and detaches the listener exactly once when prompt() rejects, leaving no timer to reject the abandoned done-promise later (Task 5 finding 6)', async () => {
const { svc, detach } = makeRejectingPromptAgentService();
const runtime = new EmbeddedChatRuntime(svc as never);
// A rejected `done` promise firing after completeLegacyRestTurn has already returned would
// surface as an unhandledRejection — the leak this test fences. Capture any that escape.
const unhandled: unknown[] = [];
const onUnhandled = (reason: unknown): void => {
unhandled.push(reason);
};
process.on('unhandledRejection', onUnhandled);
vi.useFakeTimers();
try {
const result = await runtime.completeLegacyRestTurn(ctx, {
content: 'trigger a backend failure',
});
// The rejection collapses to a total safe failure (not a timeout) — never throws out of the port.
expect(result).toEqual({ ok: false, code: 'operation_failed', retryable: false });
// The single idempotent dispose ran in the catch: listener detached exactly once.
expect(detach).toHaveBeenCalledTimes(1);
// dispose() cleared the REST timeout, so advancing far past it (120s) fires nothing: no second
// detach, and — the actual leak — no live timer left to reject the now-abandoned `done` promise.
vi.advanceTimersByTime(600_000);
expect(detach).toHaveBeenCalledTimes(1);
} finally {
vi.useRealTimers();
}
// Let any scheduled rejection surface on a real macrotask, then confirm none did.
await new Promise((resolve) => setTimeout(resolve, 0));
process.off('unhandledRejection', onUnhandled);
expect(unhandled).toHaveLength(0);
expect(socket.emit).toHaveBeenCalledWith(
'error',
expect.objectContaining({ conversationId: CONVERSATION_ID }),
);
});
it('bounds a hung prompt: when prompt() never settles and no agent_end arrives, the 120s timeout ends the turn with a timeout result and exactly one teardown, no unhandledRejection (Task 5 finding 6 — pending-prompt timeout)', async () => {
const session = makeAgentSession(USER_A);
const detach = vi.fn();
const svc = {
getSession: vi.fn(() => session),
createSession: vi.fn(),
onEvent: vi.fn(() => detach),
addChannel: vi.fn(),
removeChannel: vi.fn(),
// The prompt never resolves or rejects — a hung agent backend. Under the pre-fix sequential
// `await prompt()` the timer could never even be observed, so the turn hung forever.
prompt: vi.fn(() => new Promise<void>(() => undefined)),
recordTokenUsage: vi.fn(),
};
const runtime = new EmbeddedChatRuntime(svc as never);
it('does not terminate another owner/tenant session over WebSocket abort', async () => {
const { gateway, agentService } = makeGateway();
const socket = makeSocket();
const unhandled: unknown[] = [];
const onUnhandled = (reason: unknown): void => {
unhandled.push(reason);
};
process.on('unhandledRejection', onUnhandled);
vi.useFakeTimers();
try {
const resultPromise = runtime.completeLegacyRestTurn(ctx, {
content: 'a prompt that never returns',
});
// No agent_end, prompt still pending: only the 120s timeout can end the turn. Promise.all
// installed a handler on `done` synchronously, so the timer bounds the turn while prompt hangs.
await vi.advanceTimersByTimeAsync(200_000);
const result = await resultPromise;
await gateway.handleAbort(socket as never, { conversationId: CONVERSATION_ID });
expect(result).toEqual({ ok: false, code: 'timeout', retryable: true });
// The single idempotent dispose ran on the timeout path: listener detached exactly once.
expect(detach).toHaveBeenCalledTimes(1);
// Advancing far past the deadline fires nothing more: dispose cleared the timer.
vi.advanceTimersByTime(600_000);
expect(detach).toHaveBeenCalledTimes(1);
} finally {
vi.useRealTimers();
}
await new Promise((resolve) => setTimeout(resolve, 0));
process.off('unhandledRejection', onUnhandled);
expect(unhandled).toHaveLength(0);
});
it('when the 120s timeout fires while prompt() is still pending, returns timeout with one teardown, and a later prompt rejection surfaces no unhandledRejection (Task 5 finding 6 — timeout/prompt race)', async () => {
const session = makeAgentSession(USER_A);
const detach = vi.fn();
let rejectPrompt: (reason: unknown) => void = () => undefined;
const prompting = new Promise<void>((_resolve, reject) => {
rejectPrompt = reject;
expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
userId: USER_B.id,
tenantId: USER_B.tenantId,
});
const svc = {
getSession: vi.fn(() => session),
createSession: vi.fn(),
onEvent: vi.fn(() => detach),
addChannel: vi.fn(),
removeChannel: vi.fn(),
prompt: vi.fn(() => prompting),
recordTokenUsage: vi.fn(),
};
const runtime = new EmbeddedChatRuntime(svc as never);
const unhandled: unknown[] = [];
const onUnhandled = (reason: unknown): void => {
unhandled.push(reason);
};
process.on('unhandledRejection', onUnhandled);
vi.useFakeTimers();
try {
const resultPromise = runtime.completeLegacyRestTurn(ctx, {
content: 'prompt settles after the deadline',
});
// The timeout wins the race while prompt is still pending.
await vi.advanceTimersByTimeAsync(200_000);
const result = await resultPromise;
expect(result).toEqual({ ok: false, code: 'timeout', retryable: true });
expect(detach).toHaveBeenCalledTimes(1);
// The prompt now rejects LATE — after the turn already returned its timeout result. Because
// Promise.all installed a rejection handler on `prompting` synchronously (the fix), this late
// rejection is already observed and must not escape as an unhandledRejection.
rejectPrompt(new Error('late backend failure'));
} finally {
vi.useRealTimers();
}
await new Promise((resolve) => setTimeout(resolve, 0));
process.off('unhandledRejection', onUnhandled);
expect(unhandled).toHaveLength(0);
expect(socket.emit).toHaveBeenCalledWith(
'error',
expect.objectContaining({ conversationId: CONVERSATION_ID }),
);
});
});
+11 -9
View File
@@ -132,8 +132,9 @@ export class AgentService implements OnModuleDestroy {
@Inject(CoordService) private readonly coordService: CoordService,
@Inject(McpClientService) private readonly mcpClientService: McpClientService,
@Inject(SkillLoaderService) private readonly skillLoaderService: SkillLoaderService,
@Optional()
@Inject(SystemOverrideService)
private readonly systemOverride: SystemOverrideService,
private readonly systemOverride: SystemOverrideService | null,
@Optional()
@Inject(PreferencesService)
private readonly preferencesService: PreferencesService | null,
@@ -708,22 +709,23 @@ export class AgentService implements OnModuleDestroy {
throw new Error(`No agent session found: ${sessionId}`);
}
this.assertSessionScope(session, scope);
session.promptCount += 1;
// Channel attachments are untrusted URI references. Preserve exact,
// authenticated metadata for the agent without treating it as authority.
const attachmentContext = this.attachmentContext(attachments);
// Prepend session-scoped system override if present (renew TTL on each turn).
// Required instruction-authority wiring is consulted before session/provider effects.
// Prepend session-scoped system override if present (renew TTL on each turn)
let effectiveMessage = `${message}${attachmentContext}`;
const override = await this.systemOverride.get(sessionId, scope);
if (override) {
effectiveMessage = `[System Override]\n${override}\n\n${effectiveMessage}`;
await this.systemOverride.renew(sessionId, scope);
this.logger.debug(`Applied system override for session ${sessionId}`);
if (this.systemOverride) {
const override = await this.systemOverride.get(sessionId, scope);
if (override) {
effectiveMessage = `[System Override]\n${override}\n\n${effectiveMessage}`;
await this.systemOverride.renew(sessionId, scope);
this.logger.debug(`Applied system override for session ${sessionId}`);
}
}
session.promptCount += 1;
try {
await session.piSession.prompt(effectiveMessage);
} catch (err) {
-4
View File
@@ -24,8 +24,6 @@ import { GCModule } from './gc/gc.module.js';
import { HarnessModule } from './harness/harness.module.js';
import { ReloadModule } from './reload/reload.module.js';
import { WorkspaceModule } from './workspace/workspace.module.js';
import { HierarchyModule } from './hierarchy/hierarchy.module.js';
import { EnrollmentModule } from './enrollment/enrollment.module.js';
import { QueueModule } from './queue/queue.module.js';
import { FederationModule } from './federation/federation.module.js';
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
@@ -67,8 +65,6 @@ const federationEnabled = loadConfig(resolveGatewayConfigPath()).tier === 'feder
QueueModule,
ReloadModule,
WorkspaceModule,
HierarchyModule,
EnrollmentModule,
...(federationEnabled ? [FederationModule] : []),
],
controllers: [HealthController],
File diff suppressed because it is too large Load Diff
@@ -1,920 +0,0 @@
import 'reflect-metadata';
import { Global, Module } from '@nestjs/common';
import { Test, type TestingModule } from '@nestjs/testing';
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
import type { HarnessAdapter, HarnessConversationService } from '@mosaicstack/types';
import { AgentService } from '../agent/agent.service.js';
import { AuthGuard } from '../auth/auth.guard.js';
import { CommandsModule } from '../commands/commands.module.js';
import { HarnessModule } from '../harness/harness.module.js';
import { ChatModule } from './chat.module.js';
import { ChatGateway } from './chat.gateway.js';
import { HarnessRegistry } from '../harness/harness.registry.js';
import {
HARNESS_CONVERSATION_SERVICE,
HARNESS_CONVERSATION_SERVICE_UNAVAILABLE,
HARNESS_REGISTRY,
type HarnessConversationServiceBinding,
} from '../harness/harness.tokens.js';
import { ChatRuntimeRouter } from './chat-runtime-router.js';
import {
ChatRuntimeUnavailableError,
ownConversation,
type ChatRuntime,
type ChatRuntimeMode,
type LegacyEmbeddedChatPort,
type LegacyRuntimeStream,
type LegacySessionPresentation,
type LegacySocketTurnLease,
type OwnedConversationContext,
} from './chat-runtime.js';
import { AppModule } from '../app.module.js';
import { ProviderService } from '../agent/provider.service.js';
/**
* Task Five, Step One (router). Proves the `ChatRuntimeRouter` resolves exactly one
* runtime by mode, fails closed at init when `pi-rpc` preconditions are unmet, and
* never downgrades `pi-rpc` to embedded execution. Red-first: the router is an
* unimplemented stub, so every behavioural assertion below fails until Step Three.
*/
const embedded: ChatRuntime = { kind: 'embedded' };
const harness: ChatRuntime = { kind: 'harness' };
/** A structurally-complete, non-sentinel conversation service. Its methods are never invoked here. */
const boundConversationService = {
attach: () => Promise.reject(new Error('unused')),
detach: () => Promise.reject(new Error('unused')),
send: () => Promise.reject(new Error('unused')),
subscribeFrom: async function* () {
throw new Error('unused');
},
} as unknown as HarnessConversationService;
function registryWith(adapterIds: readonly string[]): HarnessRegistry {
const registry = new HarnessRegistry();
for (const id of adapterIds) {
registry.register({
id,
describe: () => Promise.reject(new Error('unused')),
catalog: () => Promise.reject(new Error('unused')),
create: () => Promise.reject(new Error('unused')),
resume: () => Promise.reject(new Error('unused')),
} as HarnessAdapter);
}
return registry;
}
function buildRouter(
mode: ChatRuntimeMode,
opts: { adapters: readonly string[]; service: HarnessConversationServiceBinding },
): ChatRuntimeRouter {
return new ChatRuntimeRouter(registryWith(opts.adapters), opts.service, embedded, harness, mode);
}
/**
* Tear down a module that was deliberately driven to a fail-closed init.
* `NestApplicationContext.close()` re-awaits the module's `initializationPromise` before disposing
* (nest-application-context.js:127); when `init()` rejected, that await re-throws the SAME typed
* startup error, this time into teardown. Each caller here has already captured and asserted that
* exact `ChatRuntimeUnavailableError` via `initError`, so the re-throw is expected teardown noise —
* swallow ONLY that error, and surface anything else so a genuine teardown fault still fails loudly.
*/
async function closeIgnoringFailedInit(moduleRef: TestingModule): Promise<void> {
await moduleRef.close().catch((err: unknown) => {
if (err instanceof ChatRuntimeUnavailableError) return;
throw err;
});
}
describe('ChatRuntimeRouter', () => {
it('resolves only the harness runtime in pi-rpc mode when pi adapter and conversation service are present', () => {
const router = buildRouter('pi-rpc', {
adapters: ['pi'],
service: boundConversationService,
});
expect(() => router.onModuleInit()).not.toThrow();
expect(router.active).toBe(harness);
expect(router.active.kind).toBe('harness');
});
it('resolves only the embedded runtime in legacy mode and skips the pi preconditions', () => {
// Empty registry + unavailable service: legacy must ignore both and still start.
const router = buildRouter('legacy', {
adapters: [],
service: HARNESS_CONVERSATION_SERVICE_UNAVAILABLE,
});
expect(() => router.onModuleInit()).not.toThrow();
expect(router.active).toBe(embedded);
expect(router.active.kind).toBe('embedded');
});
it('fails closed at init when pi-rpc mode has no registered pi adapter', () => {
const router = buildRouter('pi-rpc', {
adapters: [],
service: boundConversationService,
});
expect(() => router.onModuleInit()).toThrow(ChatRuntimeUnavailableError);
try {
router.onModuleInit();
expect.unreachable('onModuleInit must throw when the pi adapter is absent');
} catch (err) {
expect(err).toBeInstanceOf(ChatRuntimeUnavailableError);
expect((err as ChatRuntimeUnavailableError).reason).toBe('adapter_unavailable');
expect((err as ChatRuntimeUnavailableError).code).toBe('runtime_unsupported');
}
});
it('fails closed at init when pi-rpc mode has the unavailable conversation-service sentinel', () => {
const router = buildRouter('pi-rpc', {
adapters: ['pi'],
service: HARNESS_CONVERSATION_SERVICE_UNAVAILABLE,
});
try {
router.onModuleInit();
expect.unreachable('onModuleInit must throw when the conversation service is unbound');
} catch (err) {
expect(err).toBeInstanceOf(ChatRuntimeUnavailableError);
expect((err as ChatRuntimeUnavailableError).reason).toBe('conversation_service_unavailable');
expect((err as ChatRuntimeUnavailableError).code).toBe('runtime_unsupported');
}
});
it('never falls back to embedded execution when pi-rpc preconditions are unmet', () => {
const router = buildRouter('pi-rpc', {
adapters: [],
service: HARNESS_CONVERSATION_SERVICE_UNAVAILABLE,
});
expect(() => router.onModuleInit()).toThrow(ChatRuntimeUnavailableError);
// A failed pi-rpc init must not silently expose the embedded runtime.
expect(() => router.active).toThrow();
let leaked: ChatRuntime | undefined;
try {
leaked = router.active;
} catch {
leaked = undefined;
}
expect(leaked).not.toBe(embedded);
});
it('exposes only fixed, browser-safe failure text (no raw provider or exception detail)', () => {
const router = buildRouter('pi-rpc', {
adapters: [],
service: boundConversationService,
});
try {
router.onModuleInit();
expect.unreachable('onModuleInit must throw');
} catch (err) {
const message = (err as ChatRuntimeUnavailableError).message;
expect(message).toBe(
'The pi-rpc chat runtime is unavailable: no "pi" harness adapter is registered.',
);
expect(message).not.toMatch(/Error:|\bat \b|node_modules|Symbol\(/);
}
});
});
/**
* Task Five, Step Three — legacy port operations fail closed under pi-rpc (direct valid-input).
*
* The unit suite above constructs the router but never invokes a legacy port operation, so the
* six per-operation inner `if (this.mode === 'pi-rpc')` guards are unexercised — a mutation that
* deletes one of them SURVIVES for lack of a test that drives that operation. This group closes
* that gap the right way: it drives each of the six operations DIRECTLY, in pi-rpc mode, with a
* valid branded {@link OwnedConversationContext} and valid input, against a recording embedded
* stub whose method returns a distinguishable `ok:true` success and increments a per-op counter.
*
* For each operation:
* - pi-rpc test asserts the exact frozen `{ ok:false, code:'runtime_unsupported', retryable:false }`
* result AND that the embedded stub was touched zero times (no effects);
* - the paired legacy test proves that same stub method IS reached and returns its distinguishable
* success when the mode does not refuse — so the pi-rpc zero-invocation assertion is meaningful,
* not vacuously true because the stub could never be called.
*
* Deleting ONLY one operation's inner guard makes THAT operation's pi-rpc test behaviorally RED
* (the router returns the embedded `ok:true` value and records the call), with every outer guard
* and the other five inner guards intact. `next` is untouched; nothing here changes production.
*/
describe('ChatRuntimeRouter — legacy port ops fail closed under pi-rpc (Task Five, Step Three)', () => {
const RUNTIME_UNSUPPORTED = {
ok: false,
code: 'runtime_unsupported',
retryable: false,
} as const;
const PRESENTATION: LegacySessionPresentation = {
provider: 'embedded-provider',
modelId: 'embedded-model',
thinkingLevel: 'low',
availableThinkingLevels: ['low', 'high'],
};
const stream: LegacyRuntimeStream = {
channelId: 'websocket:test-socket',
onEvent: () => {},
};
const ctx = (): OwnedConversationContext =>
ownConversation('conversation-1', { userId: 'user-1', tenantId: 'tenant-1' });
/**
* Per-operation invocation counters with declared keys (not an index signature) so each
* `calls.<op>` is definitely `number` under `noUncheckedIndexedAccess`.
*/
type LegacyPortCallCounts = {
completeLegacyRestTurn: number;
prepareLegacySocketTurn: number;
setLegacyThinking: number;
abortLegacyTurn: number;
applyLegacyModelOverride: number;
readLegacySessionPresentation: number;
dispatchVerifiedDiscordIngress: number;
};
/**
* An embedded port that records every invocation and returns a distinguishable `ok:true`
* value per operation. If a router op reaches it (its guard removed), both the recorded call
* count and the returned `ok:true` value diverge from the frozen `runtime_unsupported` result.
*/
function recordingEmbeddedPort(): {
port: ChatRuntime & LegacyEmbeddedChatPort;
calls: LegacyPortCallCounts;
} {
const calls: LegacyPortCallCounts = {
completeLegacyRestTurn: 0,
prepareLegacySocketTurn: 0,
setLegacyThinking: 0,
abortLegacyTurn: 0,
applyLegacyModelOverride: 0,
readLegacySessionPresentation: 0,
dispatchVerifiedDiscordIngress: 0,
};
const lease: LegacySocketTurnLease = {
presentation: PRESENTATION,
dispatch: () => Promise.resolve({ ok: true, value: undefined }),
dispose: () => Promise.resolve(),
};
const port: ChatRuntime & LegacyEmbeddedChatPort = {
kind: 'embedded',
completeLegacyRestTurn: () => {
calls.completeLegacyRestTurn += 1;
return Promise.resolve({
ok: true,
value: { text: 'EMBEDDED-REST', presentation: PRESENTATION },
});
},
prepareLegacySocketTurn: () => {
calls.prepareLegacySocketTurn += 1;
return Promise.resolve({ ok: true, value: lease });
},
setLegacyThinking: () => {
calls.setLegacyThinking += 1;
return { ok: true, value: PRESENTATION };
},
abortLegacyTurn: () => {
calls.abortLegacyTurn += 1;
return Promise.resolve({ ok: true, value: undefined });
},
applyLegacyModelOverride: () => {
calls.applyLegacyModelOverride += 1;
return { ok: true, value: PRESENTATION };
},
readLegacySessionPresentation: () => {
calls.readLegacySessionPresentation += 1;
return { ok: true, value: PRESENTATION };
},
dispatchVerifiedDiscordIngress: () => {
calls.dispatchVerifiedDiscordIngress += 1;
return Promise.resolve({
ok: true,
value: {
presentation: PRESENTATION,
dispatch: () => Promise.resolve({ ok: true, value: undefined }),
dispose: () => Promise.resolve(),
},
});
},
};
return { port, calls };
}
function piRouter(port: ChatRuntime & LegacyEmbeddedChatPort): ChatRuntimeRouter {
return new ChatRuntimeRouter(
registryWith(['pi']),
boundConversationService,
port,
harness,
'pi-rpc',
);
}
function legacyRouter(port: ChatRuntime & LegacyEmbeddedChatPort): ChatRuntimeRouter {
return new ChatRuntimeRouter(
registryWith([]),
boundConversationService,
port,
harness,
'legacy',
);
}
// completeLegacyRestTurn ---------------------------------------------------
it('completeLegacyRestTurn refuses with runtime_unsupported and never touches embedded under pi-rpc', async () => {
const { port, calls } = recordingEmbeddedPort();
const result = await piRouter(port).completeLegacyRestTurn(ctx(), { content: 'hello' });
expect(result).toEqual(RUNTIME_UNSUPPORTED);
expect(calls.completeLegacyRestTurn).toBe(0);
});
it('completeLegacyRestTurn delegates to embedded under legacy (guard is the sole gate)', async () => {
const { port, calls } = recordingEmbeddedPort();
const result = await legacyRouter(port).completeLegacyRestTurn(ctx(), { content: 'hello' });
expect(result.ok).toBe(true);
expect(calls.completeLegacyRestTurn).toBe(1);
});
// prepareLegacySocketTurn --------------------------------------------------
it('prepareLegacySocketTurn refuses with runtime_unsupported and never touches embedded under pi-rpc', async () => {
const { port, calls } = recordingEmbeddedPort();
const result = await piRouter(port).prepareLegacySocketTurn(
ctx(),
{ content: 'hello' },
stream,
);
expect(result).toEqual(RUNTIME_UNSUPPORTED);
expect(calls.prepareLegacySocketTurn).toBe(0);
});
it('prepareLegacySocketTurn delegates to embedded under legacy (guard is the sole gate)', async () => {
const { port, calls } = recordingEmbeddedPort();
const result = await legacyRouter(port).prepareLegacySocketTurn(
ctx(),
{ content: 'hello' },
stream,
);
expect(result.ok).toBe(true);
expect(calls.prepareLegacySocketTurn).toBe(1);
});
// setLegacyThinking (sync) -------------------------------------------------
it('setLegacyThinking refuses with runtime_unsupported and never touches embedded under pi-rpc', () => {
const { port, calls } = recordingEmbeddedPort();
const result = piRouter(port).setLegacyThinking(ctx(), 'high');
expect(result).toEqual(RUNTIME_UNSUPPORTED);
expect(calls.setLegacyThinking).toBe(0);
});
it('setLegacyThinking delegates to embedded under legacy (guard is the sole gate)', () => {
const { port, calls } = recordingEmbeddedPort();
const result = legacyRouter(port).setLegacyThinking(ctx(), 'high');
expect(result.ok).toBe(true);
expect(calls.setLegacyThinking).toBe(1);
});
// abortLegacyTurn ----------------------------------------------------------
it('abortLegacyTurn refuses with runtime_unsupported and never touches embedded under pi-rpc', async () => {
const { port, calls } = recordingEmbeddedPort();
const result = await piRouter(port).abortLegacyTurn(ctx());
expect(result).toEqual(RUNTIME_UNSUPPORTED);
expect(calls.abortLegacyTurn).toBe(0);
});
it('abortLegacyTurn delegates to embedded under legacy (guard is the sole gate)', async () => {
const { port, calls } = recordingEmbeddedPort();
const result = await legacyRouter(port).abortLegacyTurn(ctx());
expect(result.ok).toBe(true);
expect(calls.abortLegacyTurn).toBe(1);
});
// applyLegacyModelOverride (sync) ------------------------------------------
it('applyLegacyModelOverride refuses with runtime_unsupported and never touches embedded under pi-rpc', () => {
const { port, calls } = recordingEmbeddedPort();
const result = piRouter(port).applyLegacyModelOverride(ctx(), 'model-x');
expect(result).toEqual(RUNTIME_UNSUPPORTED);
expect(calls.applyLegacyModelOverride).toBe(0);
});
it('applyLegacyModelOverride delegates to embedded under legacy (guard is the sole gate)', () => {
const { port, calls } = recordingEmbeddedPort();
const result = legacyRouter(port).applyLegacyModelOverride(ctx(), 'model-x');
expect(result.ok).toBe(true);
expect(calls.applyLegacyModelOverride).toBe(1);
});
// readLegacySessionPresentation (sync) -------------------------------------
it('readLegacySessionPresentation refuses with runtime_unsupported and never touches embedded under pi-rpc', () => {
const { port, calls } = recordingEmbeddedPort();
const result = piRouter(port).readLegacySessionPresentation(ctx());
expect(result).toEqual(RUNTIME_UNSUPPORTED);
expect(calls.readLegacySessionPresentation).toBe(0);
});
it('readLegacySessionPresentation delegates to embedded under legacy (guard is the sole gate)', () => {
const { port, calls } = recordingEmbeddedPort();
const result = legacyRouter(port).readLegacySessionPresentation(ctx());
expect(result.ok).toBe(true);
expect(calls.readLegacySessionPresentation).toBe(1);
});
// dispatchVerifiedDiscordIngress delegates in BOTH modes (embedded-only, no guard) ---------
it('dispatchVerifiedDiscordIngress delegates to embedded under pi-rpc (embedded-only, no mode guard)', async () => {
const { port, calls } = recordingEmbeddedPort();
const discordCtx = ctx() as unknown as Parameters<
ChatRuntimeRouter['dispatchVerifiedDiscordIngress']
>[0];
const result = await piRouter(port).dispatchVerifiedDiscordIngress(discordCtx, stream);
expect(result.ok).toBe(true);
expect(calls.dispatchVerifiedDiscordIngress).toBe(1);
});
});
/**
* Task Five, Step Two — group 1 (real Nest module-graph readiness).
*
* The unit suite above constructs the router directly. This group drives the SAME contract
* through a real NestJS graph: it imports the production `HarnessModule` (the proven-booting
* idiom from harness.controller.spec.ts) so the router resolves the REAL, empty `HarnessRegistry`
* via the real `HARNESS_REGISTRY` token, then runs the router's `OnModuleInit` through the Nest
* lifecycle (`moduleRef.init()`). Red-first: the router is an unimplemented stub whose
* `onModuleInit` throws a generic Error, so:
* - readiness cases fail because the graph never comes up (init rejects), and
* - fail-closed cases fail because a generic stub throw is NOT the SPECIFIC typed
* `ChatRuntimeUnavailableError` (reason/code) the contract demands — a stub that
* "throws anything" cannot mask these greens.
* The router is NOT wired into a production module yet, so it is provided here via a factory
* over the real registry token. Importing the real `ChatModule` bare is deliberately avoided:
* it injects `AgentService` without importing `AgentModule`, so its graph fails to RESOLVE — a
* collection/DI error, not a behavioural red. `next` is untouched; nothing here implements the router.
*/
describe('ChatRuntimeRouter — real Nest module-graph readiness (Task Five, Step Two group 1)', () => {
async function bootRouterGraph(
mode: ChatRuntimeMode,
opts: { adapters: readonly string[]; service: HarnessConversationServiceBinding },
) {
const moduleRef = await Test.createTestingModule({
imports: [HarnessModule],
providers: [
{
provide: ChatRuntimeRouter,
useFactory: (registry: HarnessRegistry) =>
new ChatRuntimeRouter(registry, opts.service, embedded, harness, mode),
inject: [HARNESS_REGISTRY],
},
],
})
// The imported HarnessModule's controllers reference AuthGuard (an HTTP-only concern,
// never exercised here); stub it so the graph resolves. The registry is NOT overridden —
// group 1 asserts against the genuine production HarnessRegistry.
.overrideGuard(AuthGuard)
.useValue({ canActivate: () => true })
.compile();
// Resolve the production registry singleton and register the requested adapters ON IT, so
// the router (which injects the same singleton) sees them when its lifecycle hook runs.
const registry = moduleRef.get<HarnessRegistry>(HARNESS_REGISTRY, { strict: false });
for (const id of opts.adapters) {
registry.register({
id,
describe: () => Promise.reject(new Error('unused')),
catalog: () => Promise.reject(new Error('unused')),
create: () => Promise.reject(new Error('unused')),
resume: () => Promise.reject(new Error('unused')),
} as HarnessAdapter);
}
return moduleRef;
}
// Capture an init rejection without letting a resolved init masquerade as success.
const initError = (moduleRef: { init(): Promise<unknown> }): Promise<unknown> =>
moduleRef.init().then(
() => new Error('module init resolved but the contract requires it to reject'),
(err: unknown) => err,
);
it('brings the graph up and resolves only the harness runtime in pi-rpc mode (pi adapter + bound service)', async () => {
const moduleRef = await bootRouterGraph('pi-rpc', {
adapters: ['pi'],
service: boundConversationService,
});
try {
await moduleRef.init();
const router = moduleRef.get(ChatRuntimeRouter, { strict: false });
expect(router.active).toBe(harness);
expect(router.active.kind).toBe('harness');
} finally {
await moduleRef.close();
}
});
it('brings the graph up in legacy mode over the REAL empty HarnessRegistry and resolves only the embedded runtime', async () => {
const moduleRef = await bootRouterGraph('legacy', {
adapters: [],
service: HARNESS_CONVERSATION_SERVICE_UNAVAILABLE,
});
try {
// Defense-in-depth: the production module wires the genuine registry, empty by default —
// guards against a test-double registry silently satisfying the readiness check.
const registry = moduleRef.get<HarnessRegistry>(HARNESS_REGISTRY, { strict: false });
expect(registry).toBeInstanceOf(HarnessRegistry);
expect(registry.list()).toHaveLength(0);
await moduleRef.init();
const router = moduleRef.get(ChatRuntimeRouter, { strict: false });
expect(router.active).toBe(embedded);
expect(router.active.kind).toBe('embedded');
} finally {
await moduleRef.close();
}
});
it('fails closed at module init when pi-rpc mode has no registered pi adapter (specific typed error, not a stub throw)', async () => {
const moduleRef = await bootRouterGraph('pi-rpc', {
adapters: [],
service: boundConversationService,
});
try {
const err = await initError(moduleRef);
expect(err).toBeInstanceOf(ChatRuntimeUnavailableError);
expect((err as ChatRuntimeUnavailableError).reason).toBe('adapter_unavailable');
expect((err as ChatRuntimeUnavailableError).code).toBe('runtime_unsupported');
} finally {
await closeIgnoringFailedInit(moduleRef);
}
});
it('fails closed at module init when pi-rpc mode has the unavailable conversation-service sentinel', async () => {
const moduleRef = await bootRouterGraph('pi-rpc', {
adapters: ['pi'],
service: HARNESS_CONVERSATION_SERVICE_UNAVAILABLE,
});
try {
const err = await initError(moduleRef);
expect(err).toBeInstanceOf(ChatRuntimeUnavailableError);
expect((err as ChatRuntimeUnavailableError).reason).toBe('conversation_service_unavailable');
expect((err as ChatRuntimeUnavailableError).code).toBe('runtime_unsupported');
} finally {
await closeIgnoringFailedInit(moduleRef);
}
});
it('surfaces only fixed, browser-safe failure text when the graph fails closed (no stub/exception detail)', async () => {
const moduleRef = await bootRouterGraph('pi-rpc', {
adapters: [],
service: boundConversationService,
});
try {
const err = await initError(moduleRef);
expect(err).toBeInstanceOf(ChatRuntimeUnavailableError);
const message = (err as ChatRuntimeUnavailableError).message;
expect(message).toBe(
'The pi-rpc chat runtime is unavailable: no "pi" harness adapter is registered.',
);
expect(message).not.toMatch(/Error:|\bat \b|node_modules|Symbol\(|not implemented/);
} finally {
await closeIgnoringFailedInit(moduleRef);
}
});
});
/**
* Task Five, Step Two — group 1b (production ChatModule wiring, declaration proof).
*
* Correction #1 (Scrappy fe3e02) asked for a red that imports the real `ChatModule` and calls
* `module.init()`. Investigated and found impractical/masking-prone: `ChatModule` provides
* `ChatGateway`, whose 10-argument constructor injects app-global providers (AgentService, AUTH,
* BRAIN, RoutingEngineService) plus the Commands/GC/Mcp/Reload subsystems across a forwardRef
* cycle. Booting it in isolation is a full-app integration boot — "override only unrelated
* dependencies" balloons into faking ~4 subsystems, and `overrideProvider` cannot even grant the
* cross-module export-scope visibility ChatGateway needs (probe: `ChatGateway` unresolved at
* `CommandExecutorService`). That is exactly the STOP-and-return branch of the directive.
*
* The faithful, unmaskable cover instead of a fragile boot: read the PRODUCTION `ChatModule`'s own
* Nest `@Module` metadata to prove it DECLARES the exclusive router provider and imports the real
* `HarnessModule` (the genuine registry source). This inspects the actual module object — not
* source text, not a test factory — so nothing can mask it. Group 1 above separately proves the
* router RESOLVES against the real, empty `HarnessRegistry` through the Nest lifecycle; the union
* of the two covers "the router is wired through ChatModule to the real registry" without the
* impractical single-graph boot. RED today (ChatModule provides only ChatGateway and imports only
* CommandsModule); GREEN once Step Three registers the router and imports HarnessModule.
*/
describe('ChatModule production wiring (Task Five, Step Two group 1b — declaration proof)', () => {
// Unwrap a forwardRef(() => Module) import to the module it references; pass others through.
const resolveImport = (imp: unknown): unknown =>
imp &&
typeof imp === 'object' &&
typeof (imp as { forwardRef?: unknown }).forwardRef === 'function'
? (imp as { forwardRef: () => unknown }).forwardRef()
: imp;
// A provider entry is either a class (shorthand) or a { provide, ... } object; take its token.
const providerToken = (provider: unknown): unknown =>
typeof provider === 'function' ? provider : (provider as { provide?: unknown })?.provide;
it('declares the exclusive ChatRuntimeRouter as a provider on the production ChatModule', () => {
const providers: unknown[] = Reflect.getMetadata('providers', ChatModule) ?? [];
expect(providers.map(providerToken)).toContain(ChatRuntimeRouter);
});
it('imports the real HarnessModule into the production ChatModule (registry source, not a test double)', () => {
const imports: unknown[] = Reflect.getMetadata('imports', ChatModule) ?? [];
expect(imports.map(resolveImport)).toContain(HarnessModule);
});
});
/**
* Task Five, Step Two — group 1c (bounded real-`ChatModule` boot).
*
* Scrappy adjudication d67d2b (option c): boot the ACTUAL production `ChatModule` as the SUT and
* assert the exclusive router resolves THROUGH it — the single-graph proof group 1 (router over the
* real registry) and group 1b (production-module metadata) each cover only a half of. The heavy,
* UNRELATED cycle is the only thing bounded away, per the established isolation pattern in
* `apps/gateway/src/agent/hermes-runtime-reachability.e2e.test.ts`:
* - `CommandsModule` (drags the Commands <-> Reload <-> Chat forwardRef cycle plus GC/Mcp/queue)
* is replaced wholesale with an empty module via `.overrideModule(...).useModule(...)`;
* - `ChatGateway` (10-arg constructor, an HTTP/socket concern never exercised here) is replaced
* with an inert value;
* - the sole legacy-controller dependency, `AgentService`, is supplied by a tiny `@Global()` stub;
* - the HTTP-only `AuthGuard` is stubbed.
* Nothing about the router, `HarnessModule`, the registry, or the conversation-service binding is
* faked in the production-legacy case — those are retrieved from the REAL `ChatModule` graph. Mode
* is driven only through the production `CHAT_HARNESS_RUNTIME` env contract (`resolveChatRuntimeMode`).
*
* Red-first: today `ChatModule` neither imports `HarnessModule` nor provides `ChatRuntimeRouter`, so
* the booted graph contains no router/registry/conversation-service tokens. `init()` may resolve
* (there is no router lifecycle hook yet to reject), so every case fails on the MISSING actual
* router/registry/service wiring — not on unrelated DI, which is bounded away. GREEN at Step Three
* once `ChatModule` imports `HarnessModule`, provides the exclusive router, and binds the
* conversation-service token (defaulting to the unavailable sentinel).
*/
describe('ChatModule bounded real boot (Task Five, Step Two group 1c)', () => {
// The unrelated heavy cycle, replaced wholesale — not stubbed provider-by-provider.
@Module({})
class EmptyCommandsModule {}
// The ONLY genuine legacy dependency of the real ChatController, supplied inertly and globally so
// the pre-refactor controller instantiates without dragging AgentModule into the graph.
@Global()
@Module({
providers: [{ provide: AgentService, useValue: {} }],
exports: [AgentService],
})
class LegacyControllerDepsModule {}
const ORIGINAL_RUNTIME_ENV = process.env['CHAT_HARNESS_RUNTIME'];
afterEach(() => {
if (ORIGINAL_RUNTIME_ENV === undefined) delete process.env['CHAT_HARNESS_RUNTIME'];
else process.env['CHAT_HARNESS_RUNTIME'] = ORIGINAL_RUNTIME_ENV;
});
/**
* Boot the real ChatModule with only the unrelated cycle bounded away. `mode` is set through the
* genuine production env contract before providers instantiate. The optional overrides replace
* the registry / conversation-service the router injects, exercising the pi-rpc precondition
* branches through the ACTUAL module (they are no-ops today because those tokens are not yet in
* the graph — which is exactly why the router-retrieval assertions go red).
*/
async function bootChatModule(
mode: ChatRuntimeMode,
overrides: {
registryAdapters?: readonly string[];
conversationService?: HarnessConversationServiceBinding;
} = {},
): Promise<TestingModule> {
if (mode === 'pi-rpc') process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc';
else delete process.env['CHAT_HARNESS_RUNTIME'];
let builder = Test.createTestingModule({
imports: [LegacyControllerDepsModule, ChatModule],
})
.overrideModule(CommandsModule)
.useModule(EmptyCommandsModule)
.overrideProvider(ChatGateway)
.useValue({})
.overrideGuard(AuthGuard)
.useValue({ canActivate: () => true });
if (overrides.registryAdapters) {
builder = builder
.overrideProvider(HARNESS_REGISTRY)
.useValue(registryWith(overrides.registryAdapters));
}
if (overrides.conversationService !== undefined) {
builder = builder
.overrideProvider(HARNESS_CONVERSATION_SERVICE)
.useValue(overrides.conversationService);
}
return builder.compile();
}
// Capture an init rejection without letting a resolved init masquerade as success.
const initError = (moduleRef: TestingModule): Promise<unknown> =>
moduleRef.init().then(
() => new Error('module init resolved but the contract requires it to reject'),
(err: unknown) => err,
);
it('legacy mode: the actual router resolves the embedded runtime, the actual registry is empty, and the conversation-service token is the unavailable sentinel', async () => {
const moduleRef = await bootChatModule('legacy');
try {
await moduleRef.init();
const router = moduleRef.get(ChatRuntimeRouter, { strict: false });
expect(router.active.kind).toBe('embedded');
const registry = moduleRef.get<HarnessRegistry>(HARNESS_REGISTRY, { strict: false });
expect(registry).toBeInstanceOf(HarnessRegistry);
expect(registry.list()).toHaveLength(0);
const service = moduleRef.get<HarnessConversationServiceBinding>(
HARNESS_CONVERSATION_SERVICE,
{
strict: false,
},
);
expect(service).toBe(HARNESS_CONVERSATION_SERVICE_UNAVAILABLE);
} finally {
await moduleRef.close();
}
});
it('pi-rpc mode over the REAL empty registry fails closed at init with the typed adapter-unavailable error', async () => {
const moduleRef = await bootChatModule('pi-rpc');
try {
const err = await initError(moduleRef);
expect(err).toBeInstanceOf(ChatRuntimeUnavailableError);
expect((err as ChatRuntimeUnavailableError).reason).toBe('adapter_unavailable');
expect((err as ChatRuntimeUnavailableError).code).toBe('runtime_unsupported');
} finally {
await closeIgnoringFailedInit(moduleRef);
}
});
it('pi-rpc mode with a pi adapter present but the sentinel conversation service fails closed with the typed conversation-service-unavailable error', async () => {
const moduleRef = await bootChatModule('pi-rpc', {
registryAdapters: ['pi'],
conversationService: HARNESS_CONVERSATION_SERVICE_UNAVAILABLE,
});
try {
const err = await initError(moduleRef);
expect(err).toBeInstanceOf(ChatRuntimeUnavailableError);
expect((err as ChatRuntimeUnavailableError).reason).toBe('conversation_service_unavailable');
expect((err as ChatRuntimeUnavailableError).code).toBe('runtime_unsupported');
} finally {
await closeIgnoringFailedInit(moduleRef);
}
});
it('pi-rpc mode with a pi adapter and a bound conversation service: the actual router selects the harness runtime', async () => {
const moduleRef = await bootChatModule('pi-rpc', {
registryAdapters: ['pi'],
conversationService: boundConversationService,
});
try {
await moduleRef.init();
const router = moduleRef.get(ChatRuntimeRouter, { strict: false });
expect(router.active.kind).toBe('harness');
} finally {
await moduleRef.close();
}
});
});
/**
* Task Five, Step Two — group 2 (WHOLE production `AppModule` boot, legacy end-to-end wiring).
*
* The groups above bound away the heavy cycle to isolate the router. This group instead boots the
* ACTUAL production `AppModule` (the exact graph `main.ts` runs) in the default LEGACY chat-runtime
* mode, overriding ONLY the storage/network side-effect adapters so the boot is bounded and offline
* — never the chat/router/harness/reload/commands surface under test. The bounded fakes are exactly
* the disk/network leaves:
* - `ProviderService` (the #1 hang risk: its real `onModuleInit` starts an unref'd health-check
* `setInterval` and fetches Ollama over HTTP) → inert no-op instance;
* - `DB_HANDLE`/`DB` → a fake Drizzle-shaped handle that satisfies `runPgliteMigrations` (the local
* tier's `DatabaseModule.onModuleInit`) AND `DefaultRoutingRulesSeed.onModuleInit` (which reads a
* system-rule count — the fake reports rules already present so the seed insert is skipped),
* opening no real database;
* - `STORAGE_ADAPTER`/`MEMORY`/`MEMORY_ADAPTER`/`AUTH`/`BRAIN`/`LOG_SERVICE` → inert fakes so no
* storage/auth/log backend is contacted.
* Local tier (the repo's `mosaic.config.json`) already disables BullMQ/Redis and the queue handles;
* Discord/Telegram/MCP plugins are env-gated and disarmed by deleting their tokens. Nothing about the
* router, `ChatModule`, `HarnessModule`, or `ChatGateway` is faked — those come from the REAL graph.
*
* The boot+init MUST SUCCEED cleanly (proven by `beforeAll` completing and the ChatGateway test
* passing). Red-first: on this branch `ChatRuntimeRouter` is registered in NO module (ChatModule
* provides only ChatGateway), so `moduleRef.get(ChatRuntimeRouter)` throws `UnknownElementException`
* — a WIRING gap, NOT an init failure. That single retrieval is the intended behavioural red; it
* flips green once Step Three registers the exclusive router. The ChatGateway retrieval and its
* browser-facing method surface are asserted alongside and pass today, pinning that the boot itself
* is healthy so the router failure cannot be mistaken for a mis-shaped fake or an unbounded side
* effect.
*/
describe('AppModule production boot — legacy ChatRuntimeRouter wiring (Task Five, Step Two group 2)', () => {
// A Drizzle-shaped fake that satisfies both DB consumers reached during a local-tier init:
// • runPgliteMigrations(): reads handle.db.$client.exec + handle.db.execute(SELECT hashes);
// exec is a no-op and execute yields an empty ledger, so migration statements no-op through.
// • DefaultRoutingRulesSeed.seedDefaultRules(): db.select().from().where() must resolve to a
// row set — we report a non-zero system-rule count so the seeding INSERT branch is skipped.
const fakeDb = {
$client: { exec: async (): Promise<void> => {} },
execute: async (): Promise<{ rows: unknown[] }> => ({ rows: [] }),
select: () => ({
from: () => ({
where: async (): Promise<Array<{ count: number }>> => [{ count: 1 }],
}),
}),
insert: () => ({ values: async (): Promise<void> => {} }),
};
const fakeDbHandle = { db: fakeDb, close: async (): Promise<void> => {} };
const fakeStorageAdapter = {
name: 'fake',
migrate: async (): Promise<void> => {},
close: async (): Promise<void> => {},
};
// Inert stand-in for the real ProviderService: no health-check interval, no Ollama fetch.
const fakeProviderService = {
onModuleInit: async (): Promise<void> => {},
onModuleDestroy: (): void => {},
getRegistry: () => ({
getAvailable: () => [],
getAll: () => [],
find: () => undefined,
}),
getDefaultModel: () => undefined,
listAvailableModels: () => [],
listProviders: () => [],
getAdapter: () => undefined,
getProvidersHealth: () => [],
};
const fakeBrain = { conversations: {}, agents: {} };
const BOOT_TIMEOUT_MS = 120_000;
let moduleRef: TestingModule;
let envSnapshot: Record<string, string | undefined>;
beforeAll(async () => {
envSnapshot = { ...process.env };
// Env hygiene: disarm the network-facing plugins/adapters and pin the legacy runtime mode.
delete process.env['DATABASE_URL'];
delete process.env['DISCORD_BOT_TOKEN'];
delete process.env['TELEGRAM_BOT_TOKEN'];
delete process.env['MCP_SERVERS'];
delete process.env['CHAT_HARNESS_RUNTIME']; // resolveChatRuntimeMode → 'legacy'
process.env['MOSAIC_STORAGE_TIER'] = 'local';
moduleRef = await Test.createTestingModule({ imports: [AppModule] })
// Storage/network side-effect adapters ONLY — never the router/chat/harness surface under test.
.overrideProvider('DB_HANDLE')
.useValue(fakeDbHandle)
.overrideProvider('DB')
.useValue(fakeDb)
.overrideProvider('STORAGE_ADAPTER')
.useValue(fakeStorageAdapter)
.overrideProvider('AUTH')
.useValue({})
.overrideProvider('BRAIN')
.useValue(fakeBrain)
.overrideProvider('LOG_SERVICE')
.useValue({})
.overrideProvider('MEMORY')
.useValue({})
.overrideProvider('MEMORY_ADAPTER')
.useValue({})
.overrideProvider(ProviderService)
.useValue(fakeProviderService)
.compile();
// The boot itself MUST succeed cleanly — a rejection here is a bounding failure, not the red.
await moduleRef.init();
}, BOOT_TIMEOUT_MS);
afterAll(async () => {
if (moduleRef) await moduleRef.close();
for (const key of Object.keys(process.env)) {
if (!(key in envSnapshot)) delete process.env[key];
}
for (const [key, value] of Object.entries(envSnapshot)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
});
// Passes TODAY: the real ChatGateway is provided by the real ChatModule and its browser-facing
// surface exists. This pins that the whole-AppModule boot came up healthy, so the router failure
// below is unambiguously a wiring gap and not a mis-shaped fake or an unbounded side effect.
it('boots the whole AppModule and exposes the real ChatGateway with its browser-facing methods', () => {
const gateway = moduleRef.get(ChatGateway, { strict: false });
expect(typeof gateway.broadcastReload).toBe('function');
expect(typeof gateway.getModelOverride).toBe('function');
expect(typeof gateway.setModelOverride).toBe('function');
expect(typeof gateway.broadcastSessionInfo).toBe('function');
});
// RED TODAY: ChatRuntimeRouter is registered in no module on this branch, so this retrieval throws
// UnknownElementException — the intended red-first wiring failure. GREEN once Step Three registers
// the exclusive router in the production graph, where legacy mode resolves the embedded runtime.
it('resolves the exclusive ChatRuntimeRouter to the embedded runtime in legacy mode', () => {
const router = moduleRef.get(ChatRuntimeRouter, { strict: false });
expect(router.active.kind).toBe('embedded');
});
});
@@ -1,173 +0,0 @@
import { Injectable, type OnModuleInit } from '@nestjs/common';
import { HarnessRegistry } from '../harness/harness.registry.js';
import {
isHarnessConversationServiceAvailable,
type HarnessConversationServiceBinding,
} from '../harness/harness.tokens.js';
import type {
ChatRuntime,
ChatRuntimeMode,
LegacyBrowserMessagePayload,
LegacyEmbeddedChatPort,
LegacyRuntimeResult,
LegacyRuntimeStream,
LegacySessionPresentation,
LegacySocketTurnLease,
OwnedConversationContext,
VerifiedDiscordIngressContext,
VerifiedDiscordTurnLease,
} from './chat-runtime.js';
import { ChatRuntimeUnavailableError, resolveChatRuntimeMode } from './chat-runtime.js';
/** The fixed fail-closed result for a legacy browser operation issued under `pi-rpc`. */
const RUNTIME_UNSUPPORTED = {
ok: false as const,
code: 'runtime_unsupported' as const,
retryable: false as const,
};
/**
* Resolves the one live {@link ChatRuntime} for this process and enforces the
* `pi-rpc` readiness preconditions at module init — before the gateway accepts
* traffic. It never falls back from `pi-rpc` to embedded execution: an unmet
* `pi-rpc` precondition is a typed startup failure ({@link ChatRuntimeUnavailableError}),
* and until `onModuleInit` selects a runtime, {@link active} throws rather than
* exposing any runtime — a failed `pi-rpc` init can never leak the embedded one.
*/
@Injectable()
export class ChatRuntimeRouter implements OnModuleInit, LegacyEmbeddedChatPort {
private readonly mode: ChatRuntimeMode;
/** The single resolved runtime. Undefined until a successful `onModuleInit`. */
private resolved: ChatRuntime | undefined;
constructor(
private readonly harnessRegistry: HarnessRegistry,
private readonly conversationService: HarnessConversationServiceBinding,
private readonly embedded: ChatRuntime,
private readonly harness: ChatRuntime,
mode: ChatRuntimeMode = resolveChatRuntimeMode(),
) {
this.mode = mode;
}
onModuleInit(): void {
if (this.mode === 'legacy') {
// Legacy ignores the pi-rpc preconditions entirely and always runs embedded.
this.resolved = this.embedded;
return;
}
// pi-rpc: both preconditions are hard startup failures, checked in a fixed order.
if (!this.harnessRegistry.has('pi')) {
this.resolved = undefined;
throw new ChatRuntimeUnavailableError('adapter_unavailable');
}
if (!isHarnessConversationServiceAvailable(this.conversationService)) {
this.resolved = undefined;
throw new ChatRuntimeUnavailableError('conversation_service_unavailable');
}
this.resolved = this.harness;
}
get active(): ChatRuntime {
if (this.resolved === undefined) {
// Reached only if init has not run or failed closed; never expose a runtime here.
throw new Error('The chat runtime is not available: startup did not resolve a runtime.');
}
return this.resolved;
}
/**
* The process-wide mode, available before {@link onModuleInit}. Production handlers read
* this to fail a legacy browser turn closed under `pi-rpc` *before* parsing the payload as
* either browser-legacy input or a Discord envelope — never to branch into a fallback.
*/
get runtimeMode(): ChatRuntimeMode {
return this.mode;
}
/**
* The embedded runtime narrowed to its port. Only reached on the legacy path (and for the
* verified-Discord op in both modes), where the injected runtime is always a real
* `EmbeddedChatRuntime`. The router spec constructs the router with a bare `{ kind }` stub
* but never invokes a port op, so this narrowing is never exercised against the stub.
*/
private get embeddedPort(): LegacyEmbeddedChatPort {
return this.embedded as unknown as LegacyEmbeddedChatPort;
}
// --- LegacyEmbeddedChatPort: legacy browser operations fail closed under pi-rpc ---
completeLegacyRestTurn(
context: OwnedConversationContext,
input: Readonly<{ content: string }>,
): Promise<
LegacyRuntimeResult<Readonly<{ text: string; presentation: LegacySessionPresentation }>>
> {
if (this.mode === 'pi-rpc') {
return Promise.resolve(RUNTIME_UNSUPPORTED);
}
return this.embeddedPort.completeLegacyRestTurn(context, input);
}
prepareLegacySocketTurn(
context: OwnedConversationContext,
input: LegacyBrowserMessagePayload,
stream: LegacyRuntimeStream,
): Promise<LegacyRuntimeResult<LegacySocketTurnLease>> {
if (this.mode === 'pi-rpc') {
return Promise.resolve(RUNTIME_UNSUPPORTED);
}
return this.embeddedPort.prepareLegacySocketTurn(context, input, stream);
}
setLegacyThinking(
context: OwnedConversationContext,
level: string,
): LegacyRuntimeResult<LegacySessionPresentation> {
if (this.mode === 'pi-rpc') {
return RUNTIME_UNSUPPORTED;
}
return this.embeddedPort.setLegacyThinking(context, level);
}
abortLegacyTurn(context: OwnedConversationContext): Promise<LegacyRuntimeResult<void>> {
if (this.mode === 'pi-rpc') {
return Promise.resolve(RUNTIME_UNSUPPORTED);
}
return this.embeddedPort.abortLegacyTurn(context);
}
applyLegacyModelOverride(
context: OwnedConversationContext,
modelId: string,
): LegacyRuntimeResult<LegacySessionPresentation> {
if (this.mode === 'pi-rpc') {
return RUNTIME_UNSUPPORTED;
}
return this.embeddedPort.applyLegacyModelOverride(context, modelId);
}
readLegacySessionPresentation(
context: OwnedConversationContext,
): LegacyRuntimeResult<LegacySessionPresentation> {
if (this.mode === 'pi-rpc') {
return RUNTIME_UNSUPPORTED;
}
return this.embeddedPort.readLegacySessionPresentation(context);
}
/**
* Verified Discord ingress bypasses browser mode: it is embedded-only in BOTH modes and
* never reaches the harness or routing-engine selection. It is reached only through a
* {@link VerifiedDiscordIngressContext}, which exists only after every ingress check.
*/
dispatchVerifiedDiscordIngress(
context: VerifiedDiscordIngressContext,
stream: LegacyRuntimeStream,
): Promise<LegacyRuntimeResult<VerifiedDiscordTurnLease>> {
return this.embeddedPort.dispatchVerifiedDiscordIngress(context, stream);
}
}
-273
View File
@@ -1,273 +0,0 @@
import type { ChannelAttachmentDto, RoutingDecisionInfo } from '@mosaicstack/types';
/**
* The single chat execution strategy resolved by {@link ChatRuntimeRouter}.
*
* Exactly one runtime is live per process. There is no union that lets a
* `pi-rpc` deployment silently fall back to embedded execution: an unmet
* `pi-rpc` precondition is a typed startup failure, never a downgrade.
*/
export type ChatRuntimeMode = 'legacy' | 'pi-rpc';
export type ChatRuntimeKind = 'embedded' | 'harness';
/** The resolved runtime. Slice Zero exposes only its immutable {@link ChatRuntimeKind}. */
export interface ChatRuntime {
readonly kind: ChatRuntimeKind;
}
/** Why the `pi-rpc` runtime could not be made ready. Both are hard startup failures. */
export type ChatRuntimeUnavailableReason =
| 'adapter_unavailable'
| 'conversation_service_unavailable';
/**
* Raised at module init when `pi-rpc` mode is selected but its preconditions are
* unmet. Carries only fixed, browser-safe text — never a raw exception message,
* stack, or provider detail — and reports the frozen ack code `runtime_unsupported`.
*/
export class ChatRuntimeUnavailableError extends Error {
readonly code = 'runtime_unsupported' as const;
readonly reason: ChatRuntimeUnavailableReason;
constructor(reason: ChatRuntimeUnavailableReason) {
super(
reason === 'adapter_unavailable'
? 'The pi-rpc chat runtime is unavailable: no "pi" harness adapter is registered.'
: 'The pi-rpc chat runtime is unavailable: the harness conversation service is not bound.',
);
this.name = 'ChatRuntimeUnavailableError';
this.reason = reason;
}
}
/**
* Resolves the process-wide chat runtime mode from the environment. Anything other
* than the exact opt-in token `pi-rpc` keeps the legacy embedded runtime.
*/
export function resolveChatRuntimeMode(
env: Record<string, string | undefined> = process.env,
): ChatRuntimeMode {
return env['CHAT_HARNESS_RUNTIME'] === 'pi-rpc' ? 'pi-rpc' : 'legacy';
}
// ---------------------------------------------------------------------------
// Transitional embedded chat port (Task Five).
//
// The legacy embedded browser behaviour is moved behind this exact interface so
// neither the controller nor the gateway retains AgentService, RoutingEngine,
// session, `piSession`, metric, listener, or channel access. `EmbeddedChatRuntime`
// implements the port; `ChatRuntimeRouter` exposes the same narrowly named
// operations and returns `runtime_unsupported` before touching Embedded for legacy
// browser operations when the mode is `pi-rpc`.
//
// The names are frozen (spec jarvis-brain@1c629b06). Legacy REST completion,
// legacy Socket streaming, P3 harness turns, and verified Discord are distinct
// transport/trust capabilities — there is deliberately no generic
// `sendConversationTurn` nor an AgentService-shaped mirror on the router.
// ---------------------------------------------------------------------------
/**
* Phantom brand keeping {@link OwnedConversationContext} nominally distinct so browser
* DTOs are never structurally assignable to it. The factory that mints one may be called
* only after authentication with `scopeFromUser(...)`, never with payload authority fields.
*/
declare const ownedConversationContextBrand: unique symbol;
/** Gateway-only ownership context. Embedded rechecks owner+tenant on every operation. */
export interface OwnedConversationContext {
readonly [ownedConversationContextBrand]: true;
readonly conversationId: string;
readonly scope: Readonly<{ userId: string; tenantId: string }>;
}
/**
* Every non-`ok` legacy runtime outcome. Missing, foreign, and no-longer-owned
* conversations all collapse to `conversation_unavailable`. Ownership/mode/validation
* failures are total results and never throw.
*/
export type LegacyRuntimeFailure =
| { readonly ok: false; readonly code: 'runtime_unsupported'; readonly retryable: false }
| { readonly ok: false; readonly code: 'conversation_unavailable'; readonly retryable: false }
| { readonly ok: false; readonly code: 'request_invalid'; readonly retryable: false }
| {
readonly ok: false;
readonly code: 'thinking_level_invalid';
readonly retryable: false;
readonly availableThinkingLevels: readonly string[];
}
| { readonly ok: false; readonly code: 'runtime_unavailable'; readonly retryable: true }
| { readonly ok: false; readonly code: 'turn_already_dispatched'; readonly retryable: false }
| { readonly ok: false; readonly code: 'operation_failed'; readonly retryable: boolean }
| { readonly ok: false; readonly code: 'timeout'; readonly retryable: true };
/** Total result: an `ok` value or one of the fixed {@link LegacyRuntimeFailure} codes. */
export type LegacyRuntimeResult<T> =
| { readonly ok: true; readonly value: T }
| LegacyRuntimeFailure;
/** User-facing session projection. Carries no session object, handle, or credential path. */
export interface LegacySessionPresentation {
readonly provider: string;
readonly modelId: string;
readonly thinkingLevel: string;
readonly availableThinkingLevels: readonly string[];
readonly agentName?: string;
readonly routingDecision?: RoutingDecisionInfo;
}
/** Terminal usage stats, normalized by Embedded from AgentService metrics. */
export interface LegacyUsage {
readonly provider: string;
readonly modelId: string;
readonly thinkingLevel: string;
readonly tokens: Readonly<{
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
total: number;
}>;
readonly cost: number;
readonly context: Readonly<{ percent: number | null; window: number }>;
}
/**
* Normalized stream event. Exposes no `AgentSession`, `piSession`, native handle, raw
* exception, tool arguments, or credential-bearing path — the gateway sees only these.
*/
export type LegacyRuntimeEvent =
| { readonly type: 'started' }
| { readonly type: 'text_delta'; readonly text: string }
| { readonly type: 'thinking_delta'; readonly text: string }
| {
readonly type: 'tool_started';
readonly toolCallId: string;
readonly toolName: string;
}
| {
readonly type: 'tool_finished';
readonly toolCallId: string;
readonly toolName: string;
readonly isError: boolean;
}
| { readonly type: 'settled'; readonly usage?: LegacyUsage };
/** Legacy browser message input. Authority fields are advisory only; scope comes from the context. */
export interface LegacyBrowserMessagePayload {
readonly content: string;
readonly provider?: string;
readonly modelId?: string;
readonly agentId?: string;
readonly attachments?: readonly ChannelAttachmentDto[];
}
/** A prepared-but-not-yet-dispatched legacy socket turn. */
export interface LegacySocketTurnLease {
readonly presentation: LegacySessionPresentation;
/**
* Atomically one-shot and scope-rechecking. A second call returns
* `turn_already_dispatched` and performs zero prompt/tool effects.
*/
dispatch(): Promise<LegacyRuntimeResult<void>>;
/** Idempotent, non-throwing. Removes listener and channel, including partial setup. */
dispose(): Promise<void>;
}
/**
* Phantom brand for {@link VerifiedDiscordIngressContext}. Minted only after service-token
* auth plus signature, allowlist, binding, expected-route, replay, configured-agent,
* forced-scope, and attachment-normalization checks.
*/
declare const verifiedDiscordIngressContextBrand: unique symbol;
/** Fully-verified Discord ingress. Contains no socket, envelope, signature, token, or escape hatch. */
export interface VerifiedDiscordIngressContext {
readonly [verifiedDiscordIngressContextBrand]: true;
readonly conversationId: string;
readonly scope: Readonly<{ userId: string; tenantId: string }>;
readonly configuredAgent: Readonly<{ agentConfigId: string; instanceId: string }>;
readonly content: string;
readonly attachments?: readonly ChannelAttachmentDto[];
readonly correlationId: string;
readonly discordMessageId: string;
readonly discordUserId: string;
}
/** Verified-Discord turn lease. Same atomic one-shot dispatch and idempotent dispose rules. */
export interface VerifiedDiscordTurnLease {
readonly presentation: LegacySessionPresentation;
dispatch(): Promise<LegacyRuntimeResult<void>>;
dispose(): Promise<void>;
}
/** Server-owned egress projection the runtime pushes normalized events into. */
export interface LegacyRuntimeStream {
/** Server-derived, e.g. `websocket:<socket-id>`. Never client-supplied. */
readonly channelId: string;
onEvent(event: LegacyRuntimeEvent): void;
}
/**
* The exact transitional port. `EmbeddedChatRuntime` implements it; `ChatRuntimeRouter`
* mirrors the operation names and fails closed with `runtime_unsupported` for legacy
* browser operations under `pi-rpc`.
*/
export interface LegacyEmbeddedChatPort {
completeLegacyRestTurn(
context: OwnedConversationContext,
input: Readonly<{ content: string }>,
): Promise<
LegacyRuntimeResult<Readonly<{ text: string; presentation: LegacySessionPresentation }>>
>;
prepareLegacySocketTurn(
context: OwnedConversationContext,
input: LegacyBrowserMessagePayload,
stream: LegacyRuntimeStream,
): Promise<LegacyRuntimeResult<LegacySocketTurnLease>>;
setLegacyThinking(
context: OwnedConversationContext,
level: string,
): LegacyRuntimeResult<LegacySessionPresentation>;
abortLegacyTurn(context: OwnedConversationContext): Promise<LegacyRuntimeResult<void>>;
applyLegacyModelOverride(
context: OwnedConversationContext,
modelId: string,
): LegacyRuntimeResult<LegacySessionPresentation>;
readLegacySessionPresentation(
context: OwnedConversationContext,
): LegacyRuntimeResult<LegacySessionPresentation>;
dispatchVerifiedDiscordIngress(
context: VerifiedDiscordIngressContext,
stream: LegacyRuntimeStream,
): Promise<LegacyRuntimeResult<VerifiedDiscordTurnLease>>;
}
/**
* Mints an {@link OwnedConversationContext} from a server-derived scope. Callers must pass
* a scope produced by `scopeFromUser(...)` after authentication — never a client-supplied
* authority field. The brand is phantom, so this is the only way to obtain the branded type.
*/
export function ownConversation(
conversationId: string,
scope: Readonly<{ userId: string; tenantId: string }>,
): OwnedConversationContext {
return { conversationId, scope } as unknown as OwnedConversationContext;
}
/**
* Mints a {@link VerifiedDiscordIngressContext}. Callers must have already completed every
* ingress check (service-token auth, signature, allowlist, binding, expected-route, replay,
* configured-agent, forced-scope, attachment normalization) before calling this.
*/
export function verifyDiscordIngress(
fields: Omit<VerifiedDiscordIngressContext, typeof verifiedDiscordIngressContextBrand>,
): VerifiedDiscordIngressContext {
return { ...fields } as unknown as VerifiedDiscordIngressContext;
}
+63 -32
View File
@@ -3,20 +3,21 @@ import {
Post,
Body,
Logger,
ForbiddenException,
HttpException,
HttpStatus,
NotFoundException,
Inject,
UseGuards,
} from '@nestjs/common';
import type { AgentSessionEvent } from '@mariozechner/pi-coding-agent';
import { Throttle } from '@nestjs/throttler';
import { AgentService } from '../agent/agent.service.js';
import { AuthGuard } from '../auth/auth.guard.js';
import { CurrentUser } from '../auth/current-user.decorator.js';
import { scopeFromUser, type AuthenticatedUserLike } from '../auth/session-scope.js';
import { v4 as uuid } from 'uuid';
import { ChatRequestDto } from './chat.dto.js';
import { ChatRuntimeRouter } from './chat-runtime-router.js';
import { ownConversation } from './chat-runtime.js';
import type { LegacyRuntimeFailure } from './chat-runtime.js';
interface ChatResponse {
conversationId: string;
@@ -28,7 +29,7 @@ interface ChatResponse {
export class ChatController {
private readonly logger = new Logger(ChatController.name);
constructor(private readonly runtime: ChatRuntimeRouter) {}
constructor(@Inject(AgentService) private readonly agentService: AgentService) {}
@Post()
@Throttle({ default: { limit: 10, ttl: 60_000 } })
@@ -39,38 +40,68 @@ export class ChatController {
const conversationId = body.conversationId ?? uuid();
const scope = scopeFromUser(user);
try {
let agentSession = this.agentService.getSession(conversationId, scope);
if (!agentSession) {
agentSession = await this.agentService.createSession(conversationId, {
userId: scope.userId,
tenantId: scope.tenantId,
});
}
} catch (err) {
if (err instanceof ForbiddenException) {
throw new NotFoundException('Session not found');
}
this.logger.error(
`Session creation failed for conversation=${conversationId}`,
err instanceof Error ? err.stack : String(err),
);
throw new HttpException('Agent session unavailable', HttpStatus.SERVICE_UNAVAILABLE);
}
this.logger.debug(`Handling chat request for user=${user.id}, conversation=${conversationId}`);
// The one exclusive runtime owns execution. In legacy mode this reaches the embedded runtime;
// in pi-rpc it fails closed with `runtime_unsupported` before ever touching embedded execution.
const result = await this.runtime.completeLegacyRestTurn(
ownConversation(conversationId, scope),
{ content: body.content },
);
let responseText = '';
if (result.ok) {
return { conversationId, text: result.value.text };
const done = new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
cleanup();
this.logger.error(`Agent response timed out after 120s for conversation=${conversationId}`);
reject(new Error('Agent response timed out'));
}, 120_000);
const cleanup = this.agentService.onEvent(
conversationId,
(event: AgentSessionEvent) => {
if (
event.type === 'message_update' &&
event.assistantMessageEvent.type === 'text_delta'
) {
responseText += event.assistantMessageEvent.delta;
}
if (event.type === 'agent_end') {
clearTimeout(timer);
cleanup();
resolve();
}
},
scope,
);
});
try {
await this.agentService.prompt(conversationId, body.content, scope);
await done;
} catch (err) {
if (err instanceof HttpException) throw err;
const message = err instanceof Error ? err.message : String(err);
if (message.includes('timed out')) {
throw new HttpException('Agent response timed out', HttpStatus.GATEWAY_TIMEOUT);
}
this.logger.error(`Chat prompt failed for conversation=${conversationId}`, String(err));
throw new HttpException('Agent processing failed', HttpStatus.INTERNAL_SERVER_ERROR);
}
throw this.toHttpException(result, conversationId);
}
/** Maps a total {@link LegacyRuntimeFailure} to the fixed browser-safe HTTP surface. */
private toHttpException(failure: LegacyRuntimeFailure, conversationId: string): HttpException {
switch (failure.code) {
case 'conversation_unavailable':
return new NotFoundException('Session not found');
case 'request_invalid':
case 'thinking_level_invalid':
return new HttpException('Invalid chat request', HttpStatus.BAD_REQUEST);
case 'timeout':
return new HttpException('Agent response timed out', HttpStatus.GATEWAY_TIMEOUT);
case 'runtime_unsupported':
case 'runtime_unavailable':
return new HttpException('Agent runtime unavailable', HttpStatus.SERVICE_UNAVAILABLE);
default:
this.logger.error(`Chat turn failed for conversation=${conversationId}: ${failure.code}`);
return new HttpException('Agent processing failed', HttpStatus.INTERNAL_SERVER_ERROR);
}
return { conversationId, text: responseText };
}
}
+1 -63
View File
@@ -1,14 +1,5 @@
import type { ChannelAttachmentDto } from '@mosaicstack/types';
import { Transform, Type } from 'class-transformer';
import {
IsNotEmpty,
IsObject,
IsOptional,
IsString,
IsUUID,
MaxLength,
ValidateNested,
} from 'class-validator';
import { IsOptional, IsString, IsUUID, MaxLength } from 'class-validator';
export class ChatRequestDto {
@IsOptional()
@@ -46,56 +37,3 @@ export class ChatSocketMessageDto {
/** Validated channel attachment references; binary content is not embedded. */
attachments?: readonly ChannelAttachmentDto[];
}
/**
* Task Five, group 2 — the frozen pi-rpc `turn:send` selection triple.
*
* Each id is a required, non-empty, bounded string. There is no `@IsOptional` and no extra
* field: under `forbidNonWhitelisted` an unknown selection key is rejected, and a missing id
* fails `@IsString` (undefined is not a string) rather than silently passing.
*/
export class HarnessTurnSelectionDto {
@IsString()
@IsNotEmpty()
@MaxLength(255)
harnessId!: string;
@IsString()
@IsNotEmpty()
@MaxLength(255)
providerId!: string;
@IsString()
@IsNotEmpty()
@MaxLength(255)
modelId!: string;
}
/**
* Task Five, group 2 — the frozen wire contract for a pi-rpc `turn:send`.
*
* Validated through the production `ValidationPipe({ whitelist, forbidNonWhitelisted, transform })`:
* a UUID conversation id; `content` trimmed then bounded to 1..10_000 characters (whitespace-only
* collapses to empty and fails `@IsNotEmpty`); a nested `selection` object recursed with an
* explicit `@Type` (a bare `@ValidateNested` is masked green by class-validator's empty-metadata
* `unknownValue`); and a UUID-v4 idempotency key. No `provider`/`modelId`/`attachments` or other
* authority field is declared, so `forbidNonWhitelisted` rejects every unknown top-level key.
*/
export class HarnessTurnSendDto {
@IsUUID()
conversationId!: string;
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsString()
@IsNotEmpty()
@MaxLength(10_000)
content!: string;
@IsObject()
@ValidateNested()
@Type(() => HarnessTurnSelectionDto)
selection!: HarnessTurnSelectionDto;
@IsUUID('4')
idempotencyKey!: string;
}
@@ -8,31 +8,12 @@ const payload: SlashCommandPayload = {
approvalId: 'approval-1',
};
/**
* Task 5 fence (F, existing control): gateway-owned command authorization/approval must
* cause ZERO chat-runtime dispatch. Placed in the gateway's chat-runtime-router slot (the
* former direct `AgentService` slot) so any accidental chat-runtime resolution throws
* loudly instead of silently passing. Because execute/approval run entirely through the
* command executor dependency and never resolve a chat runtime, this fixture is never
* triggered and the ingress stays a GREEN control.
*/
function failIfUsedChatRuntimeRouter() {
return {
onModuleInit: () => {
throw new Error('chat runtime router must not initialise on the command approval path');
},
get active(): never {
throw new Error('chat runtime must not be resolved on the command approval path');
},
};
}
function buildGateway(commandExecutor: {
execute: ReturnType<typeof vi.fn>;
createApproval: ReturnType<typeof vi.fn>;
}): ChatGateway {
return new ChatGateway(
failIfUsedChatRuntimeRouter() as never,
{} as never,
{} as never,
{} as never,
{} as never,
@@ -91,114 +72,3 @@ describe('ChatGateway command approval ingress', () => {
});
});
});
/**
* Task 5 (G3) command runtime fence. Under pi-rpc there is no embedded chat session, so
* embedded slash-commands (/model, /agent, and every other non-audited command) are fixed
* "unsupported" and MUST fail closed BEFORE reaching the command executor — never a silent
* fall-through to embedded execution. Only runtime-independent audited system commands
* (/reload) pass through as a positive control, and the approval path stays runtime-independent.
* The router stub here carries `runtimeMode: 'pi-rpc'` and throws if any runtime is resolved, so
* a fence bypass surfaces as a thrown error rather than a silent embedded dispatch.
*/
function buildPiRpcGateway(commandExecutor: {
execute: ReturnType<typeof vi.fn>;
createApproval: ReturnType<typeof vi.fn>;
}): ChatGateway {
const piRpcRouter = {
runtimeMode: 'pi-rpc' as const,
onModuleInit: () => {
throw new Error('chat runtime router must not initialise on the pi-rpc command path');
},
get active(): never {
throw new Error('chat runtime must not be resolved on the pi-rpc command path');
},
};
return new ChatGateway(
piRpcRouter as never,
{} as never,
{} as never,
{} as never,
commandExecutor as never,
{} as never,
);
}
describe('ChatGateway command runtime fence (Task 5 G3, pi-rpc)', () => {
const UNSUPPORTED = 'Slash commands are not available on this deployment.';
it.each(['model', 'agent', 'gc'])(
'fails /%s closed before the executor under pi-rpc (execute never called)',
async (command): Promise<void> => {
const commandExecutor = {
execute: vi
.fn()
.mockResolvedValue({ command, conversationId: 'conversation-1', success: true }),
createApproval: vi.fn(),
};
const gateway = buildPiRpcGateway(commandExecutor);
const client = { data: { user: { id: 'admin-1' } }, emit: vi.fn() };
await gateway.handleCommandExecute(client as never, {
command,
conversationId: 'conversation-1',
});
expect(commandExecutor.execute).toHaveBeenCalledTimes(0);
expect(client.emit).toHaveBeenCalledWith('command:result', {
command,
conversationId: 'conversation-1',
success: false,
message: UNSUPPORTED,
});
},
);
it('passes the audited /reload system command through as a positive control under pi-rpc', async (): Promise<void> => {
const reloadResult = { command: 'reload', conversationId: 'conversation-1', success: true };
const commandExecutor = {
execute: vi.fn().mockResolvedValue(reloadResult),
createApproval: vi.fn(),
};
const gateway = buildPiRpcGateway(commandExecutor);
const client = { data: { user: { id: 'admin-1' } }, emit: vi.fn() };
await gateway.handleCommandExecute(client as never, {
command: 'reload',
conversationId: 'conversation-1',
});
expect(commandExecutor.execute).toHaveBeenCalledTimes(1);
expect(commandExecutor.execute).toHaveBeenCalledWith(
{ command: 'reload', conversationId: 'conversation-1' },
{ userId: 'admin-1', tenantId: 'admin-1' },
);
expect(client.emit).toHaveBeenCalledWith('command:result', reloadResult);
});
it('keeps command approval runtime-independent under pi-rpc (createApproval still runs)', async (): Promise<void> => {
const commandExecutor = {
execute: vi.fn(),
createApproval: vi.fn().mockResolvedValue({
approvalId: 'approval-1',
expiresAt: '2026-07-12T00:05:00.000Z',
}),
};
const gateway = buildPiRpcGateway(commandExecutor);
const client = { data: { user: { id: 'admin-1' } }, emit: vi.fn() };
await gateway.handleCommandApproval(client as never, {
command: 'gc',
conversationId: 'conversation-1',
});
expect(commandExecutor.createApproval).toHaveBeenCalledWith(
{ command: 'gc', conversationId: 'conversation-1' },
{ userId: 'admin-1', tenantId: 'admin-1' },
);
expect(client.emit).toHaveBeenCalledWith(
'command:approval',
expect.objectContaining({ success: true, approvalId: 'approval-1' }),
);
});
});
Binary file not shown.
File diff suppressed because it is too large Load Diff
+3 -50
View File
@@ -1,59 +1,12 @@
import { forwardRef, Module } from '@nestjs/common';
import { CommandsModule } from '../commands/commands.module.js';
import { HarnessModule } from '../harness/harness.module.js';
import { HarnessRegistry } from '../harness/harness.registry.js';
import {
HARNESS_CONVERSATION_SERVICE,
HARNESS_REGISTRY,
type HarnessConversationServiceBinding,
} from '../harness/harness.tokens.js';
import type { HarnessConversationService } from '@mosaicstack/types';
import { ChatGateway } from './chat.gateway.js';
import { ChatController } from './chat.controller.js';
import { ChatRuntimeRouter } from './chat-runtime-router.js';
import { EmbeddedChatRuntime } from './embedded-chat.runtime.js';
import { HarnessChatRuntime } from './harness-chat.runtime.js';
/**
* Task Five wiring. The exclusive {@link ChatRuntimeRouter} is the single chat-execution
* authority: the controller and gateway inject only the router, never `AgentService`,
* `RoutingEngineService`, or a session/`piSession` handle. The router resolves exactly one
* runtime at module init — {@link EmbeddedChatRuntime} in legacy mode, {@link HarnessChatRuntime}
* in `pi-rpc` — over the REAL {@link HarnessModule} registry and conversation-service binding.
*
* The router and the harness runtime are constructed through factories because their
* dependencies are interface/union types with no runtime injection token (the registry and
* conversation-service arrive via the string tokens exported by `HarnessModule`); the embedded
* runtime injects the class-typed `AgentService` and is provided directly.
*/
@Module({
imports: [forwardRef(() => CommandsModule), HarnessModule],
imports: [forwardRef(() => CommandsModule)],
controllers: [ChatController],
providers: [
ChatGateway,
EmbeddedChatRuntime,
{
provide: HarnessChatRuntime,
useFactory: (conversationService: HarnessConversationServiceBinding) =>
new HarnessChatRuntime(conversationService as HarnessConversationService),
inject: [HARNESS_CONVERSATION_SERVICE],
},
{
provide: ChatRuntimeRouter,
useFactory: (
registry: HarnessRegistry,
conversationService: HarnessConversationServiceBinding,
embedded: EmbeddedChatRuntime,
harness: HarnessChatRuntime,
) => new ChatRuntimeRouter(registry, conversationService, embedded, harness),
inject: [
HARNESS_REGISTRY,
HARNESS_CONVERSATION_SERVICE,
EmbeddedChatRuntime,
HarnessChatRuntime,
],
},
],
exports: [ChatGateway, ChatRuntimeRouter],
providers: [ChatGateway],
exports: [ChatGateway],
})
export class ChatModule {}
@@ -1,532 +0,0 @@
import { ForbiddenException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import type { AgentSessionEvent } from '@mariozechner/pi-coding-agent';
import { AgentService, type AgentSession } from '../agent/agent.service.js';
import type { ActorTenantScope } from '../auth/session-scope.js';
import type {
ChatRuntime,
LegacyBrowserMessagePayload,
LegacyEmbeddedChatPort,
LegacyRuntimeEvent,
LegacyRuntimeResult,
LegacySessionPresentation,
LegacySocketTurnLease,
LegacyUsage,
OwnedConversationContext,
VerifiedDiscordIngressContext,
VerifiedDiscordTurnLease,
LegacyRuntimeStream,
} from './chat-runtime.js';
/** Fixed timeout for a synchronous REST turn, matching the historical controller budget. */
const REST_TURN_TIMEOUT_MS = 120_000;
/**
* The `legacy` chat runtime and the sole implementation of {@link LegacyEmbeddedChatPort}.
*
* It owns the embedded in-process execution path — the `AgentService` stack that the
* `ChatController` and `ChatGateway` drove directly before Task Five. Once the
* {@link import('./chat-runtime-router.js').ChatRuntimeRouter} fronts it, the browser
* HTTP/WebSocket legacy path and verified-Discord ingress route through THIS runtime, so
* neither the controller nor the gateway retains `AgentService`, `piSession`, session,
* listener, channel, or metric access. Ownership (`userId`/`tenantId`) is re-checked by
* `AgentService` on every operation; a missing, foreign, or no-longer-owned conversation
* collapses to `conversation_unavailable` and never throws out of the port.
*/
@Injectable()
export class EmbeddedChatRuntime implements ChatRuntime, LegacyEmbeddedChatPort {
readonly kind = 'embedded' as const;
private readonly logger = new Logger(EmbeddedChatRuntime.name);
constructor(readonly agentService: AgentService) {}
// -------------------------------------------------------------------------
// Legacy REST completion (op A)
// -------------------------------------------------------------------------
async completeLegacyRestTurn(
context: OwnedConversationContext,
input: Readonly<{ content: string }>,
): Promise<
LegacyRuntimeResult<Readonly<{ text: string; presentation: LegacySessionPresentation }>>
> {
const scope = toScope(context.scope);
const { conversationId } = context;
const resolved = await this.resolveOrCreate(conversationId, scope, {});
if (!resolved.ok) return resolved;
let responseText = '';
let timer: ReturnType<typeof setTimeout> | undefined;
let detach: (() => void) | undefined;
let disposed = false;
// One idempotent teardown owned OUTSIDE the completion promise: it clears the timeout and
// detaches the event listener exactly once, whichever of agent_end, timeout, or a prompt
// rejection fires first. Without this, a prompt() rejection surfaced through the catch below
// would return while leaving the listener attached (free to consume a later turn's events) and
// the 120s timer live (its rejection later going unobserved).
const dispose = (): void => {
if (disposed) return;
disposed = true;
if (timer !== undefined) clearTimeout(timer);
detach?.();
};
const done = new Promise<void>((resolve, reject) => {
timer = setTimeout(() => {
dispose();
reject(new Error('Agent response timed out'));
}, REST_TURN_TIMEOUT_MS);
detach = this.agentService.onEvent(
conversationId,
(event: AgentSessionEvent) => {
if (
event.type === 'message_update' &&
event.assistantMessageEvent.type === 'text_delta'
) {
responseText += event.assistantMessageEvent.delta;
}
if (event.type === 'agent_end') {
dispose();
resolve();
}
},
scope,
);
});
// Attach the prompt and the completion promise CONCURRENTLY. Awaiting prompt() first left the
// timeout unobservable until prompt settled (a hung prompt could never time out) and, worse,
// let the 120s timer reject `done` while nothing yet awaited it — a transient unhandledRejection
// window. Promise.all installs handlers on BOTH synchronously, so the timeout bounds the whole
// turn even while prompt is pending, and neither promise can reject unobserved. Success still
// requires both prompt() to resolve AND agent_end to arrive (identical to the prior sequential
// await). The idempotent dispose() clears the timer + detaches on whichever settles first.
const prompting = this.agentService.prompt(conversationId, input.content, scope);
try {
await Promise.all([prompting, done]);
} catch (err) {
dispose();
const message = err instanceof Error ? err.message : String(err);
if (message.includes('timed out')) {
return { ok: false, code: 'timeout', retryable: true };
}
this.logger.error(`Legacy REST turn failed for conversation=${conversationId}`, message);
return { ok: false, code: 'operation_failed', retryable: false };
}
const presentation = this.presentationFor(conversationId, scope) ?? resolved.presentation;
return { ok: true, value: { text: responseText, presentation } };
}
// -------------------------------------------------------------------------
// Legacy Socket streaming (op B)
// -------------------------------------------------------------------------
async prepareLegacySocketTurn(
context: OwnedConversationContext,
input: LegacyBrowserMessagePayload,
stream: LegacyRuntimeStream,
): Promise<LegacyRuntimeResult<LegacySocketTurnLease>> {
const scope = toScope(context.scope);
const { conversationId } = context;
const resolved = await this.resolveOrCreate(conversationId, scope, {
...(input.provider ? { provider: input.provider } : {}),
...(input.modelId ? { modelId: input.modelId } : {}),
...(input.agentId ? { agentConfigId: input.agentId } : {}),
});
if (!resolved.ok) return resolved;
let detach: () => void;
try {
detach = this.subscribe(conversationId, scope, stream);
} catch (err) {
// A partial listener/channel setup rolled itself back inside subscribe(); surface a total
// safe failure instead of throwing out of the port. Retryable — the attach is transient.
this.logger.error(
`Embedded socket subscription failed for conversation=${conversationId}`,
err instanceof Error ? err.message : String(err),
);
return { ok: false, code: 'runtime_unavailable', retryable: true };
}
return {
ok: true,
value: this.buildLease(
conversationId,
scope,
input.content,
input.attachments,
detach,
resolved.presentation,
),
};
}
// -------------------------------------------------------------------------
// Thinking level (op C) — synchronous, total
// -------------------------------------------------------------------------
setLegacyThinking(
context: OwnedConversationContext,
level: string,
): LegacyRuntimeResult<LegacySessionPresentation> {
const scope = toScope(context.scope);
const session = this.agentService.getSession(context.conversationId, scope);
if (!session) return CONVERSATION_UNAVAILABLE;
const availableThinkingLevels = session.piSession.getAvailableThinkingLevels();
if (!(availableThinkingLevels as readonly string[]).includes(level)) {
return {
ok: false,
code: 'thinking_level_invalid',
retryable: false,
availableThinkingLevels,
};
}
session.piSession.setThinkingLevel(level as never);
return { ok: true, value: this.presentationForSession(session) };
}
// -------------------------------------------------------------------------
// Abort (op D)
// -------------------------------------------------------------------------
async abortLegacyTurn(context: OwnedConversationContext): Promise<LegacyRuntimeResult<void>> {
const scope = toScope(context.scope);
const session = this.agentService.getSession(context.conversationId, scope);
if (!session) return CONVERSATION_UNAVAILABLE;
try {
await session.piSession.abort();
} catch (err) {
this.logger.error(
`Legacy abort failed for conversation=${context.conversationId}`,
err instanceof Error ? err.message : String(err),
);
return { ok: false, code: 'operation_failed', retryable: false };
}
return { ok: true, value: undefined };
}
// -------------------------------------------------------------------------
// Model override (synchronous, total)
// -------------------------------------------------------------------------
applyLegacyModelOverride(
context: OwnedConversationContext,
modelId: string,
): LegacyRuntimeResult<LegacySessionPresentation> {
const scope = toScope(context.scope);
const session = this.agentService.getSession(context.conversationId, scope);
if (!session) return CONVERSATION_UNAVAILABLE;
this.agentService.updateSessionModel(context.conversationId, modelId, scope);
const refreshed = this.agentService.getSession(context.conversationId, scope) ?? session;
return { ok: true, value: this.presentationForSession(refreshed) };
}
// -------------------------------------------------------------------------
// Presentation read (synchronous, total)
// -------------------------------------------------------------------------
readLegacySessionPresentation(
context: OwnedConversationContext,
): LegacyRuntimeResult<LegacySessionPresentation> {
const scope = toScope(context.scope);
const session = this.agentService.getSession(context.conversationId, scope);
if (!session) return CONVERSATION_UNAVAILABLE;
return { ok: true, value: this.presentationForSession(session) };
}
// -------------------------------------------------------------------------
// Verified Discord ingress (embedded-only in both modes)
// -------------------------------------------------------------------------
async dispatchVerifiedDiscordIngress(
context: VerifiedDiscordIngressContext,
stream: LegacyRuntimeStream,
): Promise<LegacyRuntimeResult<VerifiedDiscordTurnLease>> {
const scope = toScope(context.scope);
const { conversationId } = context;
const resolved = await this.resolveOrCreate(
conversationId,
scope,
{ agentConfigId: context.configuredAgent.agentConfigId },
{
agentConfigId: context.configuredAgent.agentConfigId,
instanceId: context.configuredAgent.instanceId,
},
);
if (!resolved.ok) return resolved;
let detach: () => void;
try {
detach = this.subscribe(conversationId, scope, stream);
} catch (err) {
// A partial listener/channel setup rolled itself back inside subscribe(); surface a total
// safe failure instead of throwing out of the port. Retryable — the attach is transient.
this.logger.error(
`Embedded Discord subscription failed for conversation=${conversationId}`,
err instanceof Error ? err.message : String(err),
);
return { ok: false, code: 'runtime_unavailable', retryable: true };
}
return {
ok: true,
value: this.buildLease(
conversationId,
scope,
context.content,
context.attachments,
detach,
resolved.presentation,
),
};
}
// -------------------------------------------------------------------------
// Shared helpers
// -------------------------------------------------------------------------
/**
* Resolves the owned session, creating it on first use. Ownership/scope rejections
* (`Forbidden`/`NotFound`) collapse to `conversation_unavailable`; any other creation
* failure surfaces as the retryable `runtime_unavailable`. On success returns the
* session presentation so callers avoid a redundant `getSession`.
*/
private async resolveOrCreate(
conversationId: string,
scope: ActorTenantScope,
extraOptions: Readonly<{ provider?: string; modelId?: string; agentConfigId?: string }>,
expectedAgent?: Readonly<{ agentConfigId: string; instanceId: string }>,
): Promise<
| { readonly ok: true; readonly presentation: LegacySessionPresentation }
| Exclude<LegacyRuntimeResult<never>, { ok: true }>
> {
// A verified-Discord turn may only run under a session whose configured identity matches the
// reconciled agent record EXACTLY (config id + resolved name). This holds for BOTH a reused
// pre-existing session AND a freshly created one: a session carrying a different configured
// agent — however it arose — is rejected rather than executed under the verified label, so we
// never silently run a different prompt/model/tool policy. A plain (non-verified) turn passes
// no expectedAgent and skips the check.
const identityMatches = (candidate: AgentSession): boolean =>
expectedAgent === undefined ||
(candidate.agentConfigId === expectedAgent.agentConfigId &&
candidate.agentName === expectedAgent.instanceId);
let session = this.agentService.getSession(conversationId, scope);
if (session && !identityMatches(session)) {
// Reused same-scope session minted under a different configured identity — reject with zero
// effects rather than dispatch a verified turn onto a foreign agent's session.
return CONVERSATION_UNAVAILABLE;
}
if (!session) {
try {
session = await this.agentService.createSession(conversationId, {
userId: scope.userId,
tenantId: scope.tenantId,
...extraOptions,
});
} catch (err) {
if (err instanceof ForbiddenException || err instanceof NotFoundException) {
return CONVERSATION_UNAVAILABLE;
}
this.logger.error(
`Embedded session creation failed for conversation=${conversationId}`,
err instanceof Error ? err.stack : String(err),
);
return { ok: false, code: 'runtime_unavailable', retryable: true };
}
// The just-created session must ALSO carry the reconciled identity before any effect. A
// createSession that returns a session under a different configured agent (misconfiguration
// or a substituted factory) is rejected here, before subscribe/persist/ack/prompt.
if (!identityMatches(session)) {
return CONVERSATION_UNAVAILABLE;
}
}
return { ok: true, presentation: this.presentationForSession(session) };
}
/** Installs a normalizing event listener that forwards to the server-owned stream. */
private subscribe(
conversationId: string,
scope: ActorTenantScope,
stream: LegacyRuntimeStream,
): () => void {
const unsubscribe = this.agentService.onEvent(
conversationId,
(event: AgentSessionEvent) => {
const normalized = this.normalizeEvent(conversationId, scope, event);
if (normalized) stream.onEvent(normalized);
},
scope,
);
try {
this.agentService.addChannel(conversationId, stream.channelId, scope);
} catch (err) {
// Partial setup: the listener was acquired but the channel attach failed. Roll back
// exactly what was acquired (the listener) before the failure escapes, so no leaked
// subscription survives; the caller converts the rethrow into a total safe failure.
try {
unsubscribe();
} catch {
/* idempotent teardown */
}
throw err;
}
return () => {
try {
unsubscribe();
} catch {
/* idempotent teardown */
}
try {
this.agentService.removeChannel(conversationId, stream.channelId, scope);
} catch {
/* idempotent teardown */
}
};
}
/** Builds an atomically one-shot, scope-rechecking dispatch lease. */
private buildLease(
conversationId: string,
scope: ActorTenantScope,
content: string,
attachments: VerifiedDiscordIngressContext['attachments'],
detach: () => void,
presentation: LegacySessionPresentation,
): LegacySocketTurnLease & VerifiedDiscordTurnLease {
let dispatched = false;
let disposed = false;
return {
presentation,
dispatch: async (): Promise<LegacyRuntimeResult<void>> => {
if (dispatched) {
return { ok: false, code: 'turn_already_dispatched', retryable: false };
}
dispatched = true;
try {
await this.agentService.prompt(conversationId, content, scope, attachments);
} catch (err) {
this.logger.error(
`Legacy dispatch failed for conversation=${conversationId}`,
err instanceof Error ? err.message : String(err),
);
return { ok: false, code: 'operation_failed', retryable: false };
}
return { ok: true, value: undefined };
},
dispose: async (): Promise<void> => {
if (disposed) return;
disposed = true;
detach();
},
};
}
/** Normalizes a raw agent event into the redaction-agnostic transport event, or drops it. */
private normalizeEvent(
conversationId: string,
scope: ActorTenantScope,
event: AgentSessionEvent,
): LegacyRuntimeEvent | undefined {
switch (event.type) {
case 'agent_start':
return { type: 'started' };
case 'agent_end':
return { type: 'settled', ...this.usageFor(conversationId, scope) };
case 'message_update': {
const assistant = event.assistantMessageEvent;
if (assistant.type === 'text_delta') return { type: 'text_delta', text: assistant.delta };
if (assistant.type === 'thinking_delta') {
return { type: 'thinking_delta', text: assistant.delta };
}
return undefined;
}
case 'tool_execution_start':
return { type: 'tool_started', toolCallId: event.toolCallId, toolName: event.toolName };
case 'tool_execution_end':
return {
type: 'tool_finished',
toolCallId: event.toolCallId,
toolName: event.toolName,
isError: event.isError,
};
default:
return undefined;
}
}
/**
* Gathers terminal usage from the Pi session and records it into session metrics.
* Embedded owns AgentService metrics; the gateway never touches `piSession` stats.
*/
private usageFor(conversationId: string, scope: ActorTenantScope): { usage?: LegacyUsage } {
const session = this.agentService.getSession(conversationId, scope);
const piSession = session?.piSession;
const stats = piSession?.getSessionStats();
if (!session || !stats) return {};
const contextUsage = piSession?.getContextUsage();
const tokens = {
input: stats.tokens?.input ?? 0,
output: stats.tokens?.output ?? 0,
cacheRead: stats.tokens?.cacheRead ?? 0,
cacheWrite: stats.tokens?.cacheWrite ?? 0,
total: stats.tokens?.total ?? 0,
};
this.agentService.recordTokenUsage(conversationId, { ...tokens });
return {
usage: {
provider: session.provider,
modelId: session.modelId,
thinkingLevel: piSession?.thinkingLevel ?? 'off',
tokens,
cost: stats.cost ?? 0,
context: {
percent: contextUsage?.percent ?? null,
window: contextUsage?.contextWindow ?? 0,
},
},
};
}
/** Presentation from a live session id, or undefined when no owned session exists. */
private presentationFor(
conversationId: string,
scope: ActorTenantScope,
): LegacySessionPresentation | undefined {
const session = this.agentService.getSession(conversationId, scope);
return session ? this.presentationForSession(session) : undefined;
}
/** User-facing projection carrying no session handle, credential, or raw stats. */
private presentationForSession(session: AgentSession): LegacySessionPresentation {
return {
provider: session.provider,
modelId: session.modelId,
thinkingLevel: session.piSession.thinkingLevel,
availableThinkingLevels: session.piSession.getAvailableThinkingLevels(),
...(session.agentName ? { agentName: session.agentName } : {}),
};
}
}
/** The shared terminal `conversation_unavailable` failure (missing/foreign/lost ownership). */
const CONVERSATION_UNAVAILABLE = {
ok: false as const,
code: 'conversation_unavailable' as const,
retryable: false as const,
};
/** Narrows a branded context scope to the `AgentService` actor/tenant scope (identical shape). */
function toScope(scope: Readonly<{ userId: string; tenantId: string }>): ActorTenantScope {
return { userId: scope.userId, tenantId: scope.tenantId };
}
@@ -1,170 +0,0 @@
import { describe, expect, it } from 'vitest';
import type {
AttachConversation,
ConversationSnapshot,
DetachConversation,
HarnessActorContext,
HarnessConversationService,
HarnessEventEnvelope,
HarnessSelection,
SendHarnessTurn,
TurnReceipt,
} from '@mosaicstack/types';
import { HarnessChatRuntime } from './harness-chat.runtime.js';
/**
* Task Five, Step One (harness runtime). Proves the `pi-rpc` runtime executes
* exclusively through the {@link HarnessConversationService} RPC boundary and
* forwards the caller's exact selection tuple and idempotency key without
* substitution. Red-first: the runtime is an unimplemented stub, so every
* delegation assertion fails until Step Three.
*/
const context: HarnessActorContext = {
actorId: 'actor-1',
tenantId: 'tenant-1',
seatId: 'seat-1',
correlationId: 'corr-1',
};
const selection: HarnessSelection = {
harnessId: 'pi',
providerId: 'anthropic',
modelId: 'claude-opus-4-8',
};
const conversationId = '11111111-1111-4111-8111-111111111111';
const idempotencyKey = '22222222-2222-4222-8222-222222222222';
const sendInput: SendHarnessTurn & { idempotencyKey: string } = {
context,
conversationId,
selection,
turnId: 'turn-abc',
correlationId: 'corr-1',
content: 'hello',
idempotencyKey,
};
const attachInput: AttachConversation & { afterSequence?: number } = {
context,
conversationId,
clientId: 'client-1',
selection,
afterSequence: 0,
};
const detachInput: DetachConversation = {
context,
conversationId,
clientId: 'client-1',
};
interface RecordedCalls {
attach: (AttachConversation & { afterSequence?: number })[];
detach: DetachConversation[];
send: (SendHarnessTurn & { idempotencyKey: string })[];
subscribeFrom: { conversationId: string; afterSequence: number }[];
}
const snapshot: ConversationSnapshot = {
session: {
conversationId,
nativeSessionId: 'native-1',
seatId: 'seat-1',
selection,
state: 'idle',
attachedClientIds: ['client-1'],
},
lastSequence: 0,
replay: [],
};
function build(): { runtime: HarnessChatRuntime; calls: RecordedCalls } {
const calls: RecordedCalls = { attach: [], detach: [], send: [], subscribeFrom: [] };
const service: HarnessConversationService = {
attach: (input) => {
calls.attach.push(input);
return Promise.resolve(snapshot);
},
detach: (input) => {
calls.detach.push(input);
return Promise.resolve();
},
send: (input) => {
calls.send.push(input);
// The service echoes only the requested tuple; there is no representable substitute.
const receipt: TurnReceipt = {
conversationId: input.conversationId,
turnId: 'turn-server',
correlationId: input.correlationId,
state: 'accepted',
selection: input.selection,
};
return Promise.resolve(receipt);
},
subscribeFrom: (id, afterSequence) => {
calls.subscribeFrom.push({ conversationId: id, afterSequence });
return (async function* (): AsyncIterable<HarnessEventEnvelope> {
return;
})();
},
};
return { runtime: new HarnessChatRuntime(service), calls };
}
describe('HarnessChatRuntime', () => {
it('is the harness runtime kind and needs only a HarnessConversationService', () => {
const { runtime } = build();
expect(runtime.kind).toBe('harness');
});
it('delegates send to the conversation service with the exact tuple and idempotency key', async () => {
const { runtime, calls } = build();
const receipt = await runtime.send(sendInput);
expect(calls.send).toHaveLength(1);
const firstSend = calls.send[0]!;
expect(firstSend).toEqual(sendInput);
expect(firstSend.idempotencyKey).toBe(idempotencyKey);
expect(firstSend.selection).toEqual(selection);
// The runtime must not substitute an effective tuple onto the receipt.
expect(receipt.selection).toEqual(selection);
});
it('delegates attach to the conversation service and returns its snapshot', async () => {
const { runtime, calls } = build();
const result = await runtime.attach(attachInput);
expect(calls.attach).toHaveLength(1);
expect(calls.attach[0]).toEqual(attachInput);
expect(result).toBe(snapshot);
});
it('delegates detach to the conversation service', async () => {
const { runtime, calls } = build();
await runtime.detach(detachInput);
expect(calls.detach).toHaveLength(1);
expect(calls.detach[0]).toEqual(detachInput);
});
it('delegates subscribeFrom to the conversation service journal replay', async () => {
const { runtime, calls } = build();
const iterable = runtime.subscribeFrom(conversationId, 7);
// Drain to prove it is the service-backed async iterable, not a fabricated one.
const drained: unknown[] = [];
for await (const event of iterable) {
drained.push(event);
}
expect(drained).toHaveLength(0);
expect(calls.subscribeFrom).toHaveLength(1);
expect(calls.subscribeFrom[0]).toEqual({ conversationId, afterSequence: 7 });
});
});
@@ -1,47 +0,0 @@
import type {
AttachConversation,
ConversationSnapshot,
DetachConversation,
HarnessConversationService,
HarnessEventEnvelope,
SendHarnessTurn,
TurnReceipt,
} from '@mosaicstack/types';
import type { ChatRuntime } from './chat-runtime.js';
/**
* The `pi-rpc` chat runtime. It executes browser chat exclusively through the
* harness-neutral {@link HarnessConversationService} RPC boundary — it never
* touches the embedded `AgentService`/`ProviderService`/`RoutingEngineService`
* stack, and it forwards the caller's exact selection tuple and idempotency key
* without substitution.
*
* It owns no state and adds no policy: every method forwards the caller's exact
* argument to the injected {@link HarnessConversationService} and returns its
* result unchanged, so the requested selection tuple and idempotency key can
* never be substituted on the way through.
*/
export class HarnessChatRuntime implements ChatRuntime {
readonly kind = 'harness' as const;
constructor(private readonly conversations: HarnessConversationService) {}
attach(input: AttachConversation & { afterSequence?: number }): Promise<ConversationSnapshot> {
return this.conversations.attach(input);
}
detach(input: DetachConversation): Promise<void> {
return this.conversations.detach(input);
}
send(input: SendHarnessTurn & { idempotencyKey: string }): Promise<TurnReceipt> {
return this.conversations.send(input);
}
subscribeFrom(
conversationId: string,
afterSequence: number,
): AsyncIterable<HarnessEventEnvelope> {
return this.conversations.subscribeFrom(conversationId, afterSequence);
}
}
@@ -61,33 +61,6 @@ describe('CommandAuthorizationService', () => {
).toBe(false);
});
it('denies non-admin scopes to a platform admin (contract 2 §1.1 bypass retirement)', async (): Promise<void> => {
const service = createService('admin');
for (const scope of ['core', 'agent', 'skill', 'plugin'] as const) {
const command: CommandDef = { ...adminCommand, name: `probe-${scope}`, scope };
expect(
(await service.authorize(command, { ...payload, command: command.name }, 'admin-1'))
.allowed,
).toBe(false);
}
});
it('allows member core/agent scopes and denies skill/plugin (deny-by-default)', async (): Promise<void> => {
const service = createService('member');
for (const [scope, allowed] of [
['core', true],
['agent', true],
['skill', false],
['plugin', false],
] as const) {
const command: CommandDef = { ...adminCommand, name: `probe-${scope}`, scope };
expect(
(await service.authorize(command, { ...payload, command: command.name }, 'member-1'))
.allowed,
).toBe(allowed);
}
});
it('denies a malformed durable approval expiry instead of treating it as unexpired', async (): Promise<void> => {
const entries = new Map<string, string>();
const action = {
@@ -154,15 +154,8 @@ export class CommandAuthorizationService {
return role === 'admin' || role === 'member' || role === 'viewer' ? role : null;
}
/**
* Contract 2 §1.1: platform admin confers instance administration only —
* the former admin-passes-every-scope short-circuit is retired. Admin
* reaches exactly the admin scope; core/agent scopes belong to the member
* role; skill/plugin scopes stay deny-for-all until a grant mapping names
* them (§3.1 deny-by-default).
*/
private hasScope(role: CommandRole, scope: CommandDef['scope']): boolean {
if (scope === 'admin') return role === 'admin';
if (role === 'admin') return true;
return role === 'member' && (scope === 'core' || scope === 'agent');
}
@@ -80,10 +80,6 @@ const mockMcpClient = {
getToolDefinitions: vi.fn(() => []),
};
const allowAuthorization = {
authorize: vi.fn().mockResolvedValue({ allowed: true }),
};
function buildService(
redis: typeof mockRedis | null = mockRedis,
mcpClient: {
@@ -102,7 +98,6 @@ function buildService(
null,
mockChatGateway as never,
mcpClient as never,
allowAuthorization as never,
);
}
@@ -35,8 +35,9 @@ export class CommandExecutorService {
@Inject(forwardRef(() => ChatGateway))
private readonly chatGateway: ChatGateway | null,
@Inject(McpClientService) private readonly mcpClient: McpClientService,
@Optional()
@Inject(CommandAuthorizationService)
private readonly authorization: CommandAuthorizationService,
private readonly authorization: CommandAuthorizationService | null = null,
) {}
async execute(
@@ -56,13 +57,13 @@ export class CommandExecutorService {
};
}
const authorization = await this.authorization.authorize(
const authorization = await this.authorization?.authorize(
def,
payload,
userId,
payload.approvalId,
);
if (!authorization.allowed) {
if (authorization && !authorization.allowed) {
return { command, conversationId, success: false, message: authorization.reason };
}
@@ -170,7 +171,7 @@ export class CommandExecutorService {
const def = this.registry
.getManifest()
.commands.find((command) => command.name === payload.command);
if (!def) return null;
if (!def || !this.authorization) return null;
return this.authorization.createApproval(def, payload, scope.userId);
}
@@ -55,10 +55,6 @@ const mockMcpClient = {
reconnectServer: vi.fn().mockResolvedValue(undefined),
};
const allowAuthorization = {
authorize: vi.fn().mockResolvedValue({ allowed: true }),
};
// ─── Helpers ─────────────────────────────────────────────────────────────────
function buildRegistry(): CommandRegistryService {
@@ -78,7 +74,6 @@ function buildExecutor(registry: CommandRegistryService): CommandExecutorService
null, // reloadService (optional)
null, // chatGateway (optional)
mockMcpClient as never,
allowAuthorization as never,
);
}
@@ -1,116 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { ChatRuntimeMode } from '../chat/chat-runtime.js';
import { ConversationsController } from './conversations.controller.js';
/**
* Task 5 harness fence for the conversations REST write path.
*
* Under `pi-rpc` the durable/harness conversation path (Task 15) owns message persistence, so the
* legacy direct-repository write via `POST /api/conversations/:id/messages` must be refused with a
* fixed typed `runtime_unsupported` BEFORE the repository is touched — never a duplicate write.
* Under `legacy` the endpoint keeps its current behaviour and writes through `brain.conversations`.
*
* Item 3 (single runtime-mode source of truth): the mode is the router's ONE init-time resolution,
* injected into the controller and read as `router.runtimeMode`. It is NOT re-derived from
* `process.env` at request time. The two "env is flipped after construction" tests below are the
* load-bearing guard: they pass only because the controller reads the fixed injected mode, and turn
* RED the instant the fence is reverted to `resolveChatRuntimeMode(process.env)`.
*/
const CONVERSATION_ID = '22222222-2222-4222-8222-222222222222';
const USER = { id: 'user-1' };
function sendMessageDto() {
return {
role: 'user' as const,
content: 'hello from the legacy REST write path',
metadata: undefined,
};
}
function brainWithMessageSpy() {
const addMessage = vi.fn().mockResolvedValue({
id: 'message-1',
conversationId: CONVERSATION_ID,
role: 'user',
content: 'hello from the legacy REST write path',
});
return {
brain: { conversations: { addMessage } } as never,
addMessage,
};
}
/** The controller only needs the router's immutable `runtimeMode`; supply exactly that. */
function routerFixedTo(mode: ChatRuntimeMode) {
return { runtimeMode: mode };
}
let priorMode: string | undefined;
describe('conversations REST write path — Task 5 harness fence', () => {
beforeEach(() => {
priorMode = process.env['CHAT_HARNESS_RUNTIME'];
});
afterEach(() => {
if (priorMode === undefined) delete process.env['CHAT_HARNESS_RUNTIME'];
else process.env['CHAT_HARNESS_RUNTIME'] = priorMode;
});
it('refuses the legacy repository write when the router resolved pi-rpc, before any write', async () => {
const { brain, addMessage } = brainWithMessageSpy();
const controller = new ConversationsController(brain, routerFixedTo('pi-rpc'));
await expect(
controller.addMessage(CONVERSATION_ID, sendMessageDto(), USER),
).rejects.toMatchObject({ code: 'runtime_unsupported' });
// Load-bearing: the durable/harness path owns pi-rpc persistence — the legacy repo must not be
// written, so no duplicate message can be produced.
expect(addMessage).not.toHaveBeenCalled();
});
it('writes through the repository when the router resolved legacy (GREEN control)', async () => {
const { brain, addMessage } = brainWithMessageSpy();
const controller = new ConversationsController(brain, routerFixedTo('legacy'));
const result = await controller.addMessage(CONVERSATION_ID, sendMessageDto(), USER);
expect(addMessage).toHaveBeenCalledWith(
{
conversationId: CONVERSATION_ID,
role: 'user',
content: 'hello from the legacy REST write path',
metadata: undefined,
},
USER.id,
);
expect(result).toMatchObject({ id: 'message-1', conversationId: CONVERSATION_ID });
});
it('keeps refusing under a pi-rpc router even when CHAT_HARNESS_RUNTIME is flipped to legacy after startup', async () => {
// The runtime mode is fixed at module init. A later env mutation must not reopen the fence:
// a request-time `resolveChatRuntimeMode(process.env)` read would see `legacy` and wrongly write.
process.env['CHAT_HARNESS_RUNTIME'] = 'legacy';
const { brain, addMessage } = brainWithMessageSpy();
const controller = new ConversationsController(brain, routerFixedTo('pi-rpc'));
await expect(
controller.addMessage(CONVERSATION_ID, sendMessageDto(), USER),
).rejects.toMatchObject({ code: 'runtime_unsupported' });
expect(addMessage).not.toHaveBeenCalled();
});
it('keeps writing under a legacy router even when CHAT_HARNESS_RUNTIME is flipped to pi-rpc after startup', async () => {
// Symmetric guard: a legacy-resolved router must keep writing regardless of the live env, so a
// request-time env read of `pi-rpc` cannot spuriously refuse a legitimate legacy write.
process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc';
const { brain, addMessage } = brainWithMessageSpy();
const controller = new ConversationsController(brain, routerFixedTo('legacy'));
await controller.addMessage(CONVERSATION_ID, sendMessageDto(), USER);
expect(addMessage).toHaveBeenCalledTimes(1);
});
});
@@ -6,7 +6,6 @@ import {
ForbiddenException,
Get,
HttpCode,
HttpException,
HttpStatus,
Inject,
NotFoundException,
@@ -20,7 +19,6 @@ import type { Brain } from '@mosaicstack/brain';
import { BRAIN } from '../brain/brain.tokens.js';
import { AuthGuard } from '../auth/auth.guard.js';
import { CurrentUser } from '../auth/current-user.decorator.js';
import { ChatRuntimeRouter } from '../chat/chat-runtime-router.js';
import {
CreateConversationDto,
UpdateConversationDto,
@@ -28,41 +26,10 @@ import {
SearchMessagesDto,
} from './conversations.dto.js';
/**
* Under `pi-rpc` the durable/harness conversation path (Task 15) owns message persistence, so the
* legacy direct-repository write must fail closed with a fixed typed `runtime_unsupported` before
* the repository is touched — never a duplicate write. The `code` field is exposed at the top level
* so callers can discriminate the refusal while the 503 status carries the browser-safe surface.
*/
class HarnessRuntimeWriteUnsupportedException extends HttpException {
readonly code = 'runtime_unsupported' as const;
constructor() {
super(
{
code: 'runtime_unsupported',
message:
'Conversation message writes are handled by the harness runtime on this deployment.',
},
HttpStatus.SERVICE_UNAVAILABLE,
);
}
}
@Controller('api/conversations')
@UseGuards(AuthGuard)
export class ConversationsController {
/**
* `router` supplies the ONE immutable runtime mode resolved at module init (Task 5, item 3).
* The pre-write fence reads `router.runtimeMode`, never `resolveChatRuntimeMode(process.env)` at
* request time — a single source of truth, so the controller cannot disagree with the router
* about the live runtime if the environment is mutated after startup. Narrowed to `runtimeMode`
* so this class depends on nothing else the router exposes.
*/
constructor(
@Inject(BRAIN) private readonly brain: Brain,
@Inject(ChatRuntimeRouter) private readonly router: Pick<ChatRuntimeRouter, 'runtimeMode'>,
) {}
constructor(@Inject(BRAIN) private readonly brain: Brain) {}
@Get()
async list(@CurrentUser() user: { id: string }) {
@@ -127,13 +94,6 @@ export class ConversationsController {
@Body() dto: SendMessageDto,
@CurrentUser() user: { id: string },
) {
// Fail the legacy repository write closed under pi-rpc BEFORE touching the repository — the
// harness path owns persistence there, so a direct write would duplicate the message. The mode
// comes from the router's init-time resolution, not a request-time env read.
if (this.router.runtimeMode === 'pi-rpc') {
throw new HarnessRuntimeWriteUnsupportedException();
}
const message = await this.brain.conversations.addMessage(
{
conversationId: id,
@@ -1,14 +1,7 @@
import { Module } from '@nestjs/common';
import { ChatModule } from '../chat/chat.module.js';
import { ConversationsController } from './conversations.controller.js';
/**
* Imports {@link ChatModule} solely to inject its exported {@link ChatRuntimeRouter} into
* {@link ConversationsController}, so the REST write fence reads the same init-time runtime mode the
* router resolved — one source of truth, no duplicate provider, no global token, no AppModule edit.
*/
@Module({
imports: [ChatModule],
controllers: [ConversationsController],
})
export class ConversationsModule {}
@@ -1,71 +0,0 @@
import { describe, it, expect, vi } from 'vitest';
// The module under test imports @mosaicstack/db at module scope; we replace only the
// pieces DatabaseModule uses (partial mock — the real module also exports the
// schema the storage adapter's import chain needs) so the test pins the #1392
// contract (refuse to start on an incomplete schema) without a live database.
vi.mock('@mosaicstack/db', async (importOriginal) => {
const actual: object = await importOriginal();
return {
...actual,
createDb: vi.fn(),
createPgliteDb: vi.fn(),
getMigrationStatus: vi.fn(),
runPgliteMigrations: vi.fn(),
};
});
import { DatabaseModule } from './database.module.js';
import { getMigrationStatus } from '@mosaicstack/db';
import type { DbHandle } from '@mosaicstack/db';
import type { StorageAdapter } from '@mosaicstack/storage';
import type { MosaicConfig } from '@mosaicstack/config';
function makeModule(storageType: 'postgres' | 'pglite', tier: string) {
const storageAdapter = {
name: storageType,
migrate: vi.fn(),
close: vi.fn(),
} as unknown as StorageAdapter;
const handle = { close: vi.fn() } as unknown as DbHandle;
const config = {
tier,
storage: { type: storageType, url: 'postgresql://x' },
} as unknown as MosaicConfig;
return {
mod: new DatabaseModule(handle, storageAdapter, config),
storageAdapter,
};
}
describe('DatabaseModule.onModuleInit — #1392 schema verification', () => {
it('refuses to start when the postgres schema is incomplete', async () => {
const { mod, storageAdapter } = makeModule('postgres', 'standalone');
vi.mocked(getMigrationStatus).mockResolvedValue({
appliedCount: 15,
expectedCount: 17,
expectedLastTag: '0016_salty_morlocks',
complete: false,
});
await expect(mod.onModuleInit()).rejects.toThrow('Database schema incomplete: 15/17');
expect(storageAdapter.migrate).toHaveBeenCalled(); // migrations attempted first
});
it('starts normally when the schema is complete', async () => {
const { mod } = makeModule('postgres', 'standalone');
vi.mocked(getMigrationStatus).mockResolvedValue({
appliedCount: 17,
expectedCount: 17,
expectedLastTag: '0016_salty_morlocks',
complete: true,
});
await expect(mod.onModuleInit()).resolves.toBeUndefined();
});
it('does not verify postgres status for the local tier (PGlite migrates itself)', async () => {
const { mod } = makeModule('pglite', 'local');
vi.mocked(getMigrationStatus).mockClear();
await expect(mod.onModuleInit()).resolves.toBeUndefined();
expect(getMigrationStatus).not.toHaveBeenCalled();
});
});
@@ -12,7 +12,6 @@ import {
import {
createDb,
createPgliteDb,
getMigrationStatus,
runPgliteMigrations,
type Db,
type DbHandle,
@@ -75,11 +74,6 @@ export class DatabaseModule implements OnApplicationShutdown, OnModuleInit {
// the same DATABASE_URL, so a single call covers both the gateway DB and
// the storage tables. We deliberately do NOT call runMigrations() here to
// avoid opening a second short-lived connection and doubling startup cost.
//
// #1392: we DO verify afterwards (getMigrationStatus opens one short-lived
// connection) and refuse to start on an incomplete schema. A gateway that
// boots "healthy" on an empty or partial database is precisely the failure
// that shipped in the T63 batch: silent at startup, catastrophic later.
async onModuleInit(): Promise<void> {
if (this.config.tier === 'local') {
this.logger.log('Applying PGlite schema migrations...');
@@ -87,24 +81,6 @@ export class DatabaseModule implements OnApplicationShutdown, OnModuleInit {
}
this.logger.log(`Initializing storage adapter (${this.storageAdapter.name})...`);
await this.storageAdapter.migrate();
if (this.config.storage.type === 'postgres') {
const status = await getMigrationStatus(this.config.storage.url);
if (!status.complete) {
this.logger.error(
`Database schema incomplete: ${status.appliedCount.toString()}/${status.expectedCount.toString()} migrations applied ` +
`(last expected: ${status.expectedLastTag}). ` +
'Refusing to start on a partial schema — see issues #1392/#1402. ' +
"Remediation: re-run 'mosaic gateway install' (it now verifies), or apply migrations manually.",
);
throw new Error(
`Database schema incomplete: ${status.appliedCount.toString()}/${status.expectedCount.toString()} migrations applied`,
);
}
this.logger.log(
`Database schema verified: ${status.appliedCount.toString()}/${status.expectedCount.toString()} migrations applied.`,
);
}
}
async onApplicationShutdown(): Promise<void> {
@@ -1,508 +0,0 @@
import { mkdtemp, rm } from 'node:fs/promises';
import { randomUUID } from 'node:crypto';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { Test, type TestingModule } from '@nestjs/testing';
import { Logger, ValidationPipe, type ExecutionContext } from '@nestjs/common';
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
import supertest from 'supertest';
import { unseal } from '@mosaicstack/auth';
import {
agentAuditEvents,
agentIdempotencyFence,
agentOutbox,
agents,
and,
createPgliteDb,
eq,
providerCredentials,
runPgliteMigrations,
sql,
users,
type DbHandle,
} from '@mosaicstack/db';
import { DB } from '../database/database.module.js';
import { AuthGuard } from '../auth/auth.guard.js';
import { HarnessRegistry } from '../harness/harness.registry.js';
import { HARNESS_REGISTRY } from '../harness/harness.tokens.js';
import { FakeHarnessAdapter } from '../harness/testing/fake-harness.adapter.js';
import { EnrollmentController } from './enrollment.controller.js';
import {
EnrollmentRepository,
type EnrollAgentInput,
type EnrollmentResult,
type EnrolledAgentView,
} from './enrollment.repository.js';
import { EnrollmentService } from './enrollment.service.js';
/**
* Command-level witnesses for the agent enrollment family (M4-4b) — design
* docs/plans/2026-08-29-agent-enrollment-command-design.md §5 items 19 and
* 11 (item 10, CLI parity, lives in packages/mosaic). Schema-level
* constraints are witnessed in packages/db/src/agent-enrollment.witness.test.ts.
*
* The suite runs the REAL repository/service/controller graph over PGlite,
* with only AuthGuard overridden (a session store is out of scope; the
* override binds request.user exactly as the real guard does). The §6.3
* static companions — no `any`-typed boundary pass-through, a single audit
* emitter (EnrollmentRepository.appendEvent) — are code-surface properties
* reviewed on the PR, not runtime probes.
*/
describe('enrollment commands integration', (): void => {
let dataDir: string;
let handle: DbHandle;
let moduleRef: TestingModule;
let app: NestFastifyApplication;
let http: ReturnType<typeof supertest>;
let repo: EnrollmentRepository;
let previousAuthSecret: string | undefined;
const OWNER = 'enr-owner';
const ADMIN = 'enr-admin';
const STRANGER = 'enr-stranger';
const HARNESS = 'fake-harness';
/** Never-echo probe value (§5.1). Unique enough that any leak is unambiguous. */
const SECRET = `enr-secret-value-${randomUUID()}`;
/** The HTTP-leg acting user; the overridden guard binds it per request. */
let currentUserId = OWNER;
const enrollInput = (overrides: Partial<EnrollAgentInput> = {}): EnrollAgentInput => ({
actorId: OWNER,
harness: HARNESS,
name: `Agent ${randomUUID().slice(0, 8)}`,
persona: null,
model: 'anthropic/claude-test',
provider: `prov-${randomUUID().slice(0, 8)}`,
credential: { mode: 'intake', type: 'api_key', value: SECRET },
idempotencyKey: randomUUID(),
...overrides,
});
function expectOk<T>(result: EnrollmentResult<T>): { ok: true; correlationId: string } & T {
if (!result.ok) throw new Error(`expected ok, got ${JSON.stringify(result)}`);
return result;
}
function expectFail<T>(
result: EnrollmentResult<T>,
error: string,
): { ok: false; error: string; message: string; correlationId: string } {
if (result.ok) throw new Error(`expected ${error}, got ok`);
expect(result.error).toBe(error);
return result;
}
const fenceForKey = (key: string) =>
handle.db
.select()
.from(agentIdempotencyFence)
.where(eq(agentIdempotencyFence.idempotencyKey, key));
const eventsForAgent = (agentId: string) =>
handle.db.select().from(agentAuditEvents).where(eq(agentAuditEvents.agentId, agentId));
const agentsNamed = (name: string) =>
handle.db.select().from(agents).where(eq(agents.name, name));
const credentialsFor = (userId: string, provider: string) =>
handle.db
.select()
.from(providerCredentials)
.where(
and(eq(providerCredentials.userId, userId), eq(providerCredentials.provider, provider)),
);
const allOutbox = () => handle.db.select().from(agentOutbox);
beforeAll(async (): Promise<void> => {
previousAuthSecret = process.env['BETTER_AUTH_SECRET'];
process.env['BETTER_AUTH_SECRET'] = 'enrollment-witness-sealing-key';
dataDir = await mkdtemp(join(tmpdir(), 'mosaic-gateway-enrollment-commands-'));
handle = createPgliteDb(dataDir);
await runPgliteMigrations(handle);
const registry = new HarnessRegistry();
registry.register(new FakeHarnessAdapter({ id: HARNESS }));
moduleRef = await Test.createTestingModule({
controllers: [EnrollmentController],
providers: [
EnrollmentRepository,
EnrollmentService,
{ provide: DB, useValue: handle.db },
{ provide: HARNESS_REGISTRY, useValue: registry },
],
})
.overrideGuard(AuthGuard)
.useValue({
canActivate: (ctx: ExecutionContext): boolean => {
const request = ctx.switchToHttp().getRequest<{ user?: unknown }>();
request.user = { id: currentUserId };
return true;
},
})
.compile();
app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
// Mirror main.ts exactly — the closure witnesses depend on these options.
app.useGlobalPipes(
new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }),
);
await app.init();
await app.getHttpAdapter().getInstance().ready();
http = supertest(app.getHttpServer());
repo = moduleRef.get(EnrollmentRepository);
await handle.db.insert(users).values([
{ id: OWNER, name: 'Owner', email: `${OWNER}@example.com` },
{ id: ADMIN, name: 'Admin', email: `${ADMIN}@example.com`, role: 'admin' },
{ id: STRANGER, name: 'Stranger', email: `${STRANGER}@example.com` },
]);
});
afterAll(async (): Promise<void> => {
await app?.close();
await handle.close();
await rm(dataDir, { recursive: true, force: true });
if (previousAuthSecret === undefined) delete process.env['BETTER_AUTH_SECRET'];
else process.env['BETTER_AUTH_SECRET'] = previousAuthSecret;
});
// ── §5.7 wizard-facing zero-mutation (runs FIRST: no call → zero rows) ────
it('zero-mutation: with no enrollment invocation the family tables hold zero rows', async () => {
expect(await handle.db.select().from(agents)).toHaveLength(0);
expect(await handle.db.select().from(agentAuditEvents)).toHaveLength(0);
expect(await handle.db.select().from(agentOutbox)).toHaveLength(0);
expect(await handle.db.select().from(agentIdempotencyFence)).toHaveLength(0);
});
// ── §5.1 never-echo + §5.2 sealed single-copy ─────────────────────────────
it('never echoes the intake credential value: HTTP result, audit, outbox, fence, and logs are clean', async () => {
const logSink: string[] = [];
const logSpies = (['log', 'error', 'warn', 'debug', 'verbose'] as const).map((method) =>
vi.spyOn(Logger.prototype, method).mockImplementation((...args: unknown[]) => {
logSink.push(args.map(String).join(' '));
}),
);
try {
currentUserId = OWNER;
const provider = `prov-echo-${randomUUID().slice(0, 8)}`;
const res = await http.post('/api/enrollment/agents').send({
harness: HARNESS,
name: 'Echo Probe',
persona: 'a persona',
model: 'anthropic/claude-test',
provider,
credential: { mode: 'intake', type: 'api_key', value: SECRET },
idempotencyKey: randomUUID(),
});
expect(res.status).toBe(201);
expect(res.text).not.toContain(SECRET);
const agentId = (res.body as { agent: EnrolledAgentView }).agent.id;
const events = await eventsForAgent(agentId);
expect(events).toHaveLength(1);
expect(JSON.stringify(events)).not.toContain(SECRET);
expect(JSON.stringify(await allOutbox())).not.toContain(SECRET);
const fences = await handle.db
.select()
.from(agentIdempotencyFence)
.where(eq(agentIdempotencyFence.outcomeAgentId, agentId));
expect(fences).toHaveLength(1);
expect(JSON.stringify(fences)).not.toContain(SECRET);
expect(logSink.join('\n')).not.toContain(SECRET);
// §5.2 sealed single-copy: exactly one provider_credentials row, sealed
// at rest, and it round-trips through unseal — no plaintext column.
const creds = await credentialsFor(OWNER, provider);
expect(creds).toHaveLength(1);
expect(creds[0]?.encryptedValue).not.toBe(SECRET);
expect(creds[0]?.encryptedValue).not.toContain(SECRET);
expect(unseal(creds[0]?.encryptedValue as string)).toBe(SECRET);
} finally {
logSpies.forEach((spy) => spy.mockRestore());
}
});
it('the agents table itself has no credential-bearing column (§5.2)', async () => {
const result = (await handle.db.execute(
sql`select column_name from information_schema.columns where table_name = 'agents'`,
)) as unknown as { rows?: Array<{ column_name: string }> } & Array<{ column_name: string }>;
const names = (result.rows ?? result).map((row) => row.column_name);
expect(names.length).toBeGreaterThan(0);
for (const name of names) {
expect(name).not.toMatch(/credential|secret|token|api_key/i);
}
});
// ── §5.3 reference resolution ─────────────────────────────────────────────
it('refuses an unresolvable credential reference with precondition_failed and creates nothing', async () => {
const input = enrollInput({ credential: { mode: 'reference' } });
const result = await repo.enroll(input);
expectFail(result, 'precondition_failed');
expect(await agentsNamed(input.name)).toHaveLength(0);
expect(await fenceForKey(input.idempotencyKey)).toHaveLength(0);
});
it('resolves a reference credential stored earlier for (actor, provider)', async () => {
const provider = `prov-ref-${randomUUID().slice(0, 8)}`;
const seeded = expectOk(await repo.enroll(enrollInput({ provider })));
const result = expectOk(
await repo.enroll(enrollInput({ provider, credential: { mode: 'reference' } })),
);
expect(result.agent.id).not.toBe(seeded.agent.id);
expect(await credentialsFor(OWNER, provider)).toHaveLength(1);
});
// ── §5.4 harness refusals, both codes ────────────────────────────────────
it('refuses a syntactically invalid harness as validation_failed and a registry miss as precondition_failed', async () => {
const blank = await repo.enroll(enrollInput({ harness: ' ' }));
expectFail(blank, 'validation_failed');
const miss = await repo.enroll(enrollInput({ harness: 'well-formed-but-unregistered' }));
expectFail(miss, 'precondition_failed');
currentUserId = OWNER;
const httpBlank = await http.post('/api/enrollment/agents').send({
harness: '',
name: 'H',
model: 'm',
provider: 'p',
credential: { mode: 'reference' },
idempotencyKey: randomUUID(),
});
expect(httpBlank.status).toBe(400);
});
// ── §5.5 idempotency set (contract 3 §4.3) ───────────────────────────────
it('actor-bound replay returns the recorded outcome and executes nothing new', async () => {
const input = enrollInput();
const first = expectOk(await repo.enroll(input));
const replay = expectOk(await repo.enroll({ ...input, correlationId: randomUUID() }));
expect(replay.agent.id).toBe(first.agent.id);
expect(await agentsNamed(input.name)).toHaveLength(1);
expect(await fenceForKey(input.idempotencyKey)).toHaveLength(1);
const events = await eventsForAgent(first.agent.id);
expect(events.filter((e) => e.eventType === 'agent.enrolled')).toHaveLength(1);
// A passing replay appends exactly the non-mutation access event.
const replayed = events.filter((e) => e.eventType === 'agent.enrollment.replayed');
expect(replayed).toHaveLength(1);
expect((replayed[0]?.payload as { fenceId?: string }).fenceId).toBeDefined();
});
it('payload-digest mismatch on a recorded key refuses with the single bounded conflict shape', async () => {
const input = enrollInput();
expectOk(await repo.enroll(input));
const mismatch = await repo.enroll({ ...input, name: `${input.name} CHANGED` });
const failure = expectFail(mismatch, 'conflict');
expect(failure.message).toBe('idempotency conflict');
});
it('replay-mode and scope mismatches on the recorded fence each refuse as the same constant conflict', async () => {
const modeInput = enrollInput();
expectOk(await repo.enroll(modeInput));
await handle.db
.update(agentIdempotencyFence)
.set({ replayMode: 'shared' })
.where(eq(agentIdempotencyFence.idempotencyKey, modeInput.idempotencyKey));
const modeFailure = expectFail(await repo.enroll(modeInput), 'conflict');
const scopeInput = enrollInput();
expectOk(await repo.enroll(scopeInput));
await handle.db
.update(agentIdempotencyFence)
.set({ authorizationScope: 'some-other-scope' })
.where(eq(agentIdempotencyFence.idempotencyKey, scopeInput.idempotencyKey));
const scopeFailure = expectFail(await repo.enroll(scopeInput), 'conflict');
expect(modeFailure.message).toBe(scopeFailure.message);
});
it('a different actor replaying an actor-bound key is refused conflict, learning nothing', async () => {
const input = enrollInput();
expectOk(await repo.enroll(input));
const failure = expectFail(await repo.enroll({ ...input, actorId: STRANGER }), 'conflict');
expect(failure.message).toBe('idempotency conflict');
});
it('a replay is re-authorized fresh: revoked target authority refuses instead of replaying', async () => {
const input = enrollInput();
const first = expectOk(await repo.enroll(input));
// Simulate the legacy CRUD DELETE path removing the outcome agent: the
// submitter no longer holds read authority on the referenced row.
await handle.db.delete(agents).where(eq(agents.id, first.agent.id));
expectFail(await repo.enroll(input), 'conflict');
});
it('a shared replay-mode declaration is refused validation_failed with nothing executed and no fence row', async () => {
const input = enrollInput({ replayMode: 'shared' });
expectFail(await repo.enroll(input), 'validation_failed');
expect(await agentsNamed(input.name)).toHaveLength(0);
expect(await fenceForKey(input.idempotencyKey)).toHaveLength(0);
currentUserId = OWNER;
const key = randomUUID();
const res = await http.post('/api/enrollment/agents').send({
harness: HARNESS,
name: 'Shared Probe',
model: 'm',
provider: 'p',
credential: { mode: 'reference' },
idempotencyKey: key,
replayMode: 'shared',
});
expect(res.status).toBe(400);
expect(await fenceForKey(key)).toHaveLength(0);
});
it('two concurrent same-key submissions produce exactly one mutation, the loser resolving as a replay', async () => {
const input = enrollInput();
const [a, b] = await Promise.all([
repo.enroll(input),
repo.enroll({ ...input, correlationId: randomUUID() }),
]);
const okA = expectOk(a);
const okB = expectOk(b);
expect(okA.agent.id).toBe(okB.agent.id);
expect(await agentsNamed(input.name)).toHaveLength(1);
expect(await fenceForKey(input.idempotencyKey)).toHaveLength(1);
const events = await eventsForAgent(okA.agent.id);
expect(events.filter((e) => e.eventType === 'agent.enrolled')).toHaveLength(1);
expect(events.filter((e) => e.eventType === 'agent.enrollment.replayed')).toHaveLength(1);
});
// ── §5.6 same-tx atomicity fault injection ───────────────────────────────
it('rolls everything back on failure at each write point — no orphan credential survives', async () => {
const injectionPoints = [
'writeSealedCredential',
'insertAgentRow',
'insertFenceRow',
'appendEvent',
'insertOutboxRow',
] as const;
for (const point of injectionPoints) {
const input = enrollInput();
const spy = vi.spyOn(repo, point).mockImplementationOnce(() => {
throw new Error(`injected ${point} fault`);
});
try {
const result = await repo.enroll(input);
expectFail(result, 'internal_fault');
expect(await agentsNamed(input.name)).toHaveLength(0);
expect(await fenceForKey(input.idempotencyKey)).toHaveLength(0);
// Injection at fence/audit/outbox fires AFTER the sealed credential
// write's statement ran — the rollback must leave no orphan row.
expect(await credentialsFor(OWNER, input.provider)).toHaveLength(0);
} finally {
spy.mockRestore();
}
}
});
// ── §5.8 is_system closure ───────────────────────────────────────────────
it('rejects an is_system injection attempt at the DTO boundary', async () => {
currentUserId = OWNER;
const key = randomUUID();
const res = await http.post('/api/enrollment/agents').send({
harness: HARNESS,
name: 'System Probe',
model: 'm',
provider: 'p',
credential: { mode: 'reference' },
idempotencyKey: key,
isSystem: true,
});
expect(res.status).toBe(400);
expect(await fenceForKey(key)).toHaveLength(0);
});
// ── §5.9 correlation + no-existence-oracle ───────────────────────────────
it('carries a submitted correlation id into the result, the audit event, and the outbox record', async () => {
const correlationId = randomUUID();
const input = enrollInput({ correlationId });
const result = expectOk(await repo.enroll(input));
expect(result.correlationId).toBe(correlationId);
const events = await eventsForAgent(result.agent.id);
expect(events).toHaveLength(1);
expect(events[0]?.correlationId).toBe(correlationId);
const outboxRows = await handle.db
.select()
.from(agentOutbox)
.where(eq(agentOutbox.eventId, events[0]?.id as string));
expect(outboxRows).toHaveLength(1);
expect(outboxRows[0]?.correlationId).toBe(correlationId);
// Refusals carry the correlation envelope too (contract 5 §4.3).
const refusal = expectFail(
await repo.enroll({ ...input, name: 'changed name', correlationId }),
'conflict',
);
expect(refusal.correlationId).toBe(correlationId);
});
it('agent.enrollment.get returns owner and admin reads with the correlation envelope, no idempotency key', async () => {
const enrolled = expectOk(await repo.enroll(enrollInput()));
const correlationId = randomUUID();
const asOwner = expectOk(await repo.getEnrollment(OWNER, enrolled.agent.id, correlationId));
expect(asOwner.correlationId).toBe(correlationId);
expect(asOwner.agent.id).toBe(enrolled.agent.id);
const asAdmin = expectOk(await repo.getEnrollment(ADMIN, enrolled.agent.id));
expect(asAdmin.correlationId).toMatch(/^[0-9a-f-]{36}$/);
currentUserId = OWNER;
const wire = randomUUID();
const res = await http.get(`/api/enrollment/agents/${enrolled.agent.id}?correlationId=${wire}`);
expect(res.status).toBe(200);
expect((res.body as { correlationId: string }).correlationId).toBe(wire);
});
it('no existence oracle: unauthorized get of a real agent and get of a missing id are indistinguishable', async () => {
const enrolled = expectOk(await repo.enroll(enrollInput()));
currentUserId = STRANGER;
const unauthorized = await http.get(`/api/enrollment/agents/${enrolled.agent.id}`);
const missing = await http.get(`/api/enrollment/agents/${randomUUID()}`);
expect(unauthorized.status).toBe(404);
expect(missing.status).toBe(404);
const strip = (body: Record<string, unknown>): Record<string, unknown> =>
Object.fromEntries(Object.entries(body).filter(([key]) => key !== 'correlationId'));
expect(strip(unauthorized.body as Record<string, unknown>)).toEqual(
strip(missing.body as Record<string, unknown>),
);
});
// ── §5.11 fail-closed ────────────────────────────────────────────────────
it('fails closed as internal_fault when the store is unreachable, with no fallback write', async () => {
const before = (await handle.db.select().from(agents)).length;
const txSpy = vi.spyOn(handle.db, 'transaction').mockImplementationOnce(() => {
throw new Error('injected store outage');
});
try {
expectFail(await repo.enroll(enrollInput()), 'internal_fault');
} finally {
txSpy.mockRestore();
}
const selectSpy = vi.spyOn(handle.db, 'select').mockImplementationOnce(() => {
throw new Error('injected store outage');
});
try {
expectFail(await repo.getEnrollment(OWNER, randomUUID()), 'internal_fault');
} finally {
selectSpy.mockRestore();
}
expect((await handle.db.select().from(agents)).length).toBe(before);
});
});
@@ -1,61 +0,0 @@
import {
Body,
Controller,
Get,
Param,
ParseUUIDPipe,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { AuthGuard } from '../auth/auth.guard.js';
import { CurrentUser } from '../auth/current-user.decorator.js';
import { EnrollAgentDto, GetEnrollmentQueryDto } from './enrollment.dto.js';
import { EnrollmentRepository } from './enrollment.repository.js';
import { EnrollmentService } from './enrollment.service.js';
/**
* The agent enrollment command family's closed HTTP surface (design
* docs/plans/2026-08-29-agent-enrollment-command-design.md §3): one command,
* one query. Authentication failures are the guard's (401); everything else
* is the repository's closed enum mapped by EnrollmentService.
*/
@Controller('api/enrollment')
@UseGuards(AuthGuard)
export class EnrollmentController {
constructor(
private readonly repository: EnrollmentRepository,
private readonly service: EnrollmentService,
) {}
/** agent.enroll (§3.1). */
@Post('agents')
async enroll(@CurrentUser() user: { id: string }, @Body() dto: EnrollAgentDto) {
return this.service.unwrap(
await this.repository.enroll({
actorId: user.id,
harness: dto.harness,
name: dto.name,
persona: dto.persona ?? null,
model: dto.model,
provider: dto.provider,
credential: dto.credential,
idempotencyKey: dto.idempotencyKey,
correlationId: dto.correlationId,
replayMode: dto.replayMode,
}),
);
}
/** agent.enrollment.get (§3.2): owner-or-admin; unauthorized and missing fold to one not_found. */
@Get('agents/:id')
async getEnrollment(
@CurrentUser() user: { id: string },
@Param('id', ParseUUIDPipe) id: string,
@Query() query: GetEnrollmentQueryDto,
) {
return this.service.unwrap(
await this.repository.getEnrollment(user.id, id, query.correlationId),
);
}
}
@@ -1,107 +0,0 @@
import { Type } from 'class-transformer';
import {
IsIn,
IsOptional,
IsString,
IsUUID,
MaxLength,
MinLength,
ValidateIf,
ValidateNested,
} from 'class-validator';
/**
* Agent enrollment command DTOs (design
* docs/plans/2026-08-29-agent-enrollment-command-design.md §3.1/§3.2,
* contract 5 §4.1 typed boundary).
*
* The global ValidationPipe runs with whitelist + forbidNonWhitelisted, so
* closure is contract surface here exactly as in the hierarchy DTOs:
* - EnrollAgentDto declares NO isSystem field — `is_system` is never
* settable through this command (design §3.1 rule 4); the pipe refuses it.
* - replayMode admits ONLY 'actor-bound': `shared` is seed-only (contract 3
* §4.3), so a shared declaration is refused `validation_failed` at the
* boundary, executes nothing, and records no fence row (design §3.1).
* Every class here must be registered in PIPE_GUARDED_DTOS so the boot-time
* assertion proves the pipe sees the decorators.
*/
/**
* Credential input, discriminated on `mode` (design §3.1):
* - `{ mode: 'reference' }` — a stored credential for (actor, provider)
* must already exist; `type`/`value` must be ABSENT (the repository
* refuses a reference that smuggles a value).
* - `{ mode: 'intake', type: 'api_key', value }` — the value is sealed
* into the credential store inside the enrollment transaction and is
* never echoed anywhere (§3.1 rule 1).
*/
export class EnrollCredentialDto {
@IsIn(['reference', 'intake'])
mode!: 'reference' | 'intake';
@ValidateIf((o: EnrollCredentialDto) => o.mode === 'intake')
@IsIn(['api_key'])
type?: 'api_key';
@ValidateIf((o: EnrollCredentialDto) => o.mode === 'intake')
@IsString()
@MinLength(1)
@MaxLength(4096)
value?: string;
}
export class EnrollAgentDto {
/** Registered harness name; a well-formed name missing from the registry is `precondition_failed`. */
@IsString()
@MinLength(1)
@MaxLength(200)
harness!: string;
@IsString()
@MinLength(1)
@MaxLength(200)
name!: string;
/** Stored as the agent's system prompt; null/absent leaves it unset. */
@IsOptional()
@IsString()
@MaxLength(20000)
persona?: string | null;
/** Provider-qualified model id. */
@IsString()
@MinLength(1)
@MaxLength(200)
model!: string;
/** Names the credential's provider. */
@IsString()
@MinLength(1)
@MaxLength(200)
provider!: string;
@ValidateNested()
@Type(() => EnrollCredentialDto)
credential!: EnrollCredentialDto;
/** REQUIRED — contract 3 §4.3, ratified into contract 5 §4 via §7 item 4. */
@IsUUID()
idempotencyKey!: string;
/** Optional; generated when absent (contract 5 §4.3). */
@IsOptional()
@IsUUID()
correlationId?: string;
/** Only 'actor-bound' is admissible on this family — see module doc. */
@IsOptional()
@IsIn(['actor-bound'])
replayMode?: 'actor-bound';
}
/** Query envelope for agent.enrollment.get (design §3.2): correlation only, no idempotency key. */
export class GetEnrollmentQueryDto {
@IsOptional()
@IsUUID()
correlationId?: string;
}
@@ -1,22 +0,0 @@
import { Module } from '@nestjs/common';
import { HarnessModule } from '../harness/harness.module.js';
import { EnrollmentController } from './enrollment.controller.js';
import { EnrollmentRepository } from './enrollment.repository.js';
import { EnrollmentService } from './enrollment.service.js';
/**
* Agent enrollment command family (M4-4b; design
* docs/plans/2026-08-29-agent-enrollment-command-design.md). Imports
* HarnessModule for the live harness registry — the validation source for
* the `harness` field (a well-formed name the registry does not know is a
* precondition failure). EnrollmentRepository is the family's sole writer;
* every mutation runs fence-check → mutate → audit + outbox in one
* transaction.
*/
@Module({
imports: [HarnessModule],
controllers: [EnrollmentController],
providers: [EnrollmentRepository, EnrollmentService],
exports: [EnrollmentRepository],
})
export class EnrollmentModule {}
@@ -1,538 +0,0 @@
import { createHash, randomUUID } from 'node:crypto';
import { Inject, Injectable, Logger } from '@nestjs/common';
import { seal } from '@mosaicstack/auth';
import {
agentAuditEvents,
agentIdempotencyFence,
agentOutbox,
agents,
and,
eq,
providerCredentials,
users,
type Db,
} from '@mosaicstack/db';
import { DB } from '../database/database.module.js';
import type { HarnessRegistry } from '../harness/harness.registry.js';
import { HARNESS_REGISTRY } from '../harness/harness.tokens.js';
/**
* Agent enrollment command repository (design
* docs/plans/2026-08-29-agent-enrollment-command-design.md §3; contract 5 §4
* envelope; contract 3 §4.3 idempotency fence, ratified via §7 item 4).
*
* The ONLY writer of the enrollment family's tables (`agent_audit_events`,
* `agent_outbox`, `agent_idempotency_fence`) and the only path that sets
* `agents.harness`/`agents.enrolled_at`. Every enroll runs one transaction:
* fence check → (replay | credential handling → agent insert → fence insert →
* audit event + outbox), so state, fence, event, and outbox commit or roll
* back together (§3.1 rule 6).
*
* Authorization (v1, §3.1 rule 4) is the AuthGuard-authenticated actor — no
* hierarchy grant is consulted because v1 enrollment binds no hierarchy node.
* The recorded fence authorization scope is therefore the constant
* platform-user identity domain (§3.1 rule 5).
*
* Never-echo (§3.1 rule 1): the credential value reaches exactly one sink —
* the sealed store write — and appears in no result, audit payload, outbox
* row, or log line. Log lines here carry correlation ids and error names
* only, never request fields.
*
* The single-write helper methods (writeSealedCredential, insertAgentRow,
* insertFenceRow, appendEvent, insertOutboxRow) are ordinary decomposition;
* the atomicity witnesses (§5.6) spy on them to inject faults at each write
* point without any test-only production switch.
*/
export const ENROLLMENT_OPERATION = 'agent.enroll';
/** §3.1 rule 5: v1 authorization is grant-free, so the scope is the authenticated-user identity domain. */
const AUTHORIZATION_SCOPE = 'platform-user';
/** The single bounded collision shape (§3.1 rule 5): constant, identifying no record. */
const CONFLICT_MESSAGE = 'idempotency conflict';
/** One fixed message for every not_found cause — missing and unauthorized are indistinguishable (§3.2). */
const NOT_FOUND_MESSAGE = 'agent not found';
/** Closed per-family error enum (§3.3). 401 is produced by AuthGuard; 403 folds to not_found (§3.2). */
export type EnrollmentErrorCode =
| 'validation_failed'
| 'authentication_failed'
| 'authorization_refused'
| 'not_found'
| 'conflict'
| 'precondition_failed'
| 'internal_fault';
export interface EnrollmentFailure {
readonly ok: false;
readonly error: EnrollmentErrorCode;
readonly message: string;
/** Refusals carry the correlation id too (contract 5 §4.3 end-to-end traceability). */
readonly correlationId: string;
}
export type EnrollmentResult<T> =
| ({ readonly ok: true; readonly correlationId: string } & T)
| EnrollmentFailure;
/** The persisted agent row; the table stores no credential material (§3.1 rule 1). */
export interface EnrolledAgentView {
readonly id: string;
readonly name: string;
readonly provider: string;
readonly model: string;
readonly status: string;
readonly harness: string | null;
readonly persona: string | null;
readonly ownerId: string | null;
readonly enrolledAt: string | null;
readonly createdAt: string;
}
export interface EnrollCredentialInput {
readonly mode: 'reference' | 'intake';
readonly type?: 'api_key';
readonly value?: string;
}
export interface EnrollAgentInput {
readonly actorId: string;
readonly harness: string;
readonly name: string;
readonly persona?: string | null;
readonly model: string;
readonly provider: string;
readonly credential: EnrollCredentialInput;
readonly idempotencyKey: string;
readonly correlationId?: string;
/** Defense in depth below the DTO: anything but 'actor-bound' is refused (seed-only rule). */
readonly replayMode?: string;
}
type Tx = Pick<Db, 'insert' | 'select' | 'update' | 'delete'>;
type AgentRow = typeof agents.$inferSelect;
type FenceRow = typeof agentIdempotencyFence.$inferSelect;
/** Raised inside the transaction when the fence insert lost a same-key race (§3.1 rule 5 concurrency). */
class ConcurrentEnrollmentError extends Error {
constructor() {
super('concurrent enrollment lost the fence race');
this.name = 'ConcurrentEnrollmentError';
}
}
function agentView(row: AgentRow): EnrolledAgentView {
return {
id: row.id,
name: row.name,
provider: row.provider,
model: row.model,
status: row.status,
harness: row.harness,
persona: row.systemPrompt,
ownerId: row.ownerId,
enrolledAt: row.enrolledAt?.toISOString() ?? null,
createdAt: row.createdAt.toISOString(),
};
}
/** Key-order-independent serialization (jsonb precedent in hierarchy-audit). */
function canonicalJson(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
if (value !== null && typeof value === 'object') {
const record = value as Record<string, unknown>;
const body = Object.keys(record)
.sort()
.map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`)
.join(',');
return `{${body}}`;
}
return JSON.stringify(value);
}
interface NormalizedEnrollment {
readonly actorId: string;
readonly harness: string;
readonly name: string;
readonly persona: string | null;
readonly model: string;
readonly provider: string;
readonly credential: EnrollCredentialInput;
readonly idempotencyKey: string;
readonly correlationId: string;
readonly digest: string;
}
/**
* Canonicalized-payload digest (§3.1 rule 5). The input EXCLUDES the
* credential value by construction: it covers mode and declared type only —
* plaintext never reaches the hash.
*/
function digestOf(
input: Omit<NormalizedEnrollment, 'actorId' | 'idempotencyKey' | 'correlationId' | 'digest'>,
): string {
const canonical = canonicalJson({
harness: input.harness,
name: input.name,
persona: input.persona,
model: input.model,
provider: input.provider,
credential: { mode: input.credential.mode, type: input.credential.type ?? null },
});
return createHash('sha256').update(canonical).digest('hex');
}
@Injectable()
export class EnrollmentRepository {
private readonly logger = new Logger(EnrollmentRepository.name);
constructor(
@Inject(DB) private readonly db: Db,
@Inject(HARNESS_REGISTRY) private readonly registry: HarnessRegistry,
) {}
async enroll(input: EnrollAgentInput): Promise<EnrollmentResult<{ agent: EnrolledAgentView }>> {
const correlationId = input.correlationId ?? randomUUID();
const fail = (error: EnrollmentErrorCode, message: string): EnrollmentFailure => ({
ok: false,
error,
message,
correlationId,
});
const harness = input.harness.trim();
const name = input.name.trim();
if (harness.length === 0) return fail('validation_failed', 'harness must be non-empty');
if (name.length === 0 || name.length > 200) {
return fail('validation_failed', 'name must be non-empty and at most 200 characters');
}
if (input.replayMode !== undefined && input.replayMode !== 'actor-bound') {
// Seed-only rule (contract 3 §4.3): refused with nothing executed and no fence row.
return fail('validation_failed', 'replayMode must be actor-bound');
}
if (input.credential.mode === 'reference') {
if (input.credential.type !== undefined || input.credential.value !== undefined) {
return fail('validation_failed', 'a reference credential carries no type or value');
}
} else if (
input.credential.type !== 'api_key' ||
typeof input.credential.value !== 'string' ||
input.credential.value.length === 0
) {
return fail('validation_failed', 'an intake credential requires type api_key and a value');
}
// Syntactic validity ends above; a well-formed name the live registry
// does not know is a precondition failure (§3.1 table).
if (!this.registry.has(harness)) {
return fail('precondition_failed', 'harness is not registered');
}
const normalized: NormalizedEnrollment = {
actorId: input.actorId,
harness,
name,
persona: input.persona ?? null,
model: input.model,
provider: input.provider,
credential: input.credential,
idempotencyKey: input.idempotencyKey,
correlationId,
digest: digestOf({
harness,
name,
persona: input.persona ?? null,
model: input.model,
provider: input.provider,
credential: input.credential,
}),
};
// Two attempts: a fence-race loser's transaction rolls back and the retry
// resolves through the replay path against the winner's committed row —
// or executes afresh if the winner aborted (§3.1 rule 5 concurrency). A
// unique-violation race never surfaces as an unhandled internal fault.
for (let attempt = 0; attempt < 2; attempt += 1) {
try {
return await this.db.transaction(async (tx) => this.enrollTx(tx, normalized));
} catch (error) {
if (error instanceof ConcurrentEnrollmentError && attempt === 0) continue;
if (error instanceof ConcurrentEnrollmentError) {
return fail('conflict', CONFLICT_MESSAGE);
}
// §4.4 fail-closed: whatever broke, the transaction rolled back and
// the refusal is the internal-fault class — no fallback write or read.
this.logger.error(
`agent.enroll failed closed (correlation=${correlationId}): ${
error instanceof Error ? error.name : 'unknown error'
}`,
);
return fail('internal_fault', 'internal fault');
}
}
return fail('internal_fault', 'internal fault');
}
private async enrollTx(
tx: Tx,
input: NormalizedEnrollment,
): Promise<EnrollmentResult<{ agent: EnrolledAgentView }>> {
const fence = await this.fenceFor(tx, input.idempotencyKey);
if (fence) return this.replay(tx, fence, input);
if (input.credential.mode === 'reference') {
// §3.1 rule 3: the reference must resolve for (actor, provider).
const existing = await tx
.select({ id: providerCredentials.id })
.from(providerCredentials)
.where(
and(
eq(providerCredentials.userId, input.actorId),
eq(providerCredentials.provider, input.provider),
),
)
.limit(1);
if (existing.length === 0) {
return {
ok: false,
error: 'precondition_failed',
message: 'credential reference does not resolve',
correlationId: input.correlationId,
};
}
} else {
// §3.1 rule 2: sealed-store write inside THIS transaction — a later
// failure rolls it back, leaving no orphan credential.
await this.writeSealedCredential(
tx,
input.actorId,
input.provider,
input.credential.value as string,
);
}
const agentRow = await this.insertAgentRow(tx, input);
const fenceRow = await this.insertFenceRow(tx, input, agentRow.id);
if (!fenceRow) {
// A same-(operation, key) winner committed first; abandon our writes.
throw new ConcurrentEnrollmentError();
}
await this.appendEvent(tx, {
eventType: 'agent.enrolled',
actorId: input.actorId,
agentId: agentRow.id,
correlationId: input.correlationId,
// §3.1 rule 6 payload: harness, provider, name, credentialMode — no credential material.
payload: {
harness: input.harness,
provider: input.provider,
name: input.name,
credentialMode: input.credential.mode,
},
});
return { ok: true, correlationId: input.correlationId, agent: agentView(agentRow) };
}
/**
* Replay path (§3.1 rule 5): a fresh submission of a recorded
* (operation, key). The actor is re-authorized exactly as a fresh
* submission (v1: authenticated actor — the guard already ran); then mode,
* scope, digest, and recorded-actor equality; then target-result read
* authority (owner or admin) on the referenced agent. ANY failure refuses
* with the single bounded conflict shape — constant, identifying no record.
* A passing replay executes nothing and appends only the non-mutation
* access event (with its outbox record — one outbox row per event).
*/
private async replay(
tx: Tx,
fence: FenceRow,
input: NormalizedEnrollment,
): Promise<EnrollmentResult<{ agent: EnrolledAgentView }>> {
const collision: EnrollmentFailure = {
ok: false,
error: 'conflict',
message: CONFLICT_MESSAGE,
correlationId: input.correlationId,
};
if (fence.replayMode !== 'actor-bound') return collision;
if (fence.authorizationScope !== AUTHORIZATION_SCOPE) return collision;
if (fence.payloadDigest !== input.digest) return collision;
if (fence.actorId !== input.actorId) return collision;
const rows = await tx.select().from(agents).where(eq(agents.id, fence.outcomeAgentId)).limit(1);
const agentRow = rows[0];
if (!agentRow) return collision;
const authorized =
agentRow.ownerId === input.actorId || (await this.isPlatformAdmin(tx, input.actorId));
if (!authorized) return collision;
await this.appendEvent(tx, {
eventType: 'agent.enrollment.replayed',
actorId: input.actorId,
agentId: agentRow.id,
correlationId: input.correlationId,
payload: { fenceId: fence.id },
});
return { ok: true, correlationId: input.correlationId, agent: agentView(agentRow) };
}
/**
* agent.enrollment.get (§3.2): owner-or-admin read. Unauthorized and
* missing fold to the same not_found wire shape (no existence oracle).
*/
async getEnrollment(
actorId: string,
agentId: string,
correlationId?: string,
): Promise<EnrollmentResult<{ agent: EnrolledAgentView }>> {
const resolvedCorrelation = correlationId ?? randomUUID();
try {
const rows = await this.db.select().from(agents).where(eq(agents.id, agentId)).limit(1);
const row = rows[0];
if (row) {
const authorized =
row.ownerId === actorId || (await this.isPlatformAdmin(this.db, actorId));
if (authorized) {
return { ok: true, correlationId: resolvedCorrelation, agent: agentView(row) };
}
}
return {
ok: false,
error: 'not_found',
message: NOT_FOUND_MESSAGE,
correlationId: resolvedCorrelation,
};
} catch (error) {
this.logger.error(
`agent.enrollment.get failed closed (correlation=${resolvedCorrelation}): ${
error instanceof Error ? error.name : 'unknown error'
}`,
);
return {
ok: false,
error: 'internal_fault',
message: 'internal fault',
correlationId: resolvedCorrelation,
};
}
}
private async fenceFor(tx: Tx, idempotencyKey: string): Promise<FenceRow | null> {
const rows = await tx
.select()
.from(agentIdempotencyFence)
.where(
and(
eq(agentIdempotencyFence.operation, ENROLLMENT_OPERATION),
eq(agentIdempotencyFence.idempotencyKey, idempotencyKey),
),
)
.limit(1);
return rows[0] ?? null;
}
private async isPlatformAdmin(tx: Tx, actorId: string): Promise<boolean> {
const rows = await tx
.select({ role: users.role })
.from(users)
.where(eq(users.id, actorId))
.limit(1);
return rows[0]?.role === 'admin';
}
/**
* Sealed intake write, mirroring ProviderCredentialsService.store semantics
* (seal-at-rest, one row per (userId, provider)) but on the enrollment
* transaction (§3.1 rule 2). The plaintext exists only in this frame.
*/
async writeSealedCredential(
tx: Tx,
userId: string,
provider: string,
value: string,
): Promise<void> {
const encryptedValue = seal(value);
await tx
.insert(providerCredentials)
.values({ userId, provider, credentialType: 'api_key', encryptedValue, metadata: null })
.onConflictDoUpdate({
target: [providerCredentials.userId, providerCredentials.provider],
set: {
credentialType: 'api_key',
encryptedValue,
metadata: null,
updatedAt: new Date(),
},
});
}
async insertAgentRow(tx: Tx, input: NormalizedEnrollment): Promise<AgentRow> {
const rows = await tx
.insert(agents)
.values({
name: input.name,
provider: input.provider,
model: input.model,
harness: input.harness,
systemPrompt: input.persona,
// §3.1 rule 4: owner is the authenticated actor; is_system stays default false.
ownerId: input.actorId,
enrolledAt: new Date(),
})
.returning();
const row = rows[0];
if (!row) throw new Error('agent insert returned no row');
return row;
}
async insertFenceRow(
tx: Tx,
input: NormalizedEnrollment,
outcomeAgentId: string,
): Promise<FenceRow | null> {
const rows = await tx
.insert(agentIdempotencyFence)
.values({
operation: ENROLLMENT_OPERATION,
idempotencyKey: input.idempotencyKey,
actorId: input.actorId,
authorizationScope: AUTHORIZATION_SCOPE,
payloadDigest: input.digest,
replayMode: 'actor-bound',
outcomeAgentId,
})
.onConflictDoNothing()
.returning();
return rows[0] ?? null;
}
/** Append one audit event and its outbox record on the caller's transaction (one outbox row per event). */
async appendEvent(
tx: Tx,
input: {
eventType: 'agent.enrolled' | 'agent.enrollment.replayed';
actorId: string;
agentId: string;
correlationId: string;
payload: Record<string, unknown>;
causationId?: string;
},
): Promise<void> {
const inserted = await tx
.insert(agentAuditEvents)
.values({
eventType: input.eventType,
actorId: input.actorId,
agentId: input.agentId,
correlationId: input.correlationId,
causationId: input.causationId ?? null,
payload: input.payload,
})
.returning();
const event = inserted[0];
if (!event) throw new Error('agent audit event insert returned no row');
await this.insertOutboxRow(tx, event.id, input.correlationId);
}
async insertOutboxRow(tx: Tx, eventId: string, correlationId: string): Promise<void> {
await tx.insert(agentOutbox).values({ eventId, correlationId });
}
}
@@ -1,45 +0,0 @@
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import type {
EnrollmentErrorCode,
EnrollmentFailure,
EnrollmentResult,
} from './enrollment.repository.js';
/**
* Maps enrollment result unions onto the closed HTTP status set (design
* docs/plans/2026-08-29-agent-enrollment-command-design.md §3.3, contract 5
* §4.2). Every refusal body carries the correlation id (contract 5 §4.3
* end-to-end traceability) alongside the enum code. `not_found` carries one
* fixed message for every cause — missing agent and unauthorized caller are
* indistinguishable on the wire (§3.2).
*/
const HTTP_STATUS: Record<EnrollmentErrorCode, HttpStatus> = {
validation_failed: HttpStatus.BAD_REQUEST,
authentication_failed: HttpStatus.UNAUTHORIZED,
authorization_refused: HttpStatus.FORBIDDEN,
not_found: HttpStatus.NOT_FOUND,
conflict: HttpStatus.CONFLICT,
precondition_failed: HttpStatus.UNPROCESSABLE_ENTITY,
internal_fault: HttpStatus.INTERNAL_SERVER_ERROR,
};
@Injectable()
export class EnrollmentService {
unwrap<T>(result: EnrollmentResult<T>): { ok: true; correlationId: string } & T {
if (result.ok) return result;
throw this.toException(result);
}
private toException(failure: EnrollmentFailure): HttpException {
const status = HTTP_STATUS[failure.error];
return new HttpException(
{
statusCode: status,
error: failure.error,
message: failure.message,
correlationId: failure.correlationId,
},
status,
);
}
}
@@ -245,21 +245,9 @@ describe('EnrollmentService.createToken', () => {
const after = Date.now();
const expiresMs = new Date(result.expiresAt).getTime();
// The property under test is CLAMPING: a 9999s request must come back as 900s.
// The gap between clamped and unclamped is 9_099_000 ms, so the tolerance below
// only has to exceed CI scheduling jitter — it does not need to be tight to keep
// the assertion discriminating. A 5s allowance consumes 0.05% of that margin and
// an unclamped result still misses by three orders of magnitude.
//
// It was 100ms and failed on a loaded agent at 900_106 — 6ms over (#1090). A
// wall-clock budget sized to a fast machine is a flake, not a tighter test.
const CI_JITTER_MS = 5_000;
expect(expiresMs - before).toBeLessThanOrEqual(900_000 + CI_JITTER_MS);
// Should be at most 900s from now
expect(expiresMs - before).toBeLessThanOrEqual(900_000 + 100);
expect(expiresMs - after).toBeGreaterThanOrEqual(0);
// Explicitly pin the clamp itself, independent of any timing allowance:
// unclamped (9999s) would exceed this by ~9_099_000 ms.
expect(expiresMs - before).toBeLessThan(1_000_000);
});
});
+2 -11
View File
@@ -1,12 +1,7 @@
import { Module } from '@nestjs/common';
import { HarnessRegistry } from './harness.registry.js';
import { HarnessService } from './harness.service.js';
import {
HARNESS_CONVERSATION_SERVICE,
HARNESS_CONVERSATION_SERVICE_UNAVAILABLE,
HARNESS_REGISTRY,
HARNESS_SERVICE,
} from './harness.tokens.js';
import { HARNESS_REGISTRY, HARNESS_SERVICE } from './harness.tokens.js';
import { HarnessController } from './harness.controller.js';
import { HarnessSelectionController } from './harness-selection.controller.js';
import { HarnessSelectionService } from './harness-selection.service.js';
@@ -25,13 +20,9 @@ import { HarnessSelectionRepository } from './harness-selection.repository.js';
providers: [
{ provide: HARNESS_REGISTRY, useFactory: () => new HarnessRegistry() },
{ provide: HARNESS_SERVICE, useClass: HarnessService },
// Task Five: bind the conversation-service token to its explicit "not yet bound"
// sentinel. The pi-rpc router treats this as a hard, typed startup failure; Task 14
// replaces it with a real service. Exported so ChatModule's router can inject it.
{ provide: HARNESS_CONVERSATION_SERVICE, useValue: HARNESS_CONVERSATION_SERVICE_UNAVAILABLE },
HarnessSelectionRepository,
HarnessSelectionService,
],
exports: [HARNESS_REGISTRY, HARNESS_SERVICE, HARNESS_CONVERSATION_SERVICE],
exports: [HARNESS_REGISTRY, HARNESS_SERVICE],
})
export class HarnessModule {}
@@ -4,42 +4,8 @@
* String tokens follow the existing Gateway convention (see `memory/memory.tokens.ts`)
* and remain valid Nest `InjectionToken`s for `@Inject(...)`.
*/
import type { HarnessConversationService } from '@mosaicstack/types';
export const HARNESS_REGISTRY = 'HARNESS_REGISTRY' as const;
export const HARNESS_SERVICE = 'HARNESS_SERVICE' as const;
export type HarnessRegistryToken = typeof HARNESS_REGISTRY;
export type HarnessServiceToken = typeof HARNESS_SERVICE;
/**
* Token for the {@link HarnessConversationService} that {@link HarnessChatRuntime}
* depends on. Until Task 14 provides a real implementation, `HarnessModule` binds
* the {@link HARNESS_CONVERSATION_SERVICE_UNAVAILABLE} sentinel here, and the
* `pi-rpc` router treats that sentinel as a hard, typed startup failure.
*/
export const HARNESS_CONVERSATION_SERVICE = 'HARNESS_CONVERSATION_SERVICE' as const;
export type HarnessConversationServiceToken = typeof HARNESS_CONVERSATION_SERVICE;
/**
* Explicit "not yet bound" value for {@link HARNESS_CONVERSATION_SERVICE}. It is a
* distinct sentinel — never `null`/`undefined` — so an unbound service is an
* intentional, checkable state rather than an accidental nil that could read as
* "present". Replaced by a real service in Task 14.
*/
export const HARNESS_CONVERSATION_SERVICE_UNAVAILABLE: unique symbol = Symbol(
'HARNESS_CONVERSATION_SERVICE_UNAVAILABLE',
);
/** A binding for {@link HARNESS_CONVERSATION_SERVICE}: a real service or the sentinel. */
export type HarnessConversationServiceBinding =
| HarnessConversationService
| typeof HARNESS_CONVERSATION_SERVICE_UNAVAILABLE;
/** Narrows a binding to a usable service, excluding the unavailable sentinel. */
export function isHarnessConversationServiceAvailable(
binding: HarnessConversationServiceBinding,
): binding is HarnessConversationService {
return binding !== HARNESS_CONVERSATION_SERVICE_UNAVAILABLE;
}
@@ -1,247 +0,0 @@
import { mkdtemp, rm } from 'node:fs/promises';
import { randomUUID } from 'node:crypto';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { Test, type TestingModule } from '@nestjs/testing';
import {
companies,
createPgliteDb,
eq,
estates,
hierarchyAuditEvents,
hierarchyOutbox,
platformProjects,
runPgliteMigrations,
type DbHandle,
} from '@mosaicstack/db';
import { DB } from '../database/database.module.js';
import {
HierarchyAuditIdempotencyConflictError,
HierarchyAuditRepository,
HierarchyNodeNotFoundError,
type AppendHierarchyEventInput,
} from './hierarchy-audit.repository.js';
/**
* Repository-level §6.4 witnesses for the hierarchy audit machinery
* (contract 1 §5.2, REQ-AUD-001): same-transaction atomicity of state +
* event + outbox, rollback leaving no residue, idempotent replay, snapshot
* parent chains, events surviving target deletion, per-target ordering, and
* the outbox claim/complete/release CAS. The schema-level constraints are
* witnessed in packages/db/src/hierarchy-audit.witness.test.ts.
*/
describe('hierarchy audit repository integration', (): void => {
let dataDir: string;
let handle: DbHandle;
let moduleRef: TestingModule;
let repo: HierarchyAuditRepository;
const input = (
overrides: Partial<AppendHierarchyEventInput> = {},
): AppendHierarchyEventInput => ({
actorId: 'user-actor',
verb: 'create',
targetKind: 'company',
targetId: randomUUID(),
targetSnapshot: { id: 'x', slug: 'x', name: 'x', parentChain: [] },
correlationId: 'corr-1',
idempotencyKey: `key-${randomUUID()}`,
...overrides,
});
beforeAll(async (): Promise<void> => {
dataDir = await mkdtemp(join(tmpdir(), 'mosaic-gateway-hierarchy-audit-'));
handle = createPgliteDb(dataDir);
await runPgliteMigrations(handle);
moduleRef = await Test.createTestingModule({
providers: [HierarchyAuditRepository, { provide: DB, useValue: handle.db }],
}).compile();
repo = moduleRef.get(HierarchyAuditRepository);
});
afterAll(async (): Promise<void> => {
await moduleRef.close();
await handle.close();
await rm(dataDir, { recursive: true, force: true });
});
it('commits state, event, and outbox record atomically in one transaction', async () => {
const companyId = randomUUID();
const key = `key-${randomUUID()}`;
await handle.db.transaction(async (tx) => {
await tx.insert(companies).values({ id: companyId, name: 'Atomic Co', slug: 'atomic-co' });
const snapshot = await repo.snapshot(tx, 'company', companyId);
const result = await repo.append(tx, {
...input({ targetId: companyId, idempotencyKey: key }),
targetSnapshot: { ...snapshot },
});
expect(result.replayed).toBe(false);
expect(result.event.idempotencyKey).toBe(key);
});
const events = await handle.db
.select()
.from(hierarchyAuditEvents)
.where(eq(hierarchyAuditEvents.idempotencyKey, key));
expect(events).toHaveLength(1);
const outbox = await handle.db
.select()
.from(hierarchyOutbox)
.where(eq(hierarchyOutbox.eventId, events[0]!.id));
expect(outbox).toHaveLength(1);
expect(outbox[0]).toMatchObject({
status: 'pending',
idempotencyKey: key,
correlationId: 'corr-1',
});
});
it('a rolled-back transaction leaves no state, no event, and no outbox record', async () => {
const companyId = randomUUID();
const key = `key-${randomUUID()}`;
await expect(
handle.db.transaction(async (tx) => {
await tx.insert(companies).values({ id: companyId, name: 'Doomed Co', slug: 'doomed-co' });
await repo.append(tx, input({ targetId: companyId, idempotencyKey: key }));
throw new Error('deliberate rollback');
}),
).rejects.toThrow('deliberate rollback');
const [companyRows, eventRows, outboxRows] = await Promise.all([
handle.db.select().from(companies).where(eq(companies.id, companyId)),
handle.db
.select()
.from(hierarchyAuditEvents)
.where(eq(hierarchyAuditEvents.idempotencyKey, key)),
handle.db.select().from(hierarchyOutbox).where(eq(hierarchyOutbox.idempotencyKey, key)),
]);
expect(companyRows).toHaveLength(0);
expect(eventRows).toHaveLength(0);
expect(outboxRows).toHaveLength(0);
});
it('replays a duplicate idempotency key without inserting a second event or outbox record', async () => {
const first = input();
const original = await handle.db.transaction(async (tx) => repo.append(tx, first));
const replay = await handle.db.transaction(async (tx) => repo.append(tx, first));
expect(original.replayed).toBe(false);
expect(replay.replayed).toBe(true);
expect(replay.event.id).toBe(original.event.id);
const outbox = await handle.db
.select()
.from(hierarchyOutbox)
.where(eq(hierarchyOutbox.eventId, original.event.id));
expect(outbox).toHaveLength(1);
});
it('throws on a duplicate idempotency key carrying different event content', async () => {
const first = input();
await handle.db.transaction(async (tx) => repo.append(tx, first));
await expect(
handle.db.transaction(async (tx) =>
repo.append(tx, { ...first, verb: 'rename', targetId: randomUUID() }),
),
).rejects.toThrow(HierarchyAuditIdempotencyConflictError);
});
it('throws on a duplicate idempotency key whose transfer destination differs', async () => {
const from = { kind: 'company' as const, id: randomUUID(), slug: 'src-co' };
const to = { kind: 'company' as const, id: randomUUID(), slug: 'dst-co' };
const first = input({
verb: 'transfer',
targetKind: 'estate',
transferFrom: from,
transferTo: to,
});
const original = await handle.db.transaction(async (tx) => repo.append(tx, first));
expect(original.replayed).toBe(false);
// Identical retry replays; a retry re-routed to a different destination must conflict.
const replay = await handle.db.transaction(async (tx) => repo.append(tx, first));
expect(replay.replayed).toBe(true);
await expect(
handle.db.transaction(async (tx) =>
repo.append(tx, { ...first, transferTo: { ...to, id: randomUUID() } }),
),
).rejects.toThrow(HierarchyAuditIdempotencyConflictError);
});
it('builds root-first parent chains and rejects unknown nodes', async () => {
const companyId = randomUUID();
const estateId = randomUUID();
const projectId = randomUUID();
await handle.db.transaction(async (tx) => {
await tx.insert(companies).values({ id: companyId, name: 'Chain Co', slug: 'chain-co' });
await tx
.insert(estates)
.values({ id: estateId, name: 'Chain Estate', slug: 'chain-estate', companyId });
await tx
.insert(platformProjects)
.values({ id: projectId, name: 'Chain Project', slug: 'chain-project', estateId });
});
const snapshot = await repo.snapshot(handle.db, 'platform_project', projectId);
expect(snapshot).toMatchObject({ id: projectId, slug: 'chain-project', name: 'Chain Project' });
expect(snapshot.parentChain).toEqual([
{ kind: 'company', id: companyId, slug: 'chain-co' },
{ kind: 'estate', id: estateId, slug: 'chain-estate' },
]);
await expect(repo.snapshot(handle.db, 'estate', randomUUID())).rejects.toThrow(
HierarchyNodeNotFoundError,
);
});
it('keeps events readable, in per-target seq order, after the target row is deleted', async () => {
const companyId = randomUUID();
await handle.db.transaction(async (tx) => {
await tx.insert(companies).values({ id: companyId, name: 'Mortal Co', slug: 'mortal-co' });
const snapshot = await repo.snapshot(tx, 'company', companyId);
await repo.append(tx, input({ targetId: companyId, targetSnapshot: { ...snapshot } }));
});
await handle.db.transaction(async (tx) => {
const snapshot = await repo.snapshot(tx, 'company', companyId);
await repo.append(tx, {
...input({ verb: 'delete', targetId: companyId }),
targetSnapshot: { ...snapshot },
});
await tx.delete(companies).where(eq(companies.id, companyId));
});
const events = await repo.eventsForTarget(companyId);
expect(events.map((e) => e.verb)).toEqual(['create', 'delete']);
expect(events[1]!.seq).toBeGreaterThan(events[0]!.seq);
expect((events[1]!.targetSnapshot as { id: string }).id).toBe(companyId);
});
it('claims the oldest pending outbox record exactly once, completes and releases by CAS', async () => {
// Drain records left pending by earlier cases so ordering is deterministic.
for (;;) {
const drained = await repo.claimPendingOutbox();
if (!drained) break;
await repo.completeOutbox(drained.id);
}
const older = await handle.db.transaction(async (tx) => repo.append(tx, input()));
const newer = await handle.db.transaction(async (tx) => repo.append(tx, input()));
const claimed = await repo.claimPendingOutbox();
expect(claimed).not.toBeNull();
expect(claimed!.eventId).toBe(older.event.id);
expect(claimed!.status).toBe('processing');
// Delivery fails: release returns it to pending and it is claimable again.
await repo.releaseOutbox(claimed!.id);
const reclaimed = await repo.claimPendingOutbox();
expect(reclaimed!.id).toBe(claimed!.id);
await repo.completeOutbox(reclaimed!.id);
const done = await handle.db
.select()
.from(hierarchyOutbox)
.where(eq(hierarchyOutbox.id, reclaimed!.id));
expect(done[0]!.status).toBe('delivered');
expect(done[0]!.deliveredAt).not.toBeNull();
// completeOutbox is CAS-guarded on 'processing': completing again is a no-op.
await repo.completeOutbox(reclaimed!.id);
const second = await repo.claimPendingOutbox();
expect(second!.eventId).toBe(newer.event.id);
await repo.completeOutbox(second!.id);
expect(await repo.claimPendingOutbox()).toBeNull();
});
});
@@ -1,274 +0,0 @@
import { Inject, Injectable } from '@nestjs/common';
import {
and,
asc,
companies,
eq,
estates,
hierarchyAuditEvents,
hierarchyOutbox,
platformProjects,
type Db,
type HIERARCHY_AUDIT_TARGET_KINDS,
type HIERARCHY_AUDIT_VERBS,
} from '@mosaicstack/db';
import { DB } from '../database/database.module.js';
/**
* Hierarchy audit event + outbox machinery (contract 1 §5.2).
*
* Every hierarchy mutation writes its semantic audit event AND the event's
* outbox record on the caller's transaction, so state, event, and outbox
* commit or roll back together. Events reference their target by an
* immutable snapshot (id, slug, parent chain at event time), never by a
* foreign key into the class tables — append-only events survive the
* deletion of their target. This module exposes no update or delete path
* for events: append-only is a property of the code surface, witnessed by
* the integration tests.
*
* This is NOT a class-table writer: it touches only the audit/outbox
* tables, so it does not appear on the writer-coverage allowlist. The
* hierarchy command repository (HierarchyRepository) is the allowlisted
* writer and calls into this on its own transactions.
*/
export type HierarchyAuditVerb = (typeof HIERARCHY_AUDIT_VERBS)[number];
export type HierarchyTargetKind = (typeof HIERARCHY_AUDIT_TARGET_KINDS)[number];
export type HierarchyNodeKind = Exclude<HierarchyTargetKind, 'grant'>;
export interface ParentChainEntry {
readonly kind: HierarchyNodeKind;
readonly id: string;
readonly slug: string;
}
/** Immutable node snapshot at event time; parentChain is root-first. */
export interface HierarchyNodeSnapshot {
readonly id: string;
readonly slug: string;
readonly name: string;
readonly parentChain: readonly ParentChainEntry[];
}
export interface AppendHierarchyEventInput {
readonly actorId: string;
readonly verb: HierarchyAuditVerb;
readonly targetKind: HierarchyTargetKind;
readonly targetId: string;
/** Node events: HierarchyNodeSnapshot. Grant events: subject/target/role snapshot (contract 2 §4.4). */
readonly targetSnapshot: Record<string, unknown>;
/** Present exactly on transfers (CHECK-enforced): source/destination parent { kind, id, slug }. */
readonly transferFrom?: ParentChainEntry;
readonly transferTo?: ParentChainEntry;
readonly correlationId: string;
/** Prior event in the causal chain (e.g. the delete event causing cascaded grant_revoke events). */
readonly causationId?: string;
readonly idempotencyKey: string;
}
export type HierarchyAuditEventRow = typeof hierarchyAuditEvents.$inferSelect;
export type HierarchyOutboxRow = typeof hierarchyOutbox.$inferSelect;
export interface AppendHierarchyEventResult {
readonly event: HierarchyAuditEventRow;
/** True when the idempotency key had already committed an identical event (REQ-AUD-001 duplicate suppression). */
readonly replayed: boolean;
}
type Tx = Pick<Db, 'insert' | 'select'>;
export class HierarchyAuditIdempotencyConflictError extends Error {
constructor(idempotencyKey: string) {
super(
`hierarchy audit idempotency key ${idempotencyKey} already exists with different event content`,
);
this.name = 'HierarchyAuditIdempotencyConflictError';
}
}
export class HierarchyNodeNotFoundError extends Error {
constructor(kind: HierarchyNodeKind, id: string) {
super(`hierarchy node not found: ${kind} ${id}`);
this.name = 'HierarchyNodeNotFoundError';
}
}
/**
* Append one audit event and its outbox record on the caller's transaction.
* A duplicate idempotency key with identical semantic content returns the
* prior event (replayed: true) without inserting anything; a duplicate key
* with different content throws.
*/
export async function appendHierarchyEvent(
tx: Tx,
input: AppendHierarchyEventInput,
): Promise<AppendHierarchyEventResult> {
const inserted = await tx
.insert(hierarchyAuditEvents)
.values({
actorId: input.actorId,
verb: input.verb,
targetKind: input.targetKind,
targetId: input.targetId,
targetSnapshot: input.targetSnapshot,
transferFrom: input.transferFrom ?? null,
transferTo: input.transferTo ?? null,
correlationId: input.correlationId,
causationId: input.causationId ?? null,
idempotencyKey: input.idempotencyKey,
})
.onConflictDoNothing()
.returning();
const event = inserted[0];
if (event) {
await tx.insert(hierarchyOutbox).values({
eventId: event.id,
idempotencyKey: input.idempotencyKey,
correlationId: input.correlationId,
});
return { event, replayed: false };
}
const prior = await tx
.select()
.from(hierarchyAuditEvents)
.where(eq(hierarchyAuditEvents.idempotencyKey, input.idempotencyKey))
.limit(1);
const existing = prior[0];
if (!existing || !sameEvent(existing, input)) {
throw new HierarchyAuditIdempotencyConflictError(input.idempotencyKey);
}
// Event and outbox committed atomically the first time, so the outbox
// record already exists; a replay inserts nothing.
return { event: existing, replayed: true };
}
/** Key-order-independent serialization: jsonb does not preserve key order. */
function canonicalJson(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
if (value !== null && typeof value === 'object') {
const record = value as Record<string, unknown>;
const body = Object.keys(record)
.sort()
.map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`)
.join(',');
return `{${body}}`;
}
return JSON.stringify(value);
}
function sameEvent(row: HierarchyAuditEventRow, input: AppendHierarchyEventInput): boolean {
return (
row.actorId === input.actorId &&
row.verb === input.verb &&
row.targetKind === input.targetKind &&
row.targetId === input.targetId &&
row.correlationId === input.correlationId &&
(row.causationId ?? null) === (input.causationId ?? null) &&
canonicalJson(row.targetSnapshot) === canonicalJson(input.targetSnapshot) &&
// Transfer source/destination are semantic content (§5.2): a retry with a
// different destination must conflict, never silently replay.
canonicalJson(row.transferFrom ?? null) === canonicalJson(input.transferFrom ?? null) &&
canonicalJson(row.transferTo ?? null) === canonicalJson(input.transferTo ?? null)
);
}
/**
* Build the immutable snapshot for a node: its row plus the parent chain up
* to the company root, root-first, read on the caller's transaction so the
* snapshot is consistent with the mutation it audits.
*/
export async function buildNodeSnapshot(
tx: Tx,
kind: HierarchyNodeKind,
id: string,
): Promise<HierarchyNodeSnapshot> {
if (kind === 'company') {
const rows = await tx.select().from(companies).where(eq(companies.id, id)).limit(1);
const row = rows[0];
if (!row) throw new HierarchyNodeNotFoundError(kind, id);
return { id: row.id, slug: row.slug, name: row.name, parentChain: [] };
}
if (kind === 'estate') {
const rows = await tx.select().from(estates).where(eq(estates.id, id)).limit(1);
const row = rows[0];
if (!row) throw new HierarchyNodeNotFoundError(kind, id);
const parent = await buildNodeSnapshot(tx, 'company', row.companyId);
return {
id: row.id,
slug: row.slug,
name: row.name,
parentChain: [...parent.parentChain, { kind: 'company', id: parent.id, slug: parent.slug }],
};
}
const rows = await tx.select().from(platformProjects).where(eq(platformProjects.id, id)).limit(1);
const row = rows[0];
if (!row) throw new HierarchyNodeNotFoundError(kind, id);
const parent = await buildNodeSnapshot(tx, 'estate', row.estateId);
return {
id: row.id,
slug: row.slug,
name: row.name,
parentChain: [...parent.parentChain, { kind: 'estate', id: parent.id, slug: parent.slug }],
};
}
@Injectable()
export class HierarchyAuditRepository {
constructor(@Inject(DB) private readonly db: Db) {}
/** Compose an event+outbox append into a caller-owned transaction. */
append(tx: Tx, input: AppendHierarchyEventInput): Promise<AppendHierarchyEventResult> {
return appendHierarchyEvent(tx, input);
}
snapshot(tx: Tx, kind: HierarchyNodeKind, id: string): Promise<HierarchyNodeSnapshot> {
return buildNodeSnapshot(tx, kind, id);
}
/** Per-target ordered event history (REQ-AUD-001 per-target ordering; read-only). */
async eventsForTarget(targetId: string): Promise<HierarchyAuditEventRow[]> {
return this.db
.select()
.from(hierarchyAuditEvents)
.where(eq(hierarchyAuditEvents.targetId, targetId))
.orderBy(asc(hierarchyAuditEvents.seq));
}
/**
* Claim the oldest pending outbox record (claim-by-CAS: the UPDATE is
* guarded on status so a lost race returns null and the caller retries).
*/
async claimPendingOutbox(): Promise<HierarchyOutboxRow | null> {
const candidates = await this.db
.select()
.from(hierarchyOutbox)
.where(eq(hierarchyOutbox.status, 'pending'))
.orderBy(asc(hierarchyOutbox.createdAt))
.limit(1);
const candidate = candidates[0];
if (!candidate) return null;
const claimed = await this.db
.update(hierarchyOutbox)
.set({ status: 'processing', updatedAt: new Date() })
.where(and(eq(hierarchyOutbox.id, candidate.id), eq(hierarchyOutbox.status, 'pending')))
.returning();
return claimed[0] ?? null;
}
async completeOutbox(id: string): Promise<void> {
const now = new Date();
await this.db
.update(hierarchyOutbox)
.set({ status: 'delivered', deliveredAt: now, updatedAt: now })
.where(and(eq(hierarchyOutbox.id, id), eq(hierarchyOutbox.status, 'processing')));
}
/** Return a claimed record to pending (delivery failed; it stays replayable). */
async releaseOutbox(id: string): Promise<void> {
await this.db
.update(hierarchyOutbox)
.set({ status: 'pending', updatedAt: new Date() })
.where(and(eq(hierarchyOutbox.id, id), eq(hierarchyOutbox.status, 'processing')));
}
}
@@ -1,965 +0,0 @@
import { mkdtemp, rm } from 'node:fs/promises';
import { randomUUID } from 'node:crypto';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { Test, type TestingModule } from '@nestjs/testing';
import {
companies,
createPgliteDb,
eq,
estates,
hierarchyAuditEvents,
hierarchyGrants,
hierarchyOutbox,
runPgliteMigrations,
teams,
users,
workspaces,
type DbHandle,
} from '@mosaicstack/db';
import { DB } from '../database/database.module.js';
import { appendHierarchyEvent } from './hierarchy-audit.repository.js';
import { HierarchyGrantEvaluationService } from './hierarchy-grant-evaluation.js';
import { HierarchyRepository, type HierarchyResult } from './hierarchy.repository.js';
/**
* Command-level witnesses for the hierarchy command family (M4-1b-ii):
* contract 1 §6.4 (per-mutation-class commit + rollback), §6.5
* (authorization outcomes), §6.7 (no existence oracle), §6.9 (visibility),
* and contract 2 §3 grant-evaluation semantics (deny-by-default,
* ancestor-chain inheritance, max-role, live revocation, suspended team
* subjects). Schema-level constraints are witnessed in
* packages/db/src/hierarchy-schema.witness.test.ts; the audit machinery's
* own atomicity in hierarchy-audit.integration.test.ts.
*
* The rollback legs pre-seed an audit event under the command's idempotency
* key with different content: the command's append then throws inside the
* command transaction, so the whole mutation must roll back the command
* returns `conflict` and leaves no state change, no second event, and no
* second outbox record.
*/
describe('hierarchy commands integration', (): void => {
let dataDir: string;
let handle: DbHandle;
let moduleRef: TestingModule;
let repo: HierarchyRepository;
let evaluation: HierarchyGrantEvaluationService;
const OWNER = 'hier-cmd-owner';
const ADMIN = 'hier-cmd-admin';
const STRANGER = 'hier-cmd-stranger';
const SUBJECT = 'hier-cmd-subject';
/** Base fixture: OWNER's company (created through the command surface). */
let companyId: string;
const slug = (prefix: string): string => `${prefix}-${randomUUID().slice(0, 8)}`;
function expectOk<T>(result: HierarchyResult<T>): { ok: true } & T {
if (!result.ok) throw new Error(`expected ok, got ${JSON.stringify(result)}`);
return result;
}
const eventsForKey = (key: string) =>
handle.db
.select()
.from(hierarchyAuditEvents)
.where(eq(hierarchyAuditEvents.idempotencyKey, key));
const outboxForKey = (key: string) =>
handle.db.select().from(hierarchyOutbox).where(eq(hierarchyOutbox.idempotencyKey, key));
/** Occupy `key` with unrelated event content so a command reusing it must abort. */
const seedConflictingKey = async (key: string): Promise<void> => {
await handle.db.transaction(async (tx) =>
appendHierarchyEvent(tx, {
actorId: 'seed-actor',
verb: 'create',
targetKind: 'company',
targetId: randomUUID(),
targetSnapshot: { seeded: true },
correlationId: 'seed-correlation',
idempotencyKey: key,
}),
);
};
/**
* §6.4 rollback leg: the command must return `conflict` and leave exactly
* the seeded event/outbox pair under the key nothing it wrote survives.
*/
const expectRolledBack = async <T>(
key: string,
command: () => Promise<HierarchyResult<T>>,
assertUnchanged: () => Promise<void>,
): Promise<void> => {
await seedConflictingKey(key);
const result = await command();
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toBe('conflict');
expect(await eventsForKey(key)).toHaveLength(1);
expect(await outboxForKey(key)).toHaveLength(1);
await assertUnchanged();
};
beforeAll(async (): Promise<void> => {
dataDir = await mkdtemp(join(tmpdir(), 'mosaic-gateway-hierarchy-commands-'));
handle = createPgliteDb(dataDir);
await runPgliteMigrations(handle);
moduleRef = await Test.createTestingModule({
providers: [
HierarchyRepository,
HierarchyGrantEvaluationService,
{ provide: DB, useValue: handle.db },
],
}).compile();
repo = moduleRef.get(HierarchyRepository);
evaluation = moduleRef.get(HierarchyGrantEvaluationService);
await handle.db.insert(users).values([
{ id: OWNER, name: 'Owner', email: `${OWNER}@example.com` },
{ id: ADMIN, name: 'Admin', email: `${ADMIN}@example.com`, role: 'admin' },
{ id: STRANGER, name: 'Stranger', email: `${STRANGER}@example.com` },
{ id: SUBJECT, name: 'Subject', email: `${SUBJECT}@example.com` },
]);
const created = expectOk(
await repo.createCompany({ actorId: OWNER, name: 'Base Co', slug: slug('base') }),
);
companyId = created.company.id;
});
afterAll(async (): Promise<void> => {
await moduleRef.close();
await handle.close();
await rm(dataDir, { recursive: true, force: true });
});
// ── §6.4 commit legs ───────────────────────────────────────────────────────
it('createCompany commits company, owner grant, causation-linked events, and outbox atomically', async () => {
const key = `key-${randomUUID()}`;
const result = expectOk(
await repo.createCompany({
actorId: OWNER,
name: 'Atomic Co',
slug: slug('atomic'),
idempotencyKey: key,
}),
);
expect(result.company.visibility).toBe('private');
expect(result.grant.role).toBe('hierarchy:owner');
expect(result.grant.userId).toBe(OWNER);
expect(result.grant.grantedBy).toBe(OWNER);
const [createEvents, grantEvents] = await Promise.all([
eventsForKey(key),
eventsForKey(`${key}:grant`),
]);
expect(createEvents).toHaveLength(1);
expect(createEvents[0]).toMatchObject({ verb: 'create', targetId: result.company.id });
expect(grantEvents).toHaveLength(1);
expect(grantEvents[0]).toMatchObject({ verb: 'grant_create', targetId: result.grant.id });
// The grant event is caused by the create event, same correlation (§4.3).
expect(grantEvents[0]!.causationId).toBe(createEvents[0]!.id);
expect(grantEvents[0]!.correlationId).toBe(createEvents[0]!.correlationId);
expect(await outboxForKey(key)).toHaveLength(1);
expect(await outboxForKey(`${key}:grant`)).toHaveLength(1);
const rows = await handle.db
.select()
.from(companies)
.where(eq(companies.id, result.company.id));
expect(rows).toHaveLength(1);
});
it('deleteCompany commits the delete with one audited grant_revoke per cascaded grant', async () => {
const created = expectOk(
await repo.createCompany({ actorId: OWNER, name: 'Mortal Co', slug: slug('mortal') }),
);
const extraGrant = expectOk(
await repo.createGrant({
actorId: OWNER,
userId: SUBJECT,
targetKind: 'company',
targetId: created.company.id,
role: 'viewer',
}),
);
const key = `key-${randomUUID()}`;
expectOk(
await repo.deleteCompany({
actorId: OWNER,
companyId: created.company.id,
idempotencyKey: key,
}),
);
const deleteEvents = await eventsForKey(key);
expect(deleteEvents).toHaveLength(1);
expect(deleteEvents[0]).toMatchObject({ verb: 'delete', targetId: created.company.id });
for (const grantId of [created.grant.id, extraGrant.grant.id]) {
const revokeEvents = await eventsForKey(`${key}:revoke:${grantId}`);
expect(revokeEvents).toHaveLength(1);
expect(revokeEvents[0]).toMatchObject({ verb: 'grant_revoke', targetId: grantId });
expect(revokeEvents[0]!.causationId).toBe(deleteEvents[0]!.id);
}
expect(
await handle.db.select().from(companies).where(eq(companies.id, created.company.id)),
).toHaveLength(0);
});
// ── §6.4 rollback legs (one per mutation class) ────────────────────────────
it('renameCompany commits the rename with an audited event carrying previousName', async () => {
const created = expectOk(
await repo.createCompany({ actorId: OWNER, name: 'Old Name Co', slug: slug('rename') }),
);
const key = `key-${randomUUID()}`;
const renamed = expectOk(
await repo.renameCompany({
actorId: OWNER,
companyId: created.company.id,
name: 'New Name Co',
idempotencyKey: key,
}),
);
expect(renamed.company.name).toBe('New Name Co');
const events = await eventsForKey(key);
expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({ verb: 'rename', targetId: created.company.id });
// §6.4: the audited rename carries the old and new names.
expect(events[0]!.targetSnapshot).toMatchObject({
name: 'New Name Co',
previousName: 'Old Name Co',
});
expect(await outboxForKey(key)).toHaveLength(1);
const rows = await handle.db
.select()
.from(companies)
.where(eq(companies.id, created.company.id));
expect(rows[0]!.name).toBe('New Name Co');
});
it('revokeGrant commits the row deletion with one audited grant_revoke event', async () => {
const grant = expectOk(
await repo.createGrant({
actorId: OWNER,
userId: SUBJECT,
targetKind: 'company',
targetId: companyId,
role: 'viewer',
}),
);
const key = `key-${randomUUID()}`;
const revoked = expectOk(
await repo.revokeGrant({ actorId: OWNER, grantId: grant.grant.id, idempotencyKey: key }),
);
expect(revoked.revokedId).toBe(grant.grant.id);
const events = await eventsForKey(key);
expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({ verb: 'grant_revoke', targetId: grant.grant.id });
expect(await outboxForKey(key)).toHaveLength(1);
// §6 revocation = row deletion: the grant row is gone.
const rows = await handle.db
.select()
.from(hierarchyGrants)
.where(eq(hierarchyGrants.id, grant.grant.id));
expect(rows).toHaveLength(0);
});
it('rolls back a create: no estate row survives the aborted transaction', async () => {
const estateSlug = slug('rb-create');
const key = `key-${randomUUID()}`;
await expectRolledBack(
key,
() =>
repo.createEstate({
actorId: OWNER,
companyId,
name: 'Doomed Estate',
slug: estateSlug,
idempotencyKey: key,
}),
async () => {
expect(
await handle.db.select().from(estates).where(eq(estates.slug, estateSlug)),
).toHaveLength(0);
},
);
});
it('rolls back a rename: the company keeps its name', async () => {
const before = (
await handle.db.select().from(companies).where(eq(companies.id, companyId))
)[0]!;
const key = `key-${randomUUID()}`;
await expectRolledBack(
key,
() => repo.renameCompany({ actorId: OWNER, companyId, name: 'Never', idempotencyKey: key }),
async () => {
const after = (
await handle.db.select().from(companies).where(eq(companies.id, companyId))
)[0]!;
expect(after.name).toBe(before.name);
},
);
});
it('rolls back a visibility change: the company stays private', async () => {
const key = `key-${randomUUID()}`;
await expectRolledBack(
key,
() =>
repo.changeCompanyVisibility({
actorId: ADMIN,
companyId,
visibility: 'directory',
idempotencyKey: key,
}),
async () => {
const after = (
await handle.db.select().from(companies).where(eq(companies.id, companyId))
)[0]!;
expect(after.visibility).toBe('private');
},
);
});
it('rolls back a transfer: the estate keeps its parent', async () => {
const estate = expectOk(
await repo.createEstate({ actorId: OWNER, companyId, name: 'RB-T', slug: slug('rb-t') }),
);
const other = expectOk(
await repo.createCompany({ actorId: OWNER, name: 'RB Dest', slug: slug('rb-dest') }),
);
const key = `key-${randomUUID()}`;
await expectRolledBack(
key,
() =>
repo.transferEstate({
actorId: OWNER,
estateId: estate.estate.id,
destinationCompanyId: other.company.id,
idempotencyKey: key,
}),
async () => {
const after = (
await handle.db.select().from(estates).where(eq(estates.id, estate.estate.id))
)[0]!;
expect(after.companyId).toBe(companyId);
},
);
});
it('rolls back a delete: the estate row survives', async () => {
const estate = expectOk(
await repo.createEstate({ actorId: OWNER, companyId, name: 'RB-D', slug: slug('rb-d') }),
);
const key = `key-${randomUUID()}`;
await expectRolledBack(
key,
() => repo.deleteEstate({ actorId: OWNER, estateId: estate.estate.id, idempotencyKey: key }),
async () => {
expect(
await handle.db.select().from(estates).where(eq(estates.id, estate.estate.id)),
).toHaveLength(1);
},
);
});
it('rolls back a grant create: no grant row survives', async () => {
const key = `key-${randomUUID()}`;
await expectRolledBack(
key,
() =>
repo.createGrant({
actorId: OWNER,
userId: STRANGER,
targetKind: 'company',
targetId: companyId,
role: 'viewer',
idempotencyKey: key,
}),
async () => {
const rows = await handle.db
.select()
.from(hierarchyGrants)
.where(eq(hierarchyGrants.userId, STRANGER));
expect(rows.filter((r) => r.companyId === companyId)).toHaveLength(0);
},
);
});
it('rolls back a grant change and a grant revoke: the grant keeps its role and its row', async () => {
const grant = expectOk(
await repo.createGrant({
actorId: OWNER,
userId: SUBJECT,
targetKind: 'company',
targetId: companyId,
role: 'viewer',
}),
);
const changeKey = `key-${randomUUID()}`;
await expectRolledBack(
changeKey,
() =>
repo.changeGrant({
actorId: OWNER,
grantId: grant.grant.id,
role: 'member',
idempotencyKey: changeKey,
}),
async () => {
const row = (
await handle.db
.select()
.from(hierarchyGrants)
.where(eq(hierarchyGrants.id, grant.grant.id))
)[0]!;
expect(row.role).toBe('viewer');
},
);
const revokeKey = `key-${randomUUID()}`;
await expectRolledBack(
revokeKey,
() =>
repo.revokeGrant({ actorId: OWNER, grantId: grant.grant.id, idempotencyKey: revokeKey }),
async () => {
expect(
await handle.db
.select()
.from(hierarchyGrants)
.where(eq(hierarchyGrants.id, grant.grant.id)),
).toHaveLength(1);
},
);
expectOk(await repo.revokeGrant({ actorId: OWNER, grantId: grant.grant.id }));
});
it('replays a completed command idempotently through the audit machinery', async () => {
const key = `key-${randomUUID()}`;
const input = { actorId: OWNER, companyId, name: 'Replayed Estate', slug: slug('replay') };
const first = expectOk(await repo.createEstate({ ...input, idempotencyKey: key }));
// The retry's insert no-ops on the slug conflict — the command surfaces
// `conflict`, and crucially appends no second event under the key.
const retry = await repo.createEstate({ ...input, idempotencyKey: key });
expect(retry.ok).toBe(false);
expect(await eventsForKey(key)).toHaveLength(1);
expectOk(await repo.deleteEstate({ actorId: OWNER, estateId: first.estate.id }));
});
// ── §6.5 authorization ─────────────────────────────────────────────────────
it('deny-by-default: a user with no grant cannot mutate and sees not_found (§3.1)', async () => {
expect(await repo.renameCompany({ actorId: STRANGER, companyId, name: 'x' })).toEqual({
ok: false,
error: 'not_found',
});
expect(
await repo.createEstate({ actorId: STRANGER, companyId, name: 'x', slug: slug('deny') }),
).toEqual({ ok: false, error: 'not_found' });
expect(await repo.deleteCompany({ actorId: STRANGER, companyId })).toEqual({
ok: false,
error: 'not_found',
});
});
it('grant management requires effective owner: member and viewer are refused (§4.1)', async () => {
const grant = expectOk(
await repo.createGrant({
actorId: OWNER,
userId: SUBJECT,
targetKind: 'company',
targetId: companyId,
role: 'member',
}),
);
expect(
await repo.createGrant({
actorId: SUBJECT,
userId: STRANGER,
targetKind: 'company',
targetId: companyId,
role: 'viewer',
}),
).toEqual({ ok: false, error: 'not_found' });
expect(await repo.revokeGrant({ actorId: SUBJECT, grantId: grant.grant.id })).toEqual({
ok: false,
error: 'not_found',
});
// Member also cannot create children (owner-only, §4.1/§4.3).
expect(
await repo.createEstate({ actorId: SUBJECT, companyId, name: 'x', slug: slug('member') }),
).toEqual({ ok: false, error: 'not_found' });
expectOk(await repo.revokeGrant({ actorId: OWNER, grantId: grant.grant.id }));
});
it('platform admin confers no tenant content access (§1.1): ungrated admin is a stranger', async () => {
expect(await repo.renameCompany({ actorId: ADMIN, companyId, name: 'x' })).toEqual({
ok: false,
error: 'not_found',
});
expect(
await repo.createGrant({
actorId: ADMIN,
userId: SUBJECT,
targetKind: 'company',
targetId: companyId,
role: 'viewer',
}),
).toEqual({ ok: false, error: 'not_found' });
expect(await repo.listGrantedCompanies(ADMIN)).toEqual([]);
expect(await evaluation.effectiveRole(ADMIN, 'company', companyId)).toBeNull();
});
it('visibility change is platform-admin-only (§5.5): the owner is forbidden, the admin succeeds', async () => {
const owned = await repo.changeCompanyVisibility({
actorId: OWNER,
companyId,
visibility: 'directory',
});
expect(owned).toEqual({
ok: false,
error: 'forbidden',
message: 'visibility change is platform-admin-only',
});
const changed = expectOk(
await repo.changeCompanyVisibility({ actorId: ADMIN, companyId, visibility: 'directory' }),
);
expect(changed.company.visibility).toBe('directory');
// Restore for later witnesses.
expectOk(
await repo.changeCompanyVisibility({ actorId: ADMIN, companyId, visibility: 'private' }),
);
});
// ── §6.9 visibility ────────────────────────────────────────────────────────
it('directory lists exactly directory-class companies with closed fields (§2.8)', async () => {
const listed = expectOk(
await repo.createCompany({ actorId: OWNER, name: 'Listed Co', slug: slug('listed') }),
);
const unlisted = expectOk(
await repo.createCompany({ actorId: OWNER, name: 'Unlisted Co', slug: slug('unlisted') }),
);
const key = `key-${randomUUID()}`;
expectOk(
await repo.changeCompanyVisibility({
actorId: ADMIN,
companyId: listed.company.id,
visibility: 'directory',
idempotencyKey: key,
}),
);
const directory = await repo.listDirectory();
const ids = directory.map((entry) => entry.id);
expect(ids).toContain(listed.company.id);
expect(ids).not.toContain(unlisted.company.id);
expect(ids).not.toContain(companyId);
// Closed-field: existence, name, slug — nothing else (no visibility, no
// timestamps, no grant or membership data).
for (const entry of directory) {
expect(Object.keys(entry).sort()).toEqual(['id', 'name', 'slug']);
}
// §5.5: the audited event carries old and new values.
const events = await eventsForKey(key);
expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({ verb: 'visibility_change', targetId: listed.company.id });
expect(events[0]!.targetSnapshot).toMatchObject({
previousVisibility: 'private',
visibility: 'directory',
});
});
it('directory disclosure confers no authority: a listed company still refuses non-granted callers (§6.9)', async () => {
const listed = expectOk(
await repo.createCompany({ actorId: OWNER, name: 'Exposed Co', slug: slug('exposed') }),
);
expectOk(
await repo.changeCompanyVisibility({
actorId: ADMIN,
companyId: listed.company.id,
visibility: 'directory',
}),
);
// The company is directory-listed for the whole probe window...
expect((await repo.listDirectory()).map((entry) => entry.id)).toContain(listed.company.id);
// ...but the non-granted reader's granted-read surface still excludes it:
// directory disclosure adds existence/name/slug only, never content access.
expect(await repo.listGrantedCompanies(STRANGER)).toEqual([]);
// A stranger mutation of the listed company is refused exactly like a
// missing node — the §6.7 carve-out covers the listing, not commands.
const realProbe = await repo.renameCompany({
actorId: STRANGER,
companyId: listed.company.id,
name: 'x',
});
const missingProbe = await repo.renameCompany({
actorId: STRANGER,
companyId: randomUUID(),
name: 'x',
});
expect(realProbe).toEqual(missingProbe);
expect(await repo.deleteCompany({ actorId: STRANGER, companyId: listed.company.id })).toEqual({
ok: false,
error: 'not_found',
});
});
it('granted companies are the reader control: owner sees them, a stranger sees nothing (§2.8)', async () => {
const ownerCompanies = await repo.listGrantedCompanies(OWNER);
expect(ownerCompanies.map((c) => c.id)).toContain(companyId);
expect(await repo.listGrantedCompanies(STRANGER)).toEqual([]);
});
// ── §6.7 no existence oracle ───────────────────────────────────────────────
it('an unauthorized probe of a real node is indistinguishable from a missing node', async () => {
const realCompany = await repo.renameCompany({ actorId: STRANGER, companyId, name: 'x' });
const missingCompany = await repo.renameCompany({
actorId: STRANGER,
companyId: randomUUID(),
name: 'x',
});
expect(realCompany).toEqual(missingCompany);
const estate = expectOk(
await repo.createEstate({
actorId: OWNER,
companyId,
name: 'Oracle E',
slug: slug('oracle'),
}),
);
const realEstate = await repo.deleteEstate({ actorId: STRANGER, estateId: estate.estate.id });
const missingEstate = await repo.deleteEstate({ actorId: STRANGER, estateId: randomUUID() });
expect(realEstate).toEqual(missingEstate);
const grant = expectOk(
await repo.createGrant({
actorId: OWNER,
userId: SUBJECT,
targetKind: 'estate',
targetId: estate.estate.id,
role: 'viewer',
}),
);
const realGrant = await repo.revokeGrant({ actorId: STRANGER, grantId: grant.grant.id });
const missingGrant = await repo.revokeGrant({ actorId: STRANGER, grantId: randomUUID() });
expect(realGrant).toEqual(missingGrant);
expectOk(await repo.deleteEstate({ actorId: OWNER, estateId: estate.estate.id }));
});
// ── contract 2 §3 grant evaluation ─────────────────────────────────────────
it('a company grant confers its role down the whole chain, workspace included (§3.2)', async () => {
const estate = expectOk(
await repo.createEstate({ actorId: OWNER, companyId, name: 'Chain E', slug: slug('chain') }),
);
const project = expectOk(
await repo.createPlatformProject({
actorId: OWNER,
estateId: estate.estate.id,
name: 'Chain P',
slug: slug('chain-p'),
}),
);
// Workspaces are evaluable but not hierarchy commands; seed one directly.
const workspaceId = randomUUID();
await handle.db.insert(workspaces).values({
id: workspaceId,
name: 'Chain W',
slug: slug('chain-w'),
platformProjectId: project.platformProject.id,
});
for (const [kind, id] of [
['company', companyId],
['estate', estate.estate.id],
['platform_project', project.platformProject.id],
['workspace', workspaceId],
] as const) {
expect(await evaluation.effectiveRole(OWNER, kind, id)).toBe('owner');
expect(await evaluation.effectiveRole(STRANGER, kind, id)).toBeNull();
}
// Max-role (§3.3): viewer on the company + owner on the estate → owner at
// and below the estate, viewer at the company.
const viewerGrant = expectOk(
await repo.createGrant({
actorId: OWNER,
userId: SUBJECT,
targetKind: 'company',
targetId: companyId,
role: 'viewer',
}),
);
const ownerGrant = expectOk(
await repo.createGrant({
actorId: OWNER,
userId: SUBJECT,
targetKind: 'estate',
targetId: estate.estate.id,
role: 'owner',
}),
);
expect(await evaluation.effectiveRole(SUBJECT, 'company', companyId)).toBe('viewer');
expect(await evaluation.effectiveRole(SUBJECT, 'estate', estate.estate.id)).toBe('owner');
expect(await evaluation.effectiveRole(SUBJECT, 'workspace', workspaceId)).toBe('owner');
// Revocation is row deletion and denies the very next evaluation (§6).
expectOk(await repo.revokeGrant({ actorId: OWNER, grantId: ownerGrant.grant.id }));
expect(await evaluation.effectiveRole(SUBJECT, 'estate', estate.estate.id)).toBe('viewer');
expectOk(await repo.revokeGrant({ actorId: OWNER, grantId: viewerGrant.grant.id }));
expect(await evaluation.effectiveRole(SUBJECT, 'company', companyId)).toBeNull();
await handle.db.delete(workspaces).where(eq(workspaces.id, workspaceId));
expectOk(
await repo.deletePlatformProject({
actorId: OWNER,
platformProjectId: project.platformProject.id,
}),
);
expectOk(await repo.deleteEstate({ actorId: OWNER, estateId: estate.estate.id }));
});
it('team grant subjects are suspended: a team row confers nothing and cannot be changed (§1.4)', async () => {
const teamId = randomUUID();
await handle.db.insert(teams).values({
id: teamId,
name: slug('team'),
slug: slug('team'),
ownerId: SUBJECT,
managerId: SUBJECT,
});
// Out-of-band team row (the command surface cannot create one).
const inserted = await handle.db
.insert(hierarchyGrants)
.values({ teamId, companyId, role: 'owner', grantedBy: OWNER })
.returning();
const teamGrantId = inserted[0]!.id;
// The team's own owner gains no effective role from it.
expect(await evaluation.effectiveRole(SUBJECT, 'company', companyId)).toBeNull();
// changeGrant refuses the row.
expect(
await repo.changeGrant({ actorId: OWNER, grantId: teamGrantId, role: 'viewer' }),
).toEqual({
ok: false,
error: 'conflict',
message: 'team grant subjects are suspended',
});
await handle.db.delete(hierarchyGrants).where(eq(hierarchyGrants.id, teamGrantId));
await handle.db.delete(teams).where(eq(teams.id, teamId));
});
// ── command conflict semantics ─────────────────────────────────────────────
it('transfer needs owner on both parents in its own transaction, and refuses no-op and colliding transfers (§5)', async () => {
const source = expectOk(
await repo.createCompany({ actorId: OWNER, name: 'Src Co', slug: slug('src') }),
);
const destination = expectOk(
await repo.createCompany({ actorId: SUBJECT, name: 'Dst Co', slug: slug('dst') }),
);
const estateSlug = slug('mv');
const estate = expectOk(
await repo.createEstate({
actorId: OWNER,
companyId: source.company.id,
name: 'Mv E',
slug: estateSlug,
}),
);
// OWNER owns the source but not the destination → not_found (§6.7-safe).
expect(
await repo.transferEstate({
actorId: OWNER,
estateId: estate.estate.id,
destinationCompanyId: destination.company.id,
}),
).toEqual({ ok: false, error: 'not_found' });
// Same-parent transfer is refused.
const samePlace = await repo.transferEstate({
actorId: OWNER,
estateId: estate.estate.id,
destinationCompanyId: source.company.id,
});
expect(samePlace.ok).toBe(false);
if (!samePlace.ok) expect(samePlace.error).toBe('conflict');
// Grant OWNER the destination; a slug collision there is refused.
expectOk(
await repo.createGrant({
actorId: SUBJECT,
userId: OWNER,
targetKind: 'company',
targetId: destination.company.id,
role: 'owner',
}),
);
expectOk(
await repo.createEstate({
actorId: OWNER,
companyId: destination.company.id,
name: 'Collide',
slug: estateSlug,
}),
);
const collision = await repo.transferEstate({
actorId: OWNER,
estateId: estate.estate.id,
destinationCompanyId: destination.company.id,
});
expect(collision.ok).toBe(false);
if (!collision.ok) expect(collision.error).toBe('conflict');
});
it('a successful transfer records transfer_from and transfer_to (§6.4 three-leg witness)', async () => {
const from = expectOk(
await repo.createCompany({ actorId: OWNER, name: 'From Co', slug: slug('from') }),
);
const to = expectOk(
await repo.createCompany({ actorId: OWNER, name: 'To Co', slug: slug('to') }),
);
const estate = expectOk(
await repo.createEstate({
actorId: OWNER,
companyId: from.company.id,
name: 'Moved E',
slug: slug('moved'),
}),
);
const key = `key-${randomUUID()}`;
expectOk(
await repo.transferEstate({
actorId: OWNER,
estateId: estate.estate.id,
destinationCompanyId: to.company.id,
idempotencyKey: key,
}),
);
const moved = (
await handle.db.select().from(estates).where(eq(estates.id, estate.estate.id))
)[0]!;
expect(moved.companyId).toBe(to.company.id);
const events = await eventsForKey(key);
expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({ verb: 'transfer', targetId: estate.estate.id });
expect(events[0]!.transferFrom).toMatchObject({ kind: 'company', id: from.company.id });
expect(events[0]!.transferTo).toMatchObject({ kind: 'company', id: to.company.id });
// The post-transfer snapshot's parent chain names the destination.
expect(events[0]!.targetSnapshot).toMatchObject({
parentChain: [{ kind: 'company', id: to.company.id, slug: to.company.slug }],
});
});
it('refuses duplicate slugs, deletes with children, and degenerate grant commands as conflicts', async () => {
const co = expectOk(
await repo.createCompany({ actorId: OWNER, name: 'Conflict Co', slug: slug('conf') }),
);
const dupSlug = await repo.createCompany({ actorId: OWNER, name: 'x', slug: co.company.slug });
expect(dupSlug.ok).toBe(false);
if (!dupSlug.ok) expect(dupSlug.error).toBe('conflict');
expectOk(
await repo.createEstate({
actorId: OWNER,
companyId: co.company.id,
name: 'Child',
slug: slug('child'),
}),
);
const withChildren = await repo.deleteCompany({ actorId: OWNER, companyId: co.company.id });
expect(withChildren.ok).toBe(false);
if (!withChildren.ok) expect(withChildren.error).toBe('conflict');
// Grant to a nonexistent subject is refused (the caller already holds
// owner, so the refusal discloses nothing new).
const ghost = await repo.createGrant({
actorId: OWNER,
userId: `missing-${randomUUID()}`,
targetKind: 'company',
targetId: co.company.id,
role: 'viewer',
});
expect(ghost.ok).toBe(false);
if (!ghost.ok) expect(ghost.error).toBe('conflict');
const grant = expectOk(
await repo.createGrant({
actorId: OWNER,
userId: SUBJECT,
targetKind: 'company',
targetId: co.company.id,
role: 'viewer',
}),
);
const duplicate = await repo.createGrant({
actorId: OWNER,
userId: SUBJECT,
targetKind: 'company',
targetId: co.company.id,
role: 'viewer',
});
expect(duplicate.ok).toBe(false);
if (!duplicate.ok) expect(duplicate.error).toBe('conflict');
const sameRole = await repo.changeGrant({
actorId: OWNER,
grantId: grant.grant.id,
role: 'viewer',
});
expect(sameRole.ok).toBe(false);
if (!sameRole.ok) expect(sameRole.error).toBe('conflict');
// A second grant with another role exists → changing the first onto that
// role would collide with the unique constraint; refused ahead of it.
const second = expectOk(
await repo.createGrant({
actorId: OWNER,
userId: SUBJECT,
targetKind: 'company',
targetId: co.company.id,
role: 'member',
}),
);
const collide = await repo.changeGrant({
actorId: OWNER,
grantId: grant.grant.id,
role: 'member',
});
expect(collide.ok).toBe(false);
if (!collide.ok) expect(collide.error).toBe('conflict');
// A clean change succeeds and records the previous role, namespaced (§4.5).
const changeKey = `key-${randomUUID()}`;
const changed = expectOk(
await repo.changeGrant({
actorId: OWNER,
grantId: second.grant.id,
role: 'owner',
idempotencyKey: changeKey,
}),
);
expect(changed.grant.role).toBe('hierarchy:owner');
const events = await eventsForKey(changeKey);
expect(events).toHaveLength(1);
expect(events[0]!.targetSnapshot).toMatchObject({
role: 'hierarchy:owner',
previousRole: 'hierarchy:member',
});
});
});
@@ -1,242 +0,0 @@
import { Inject, Injectable } from '@nestjs/common';
import {
companies,
eq,
estates,
hierarchyGrants,
inArray,
or,
platformProjects,
workspaces,
and,
type Db,
HIERARCHY_GRANT_ROLES,
} from '@mosaicstack/db';
import { DB } from '../database/database.module.js';
/**
* Hierarchy grant evaluation (contract 2 §3).
*
* Deny-by-default (§3.1): a user's effective role on a node is null unless a
* grant row explicitly confers one. Grants apply down the chain only (§3.2):
* the effective role on a node is the maximum role over grants targeting the
* node itself or any of its ancestors, maximum per the total order
* viewer member owner (§2). Evaluation is live and per-decision no
* caching so revocation (row deletion, §6) denies the next decision
* inherently. A missing node evaluates to null, indistinguishable from
* no-grant, which keeps unauthorized probes oracle-safe (contract 1 §6.7).
*
* Team grant subjects are SUSPENDED (§1.4): the command surface refuses to
* create them and this evaluator considers user-subject grants only, so a
* team row could not confer access even if one existed.
*
* Read-only module: it selects from the class tables but never writes them,
* so it does not appear on the writer-coverage allowlist.
*/
export type HierarchyGrantRole = (typeof HIERARCHY_GRANT_ROLES)[number];
/** Node kinds a grant may target (§3.2; workspace is evaluable, not grantable). */
export type GrantTargetKind = 'company' | 'estate' | 'platform_project';
/** Node kinds an authorization decision may be evaluated at (§3.2: down to workspace). */
export type EvaluableNodeKind = GrantTargetKind | 'workspace';
type Tx = Pick<Db, 'select'>;
/** Ancestor chain of a node, self included at its own level; ids only. */
export interface AncestorChain {
readonly companyId: string;
readonly estateId?: string;
readonly platformProjectId?: string;
readonly workspaceId?: string;
}
export function roleStrength(role: HierarchyGrantRole): number {
return HIERARCHY_GRANT_ROLES.indexOf(role);
}
export function roleAtLeast(
role: HierarchyGrantRole | null,
required: HierarchyGrantRole,
): boolean {
return role !== null && roleStrength(role) >= roleStrength(required);
}
/**
* Serialized role strings are namespaced (§4.5): audit events and API
* responses carry `hierarchy:owner`, never a bare `owner`.
*/
export function namespacedHierarchyRole(role: HierarchyGrantRole): string {
return `hierarchy:${role}`;
}
/**
* Resolve a node's ancestor chain (self included). Returns null when the
* node does not exist callers treat that exactly like no-grant (§3.1,
* oracle-safe).
*/
export async function resolveAncestorChain(
tx: Tx,
kind: EvaluableNodeKind,
id: string,
): Promise<AncestorChain | null> {
if (kind === 'company') {
const rows = await tx
.select({ id: companies.id })
.from(companies)
.where(eq(companies.id, id))
.limit(1);
const row = rows[0];
return row ? { companyId: row.id } : null;
}
if (kind === 'estate') {
const rows = await tx
.select({ id: estates.id, companyId: estates.companyId })
.from(estates)
.where(eq(estates.id, id))
.limit(1);
const row = rows[0];
return row ? { companyId: row.companyId, estateId: row.id } : null;
}
if (kind === 'platform_project') {
const rows = await tx
.select({
id: platformProjects.id,
estateId: platformProjects.estateId,
companyId: estates.companyId,
})
.from(platformProjects)
.innerJoin(estates, eq(estates.id, platformProjects.estateId))
.where(eq(platformProjects.id, id))
.limit(1);
const row = rows[0];
return row
? { companyId: row.companyId, estateId: row.estateId, platformProjectId: row.id }
: null;
}
const rows = await tx
.select({
id: workspaces.id,
platformProjectId: workspaces.platformProjectId,
estateId: platformProjects.estateId,
companyId: estates.companyId,
})
.from(workspaces)
.innerJoin(platformProjects, eq(platformProjects.id, workspaces.platformProjectId))
.innerJoin(estates, eq(estates.id, platformProjects.estateId))
.where(eq(workspaces.id, id))
.limit(1);
const row = rows[0];
return row
? {
companyId: row.companyId,
estateId: row.estateId,
platformProjectId: row.platformProjectId,
workspaceId: row.id,
}
: null;
}
function maxRole(roles: readonly string[]): HierarchyGrantRole | null {
let best: HierarchyGrantRole | null = null;
for (const candidate of roles) {
// Fail-closed: a value outside the vocabulary confers nothing.
if (!(HIERARCHY_GRANT_ROLES as readonly string[]).includes(candidate)) continue;
const role = candidate as HierarchyGrantRole;
if (best === null || roleStrength(role) > roleStrength(best)) best = role;
}
return best;
}
/**
* Effective role of a user on a node: maximum over the user's grants whose
* target is the node or any ancestor (§3.2); null = deny (§3.1). Missing
* node null.
*/
export async function evaluateEffectiveRole(
tx: Tx,
userId: string,
kind: EvaluableNodeKind,
id: string,
): Promise<HierarchyGrantRole | null> {
const chain = await resolveAncestorChain(tx, kind, id);
if (!chain) return null;
const targetConditions = [eq(hierarchyGrants.companyId, chain.companyId)];
if (chain.estateId) targetConditions.push(eq(hierarchyGrants.estateId, chain.estateId));
if (chain.platformProjectId) {
targetConditions.push(eq(hierarchyGrants.platformProjectId, chain.platformProjectId));
}
const rows = await tx
.select({ role: hierarchyGrants.role })
.from(hierarchyGrants)
.where(and(eq(hierarchyGrants.userId, userId), or(...targetConditions)));
return maxRole(rows.map((r) => r.role));
}
/**
* All companies on which the user holds any effective role, i.e. companies
* with a grant on the company itself or on any descendant (contract 1 §2.8:
* a grant anywhere in the subtree discloses the company's chain upward).
*/
export async function grantedCompanyIds(tx: Tx, userId: string): Promise<string[]> {
const grants = await tx
.select({
companyId: hierarchyGrants.companyId,
estateId: hierarchyGrants.estateId,
platformProjectId: hierarchyGrants.platformProjectId,
})
.from(hierarchyGrants)
.where(eq(hierarchyGrants.userId, userId));
const companyIds = new Set<string>();
const estateIds = new Set<string>();
const platformProjectIds = new Set<string>();
for (const grant of grants) {
if (grant.companyId) companyIds.add(grant.companyId);
else if (grant.estateId) estateIds.add(grant.estateId);
else if (grant.platformProjectId) platformProjectIds.add(grant.platformProjectId);
}
if (platformProjectIds.size > 0) {
const rows = await tx
.select({ estateId: platformProjects.estateId })
.from(platformProjects)
.where(inArray(platformProjects.id, [...platformProjectIds]));
for (const row of rows) estateIds.add(row.estateId);
}
if (estateIds.size > 0) {
const rows = await tx
.select({ companyId: estates.companyId })
.from(estates)
.where(inArray(estates.id, [...estateIds]));
for (const row of rows) companyIds.add(row.companyId);
}
return [...companyIds];
}
@Injectable()
export class HierarchyGrantEvaluationService {
constructor(@Inject(DB) private readonly db: Db) {}
/** Live per-decision evaluation; pass a tx to evaluate inside a command's transaction. */
effectiveRole(
userId: string,
kind: EvaluableNodeKind,
id: string,
tx?: Tx,
): Promise<HierarchyGrantRole | null> {
return evaluateEffectiveRole(tx ?? this.db, userId, kind, id);
}
async hasRole(
userId: string,
kind: EvaluableNodeKind,
id: string,
required: HierarchyGrantRole,
tx?: Tx,
): Promise<boolean> {
return roleAtLeast(await this.effectiveRole(userId, kind, id, tx), required);
}
}
@@ -1,297 +0,0 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
Param,
ParseUUIDPipe,
Post,
UseGuards,
} from '@nestjs/common';
import { AuthGuard } from '../auth/auth.guard.js';
import { CurrentUser } from '../auth/current-user.decorator.js';
import {
ChangeCompanyVisibilityDto,
ChangeGrantDto,
CreateCompanyDto,
CreateEstateDto,
CreateGrantDto,
CreatePlatformProjectDto,
DeleteNodeDto,
RenameNodeDto,
TransferEstateDto,
TransferPlatformProjectDto,
} from './hierarchy.dto.js';
import { HierarchyRepository } from './hierarchy.repository.js';
import { HierarchyService } from './hierarchy.service.js';
/**
* The hierarchy command family (contract 1 §5, §6.3). This controller is the
* closed HTTP surface over the hierarchy class tables: the route-inventory
* witness asserts these routes and no others exist. Delete commands take an
* optional body (idempotency key) via POST-style DTOs; every mutation is
* audited on its own transaction by the repository.
*/
@Controller('api/hierarchy')
@UseGuards(AuthGuard)
export class HierarchyController {
constructor(
private readonly repository: HierarchyRepository,
private readonly service: HierarchyService,
) {}
// ── companies ────────────────────────────────────────────────────────────
@Post('companies')
async createCompany(@CurrentUser() user: { id: string }, @Body() dto: CreateCompanyDto) {
return this.service.unwrap(
await this.repository.createCompany({
actorId: user.id,
name: dto.name,
slug: dto.slug,
idempotencyKey: dto.idempotencyKey,
}),
);
}
/** Companies the caller holds a grant on (directly or via a descendant). */
@Get('companies')
listGrantedCompanies(@CurrentUser() user: { id: string }) {
return this.repository.listGrantedCompanies(user.id);
}
/** Directory-class companies, closed-field (§2.8). */
@Get('companies/directory')
listDirectory() {
return this.repository.listDirectory();
}
@Post('companies/:id/rename')
@HttpCode(200)
async renameCompany(
@CurrentUser() user: { id: string },
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: RenameNodeDto,
) {
return this.service.unwrap(
await this.repository.renameCompany({
actorId: user.id,
companyId: id,
name: dto.name,
idempotencyKey: dto.idempotencyKey,
}),
);
}
@Post('companies/:id/visibility')
@HttpCode(200)
async changeCompanyVisibility(
@CurrentUser() user: { id: string },
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: ChangeCompanyVisibilityDto,
) {
return this.service.unwrap(
await this.repository.changeCompanyVisibility({
actorId: user.id,
companyId: id,
visibility: dto.visibility,
idempotencyKey: dto.idempotencyKey,
}),
);
}
@Delete('companies/:id')
async deleteCompany(
@CurrentUser() user: { id: string },
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: DeleteNodeDto,
) {
return this.service.unwrap(
await this.repository.deleteCompany({
actorId: user.id,
companyId: id,
idempotencyKey: dto?.idempotencyKey,
}),
);
}
// ── estates ──────────────────────────────────────────────────────────────
@Post('estates')
async createEstate(@CurrentUser() user: { id: string }, @Body() dto: CreateEstateDto) {
return this.service.unwrap(
await this.repository.createEstate({
actorId: user.id,
companyId: dto.companyId,
name: dto.name,
slug: dto.slug,
idempotencyKey: dto.idempotencyKey,
}),
);
}
@Post('estates/:id/rename')
@HttpCode(200)
async renameEstate(
@CurrentUser() user: { id: string },
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: RenameNodeDto,
) {
return this.service.unwrap(
await this.repository.renameEstate({
actorId: user.id,
estateId: id,
name: dto.name,
idempotencyKey: dto.idempotencyKey,
}),
);
}
@Post('estates/:id/transfer')
@HttpCode(200)
async transferEstate(
@CurrentUser() user: { id: string },
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: TransferEstateDto,
) {
return this.service.unwrap(
await this.repository.transferEstate({
actorId: user.id,
estateId: id,
destinationCompanyId: dto.destinationCompanyId,
idempotencyKey: dto.idempotencyKey,
}),
);
}
@Delete('estates/:id')
async deleteEstate(
@CurrentUser() user: { id: string },
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: DeleteNodeDto,
) {
return this.service.unwrap(
await this.repository.deleteEstate({
actorId: user.id,
estateId: id,
idempotencyKey: dto?.idempotencyKey,
}),
);
}
// ── platform projects ────────────────────────────────────────────────────
@Post('platform-projects')
async createPlatformProject(
@CurrentUser() user: { id: string },
@Body() dto: CreatePlatformProjectDto,
) {
return this.service.unwrap(
await this.repository.createPlatformProject({
actorId: user.id,
estateId: dto.estateId,
name: dto.name,
slug: dto.slug,
idempotencyKey: dto.idempotencyKey,
}),
);
}
@Post('platform-projects/:id/rename')
@HttpCode(200)
async renamePlatformProject(
@CurrentUser() user: { id: string },
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: RenameNodeDto,
) {
return this.service.unwrap(
await this.repository.renamePlatformProject({
actorId: user.id,
platformProjectId: id,
name: dto.name,
idempotencyKey: dto.idempotencyKey,
}),
);
}
@Post('platform-projects/:id/transfer')
@HttpCode(200)
async transferPlatformProject(
@CurrentUser() user: { id: string },
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: TransferPlatformProjectDto,
) {
return this.service.unwrap(
await this.repository.transferPlatformProject({
actorId: user.id,
platformProjectId: id,
destinationEstateId: dto.destinationEstateId,
idempotencyKey: dto.idempotencyKey,
}),
);
}
@Delete('platform-projects/:id')
async deletePlatformProject(
@CurrentUser() user: { id: string },
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: DeleteNodeDto,
) {
return this.service.unwrap(
await this.repository.deletePlatformProject({
actorId: user.id,
platformProjectId: id,
idempotencyKey: dto?.idempotencyKey,
}),
);
}
// ── grants ───────────────────────────────────────────────────────────────
@Post('grants')
async createGrant(@CurrentUser() user: { id: string }, @Body() dto: CreateGrantDto) {
return this.service.unwrap(
await this.repository.createGrant({
actorId: user.id,
userId: dto.userId,
targetKind: dto.targetKind,
targetId: dto.targetId,
role: dto.role,
idempotencyKey: dto.idempotencyKey,
}),
);
}
@Post('grants/:id/change')
@HttpCode(200)
async changeGrant(
@CurrentUser() user: { id: string },
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: ChangeGrantDto,
) {
return this.service.unwrap(
await this.repository.changeGrant({
actorId: user.id,
grantId: id,
role: dto.role,
idempotencyKey: dto.idempotencyKey,
}),
);
}
@Delete('grants/:id')
async revokeGrant(
@CurrentUser() user: { id: string },
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: DeleteNodeDto,
) {
return this.service.unwrap(
await this.repository.revokeGrant({
actorId: user.id,
grantId: id,
idempotencyKey: dto?.idempotencyKey,
}),
);
}
}
-169
View File
@@ -1,169 +0,0 @@
import { COMPANY_VISIBILITY, HIERARCHY_GRANT_ROLES } from '@mosaicstack/db';
import { IsIn, IsOptional, IsString, IsUUID, Matches, MaxLength, MinLength } from 'class-validator';
/**
* Hierarchy command DTOs (contract 1 §5, contract 2 §4/§7).
*
* The global ValidationPipe runs with whitelist + forbidNonWhitelisted, so a
* payload field absent from these classes is a 400. That closure is itself
* contract surface:
* - CreateCompanyDto declares NO visibility field creation is always
* private (contract 1 §5.5); a visibility argument is refused by the pipe.
* - CreateGrantDto declares NO teamId field team grant subjects are
* suspended (contract 2 §1.4/§7.5); a team subject is refused by the pipe.
* Every class here must be registered in PIPE_GUARDED_DTOS so the boot-time
* assertion proves the pipe sees the decorators.
*/
const SLUG_PATTERN = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
const SLUG_MESSAGE = 'slug must be lowercase alphanumeric with interior hyphens';
export class CreateCompanyDto {
@IsString()
@MinLength(1)
@MaxLength(255)
name!: string;
@IsString()
@MaxLength(100)
@Matches(SLUG_PATTERN, { message: SLUG_MESSAGE })
slug!: string;
/** Client-supplied idempotency key (REQ-AUD-001 replay); server-generated when absent. */
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(255)
idempotencyKey?: string;
}
export class RenameNodeDto {
@IsString()
@MinLength(1)
@MaxLength(255)
name!: string;
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(255)
idempotencyKey?: string;
}
export class ChangeCompanyVisibilityDto {
@IsIn(COMPANY_VISIBILITY)
visibility!: (typeof COMPANY_VISIBILITY)[number];
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(255)
idempotencyKey?: string;
}
export class DeleteNodeDto {
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(255)
idempotencyKey?: string;
}
export class CreateEstateDto {
@IsUUID()
companyId!: string;
@IsString()
@MinLength(1)
@MaxLength(255)
name!: string;
@IsString()
@MaxLength(100)
@Matches(SLUG_PATTERN, { message: SLUG_MESSAGE })
slug!: string;
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(255)
idempotencyKey?: string;
}
export class CreatePlatformProjectDto {
@IsUUID()
estateId!: string;
@IsString()
@MinLength(1)
@MaxLength(255)
name!: string;
@IsString()
@MaxLength(100)
@Matches(SLUG_PATTERN, { message: SLUG_MESSAGE })
slug!: string;
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(255)
idempotencyKey?: string;
}
export class TransferEstateDto {
@IsUUID()
destinationCompanyId!: string;
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(255)
idempotencyKey?: string;
}
export class TransferPlatformProjectDto {
@IsUUID()
destinationEstateId!: string;
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(255)
idempotencyKey?: string;
}
export class CreateGrantDto {
/** Subject user (better-auth text id). No teamId field — see module doc. */
@IsString()
@MinLength(1)
@MaxLength(255)
userId!: string;
@IsIn(['company', 'estate', 'platform_project'])
targetKind!: 'company' | 'estate' | 'platform_project';
@IsUUID()
targetId!: string;
/** Bare vocabulary on requests; responses and audit events are namespaced (§4.5). */
@IsIn(HIERARCHY_GRANT_ROLES)
role!: (typeof HIERARCHY_GRANT_ROLES)[number];
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(255)
idempotencyKey?: string;
}
export class ChangeGrantDto {
@IsIn(HIERARCHY_GRANT_ROLES)
role!: (typeof HIERARCHY_GRANT_ROLES)[number];
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(255)
idempotencyKey?: string;
}
@@ -1,28 +0,0 @@
import { Module } from '@nestjs/common';
import { HierarchyAuditRepository } from './hierarchy-audit.repository.js';
import { HierarchyGrantEvaluationService } from './hierarchy-grant-evaluation.js';
import { HierarchyController } from './hierarchy.controller.js';
import { HierarchyRepository } from './hierarchy.repository.js';
import { HierarchyService } from './hierarchy.service.js';
/**
* Hierarchy (tenancy/authorization structure) feature module.
*
* M4-1b-i shipped the audit event + outbox machinery (contract 1 §5.2);
* M4-1b-ii adds the command family the closed route surface asserted by
* the route-inventory witness plus grant evaluation (contract 2 §3).
* HierarchyRepository is the sole class-table writer (writer-coverage
* allowlist); every mutation runs authorize mutate audit in one
* transaction.
*/
@Module({
controllers: [HierarchyController],
providers: [
HierarchyAuditRepository,
HierarchyGrantEvaluationService,
HierarchyRepository,
HierarchyService,
],
exports: [HierarchyAuditRepository, HierarchyGrantEvaluationService],
})
export class HierarchyModule {}
@@ -1,935 +0,0 @@
import { randomUUID } from 'node:crypto';
import { Inject, Injectable } from '@nestjs/common';
import {
and,
asc,
companies,
eq,
estates,
hierarchyGrants,
inArray,
platformProjects,
users,
workspaces,
type Db,
} from '@mosaicstack/db';
import { DB } from '../database/database.module.js';
import {
appendHierarchyEvent,
buildNodeSnapshot,
HierarchyAuditIdempotencyConflictError,
} from './hierarchy-audit.repository.js';
import {
evaluateEffectiveRole,
grantedCompanyIds,
namespacedHierarchyRole,
roleAtLeast,
type GrantTargetKind,
type HierarchyGrantRole,
} from './hierarchy-grant-evaluation.js';
/**
* Hierarchy command repository (contract 1 §5, contract 2 §4).
*
* The ONLY writer of the hierarchy class tables (companies, estates,
* platform_projects, hierarchy_grants) it is the writer-coverage
* allowlist's sole entry. Every command runs one transaction that
* authorizes (live grant evaluation inside the same transaction), mutates,
* and appends the semantic audit event + outbox record via the M4-1b-i
* machinery, so state, event, and outbox commit or roll back together
* (REQ-AUD-001).
*
* Authorization failure and target-not-found both return `not_found`
* (contract 1 §6.7: no existence oracle an unauthorized caller learns
* nothing a stranger would not). `forbidden` appears only where the caller
* already knows the surface exists independent of any node: the admin-only
* visibility change (§5.5). Serialized role strings are namespaced (§4.5).
*/
export type HierarchyCommandFailure =
| { readonly ok: false; readonly error: 'not_found' }
| { readonly ok: false; readonly error: 'forbidden'; readonly message: string }
| { readonly ok: false; readonly error: 'conflict'; readonly message: string };
export type HierarchyResult<T> = ({ readonly ok: true } & T) | HierarchyCommandFailure;
export interface CompanyView {
readonly id: string;
readonly name: string;
readonly slug: string;
readonly visibility: string;
}
export interface NodeView {
readonly id: string;
readonly name: string;
readonly slug: string;
}
export interface GrantView {
readonly id: string;
readonly userId: string;
readonly targetKind: GrantTargetKind;
readonly targetId: string;
/** Namespaced (§4.5), e.g. `hierarchy:owner`. */
readonly role: string;
readonly grantedBy: string;
}
/** Directory rows are closed-field: existence, name, slug — nothing else (§2.8). */
export interface DirectoryEntry {
readonly id: string;
readonly name: string;
readonly slug: string;
}
type Tx = Pick<Db, 'insert' | 'select' | 'update' | 'delete'>;
type GrantRow = typeof hierarchyGrants.$inferSelect;
const NOT_FOUND: HierarchyCommandFailure = { ok: false, error: 'not_found' };
function conflict(message: string): HierarchyCommandFailure {
return { ok: false, error: 'conflict', message };
}
function grantTarget(row: GrantRow): { kind: GrantTargetKind; id: string } {
if (row.companyId) return { kind: 'company', id: row.companyId };
if (row.estateId) return { kind: 'estate', id: row.estateId };
return { kind: 'platform_project', id: row.platformProjectId as string };
}
/** Grant event snapshot (contract 2 §4.4): subject, target, namespaced role, grantor. */
function grantSnapshot(row: GrantRow): Record<string, unknown> {
const target = grantTarget(row);
return {
id: row.id,
subject: { userId: row.userId },
target: { kind: target.kind, id: target.id },
role: namespacedHierarchyRole(row.role as HierarchyGrantRole),
grantedBy: row.grantedBy,
};
}
function grantView(row: GrantRow): GrantView {
const target = grantTarget(row);
return {
id: row.id,
userId: row.userId as string,
targetKind: target.kind,
targetId: target.id,
role: namespacedHierarchyRole(row.role as HierarchyGrantRole),
grantedBy: row.grantedBy,
};
}
function companyView(row: typeof companies.$inferSelect): CompanyView {
return { id: row.id, name: row.name, slug: row.slug, visibility: row.visibility };
}
interface CommandContext {
readonly actorId: string;
readonly idempotencyKey: string;
readonly correlationId: string;
}
@Injectable()
export class HierarchyRepository {
constructor(@Inject(DB) private readonly db: Db) {}
private async run<T>(
idempotencyKey: string | undefined,
actorId: string,
body: (tx: Tx, ctx: CommandContext) => Promise<HierarchyResult<T>>,
): Promise<HierarchyResult<T>> {
const ctx: CommandContext = {
actorId,
idempotencyKey: idempotencyKey ?? randomUUID(),
correlationId: randomUUID(),
};
try {
return await this.db.transaction(async (tx) => body(tx, ctx));
} catch (error) {
// A key replayed with different content aborts the whole command —
// the transaction (state change included) has rolled back (§6.4).
if (error instanceof HierarchyAuditIdempotencyConflictError) {
return conflict(error.message);
}
throw error;
}
}
private async requireOwner(
tx: Tx,
actorId: string,
kind: GrantTargetKind,
id: string,
): Promise<boolean> {
return roleAtLeast(await evaluateEffectiveRole(tx, actorId, kind, id), 'owner');
}
private async isPlatformAdmin(tx: Tx, actorId: string): Promise<boolean> {
const rows = await tx
.select({ role: users.role })
.from(users)
.where(eq(users.id, actorId))
.limit(1);
return rows[0]?.role === 'admin';
}
// ── companies ────────────────────────────────────────────────────────────
/**
* Any authenticated user may create a company; the same audited operation
* writes the creator's initial owner grant (§4.3), causation-linked to the
* create event. Visibility is always 'private' the command takes no
* visibility input (§5.5).
*/
createCompany(input: {
actorId: string;
name: string;
slug: string;
idempotencyKey?: string;
}): Promise<HierarchyResult<{ company: CompanyView; grant: GrantView }>> {
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
const inserted = await tx
.insert(companies)
.values({ name: input.name, slug: input.slug })
.onConflictDoNothing()
.returning();
const company = inserted[0];
if (!company) return conflict('company slug already exists');
const grantRows = await tx
.insert(hierarchyGrants)
.values({
userId: ctx.actorId,
companyId: company.id,
role: 'owner',
grantedBy: ctx.actorId,
})
.returning();
const grant = grantRows[0] as GrantRow;
const snapshot = await buildNodeSnapshot(tx, 'company', company.id);
const created = await appendHierarchyEvent(tx, {
actorId: ctx.actorId,
verb: 'create',
targetKind: 'company',
targetId: company.id,
targetSnapshot: { ...snapshot },
correlationId: ctx.correlationId,
idempotencyKey: ctx.idempotencyKey,
});
await appendHierarchyEvent(tx, {
actorId: ctx.actorId,
verb: 'grant_create',
targetKind: 'grant',
targetId: grant.id,
targetSnapshot: grantSnapshot(grant),
correlationId: ctx.correlationId,
causationId: created.event.id,
idempotencyKey: `${ctx.idempotencyKey}:grant`,
});
return { ok: true, company: companyView(company), grant: grantView(grant) };
});
}
renameCompany(input: {
actorId: string;
companyId: string;
name: string;
idempotencyKey?: string;
}): Promise<HierarchyResult<{ company: CompanyView }>> {
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
if (!(await this.requireOwner(tx, ctx.actorId, 'company', input.companyId))) {
return NOT_FOUND;
}
const rows = await tx
.select()
.from(companies)
.where(eq(companies.id, input.companyId))
.limit(1);
const previous = rows[0];
if (!previous) return NOT_FOUND;
const updated = await tx
.update(companies)
.set({ name: input.name, updatedAt: new Date() })
.where(eq(companies.id, input.companyId))
.returning();
const company = updated[0] as typeof companies.$inferSelect;
const snapshot = await buildNodeSnapshot(tx, 'company', company.id);
await appendHierarchyEvent(tx, {
actorId: ctx.actorId,
verb: 'rename',
targetKind: 'company',
targetId: company.id,
targetSnapshot: { ...snapshot, previousName: previous.name },
correlationId: ctx.correlationId,
idempotencyKey: ctx.idempotencyKey,
});
return { ok: true, company: companyView(company) };
});
}
/**
* Admin-only until the company-CRUD capability ratifies (§5.5) the one
* hierarchy mutation a platform admin performs without a grant. A
* non-admin caller (owner included) gets `forbidden` before any company
* read: the refusal reveals nothing about the target's existence.
*/
changeCompanyVisibility(input: {
actorId: string;
companyId: string;
visibility: string;
idempotencyKey?: string;
}): Promise<HierarchyResult<{ company: CompanyView }>> {
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
if (!(await this.isPlatformAdmin(tx, ctx.actorId))) {
return {
ok: false,
error: 'forbidden',
message: 'visibility change is platform-admin-only',
};
}
const rows = await tx
.select()
.from(companies)
.where(eq(companies.id, input.companyId))
.limit(1);
const previous = rows[0];
if (!previous) return NOT_FOUND;
const updated = await tx
.update(companies)
.set({ visibility: input.visibility, updatedAt: new Date() })
.where(eq(companies.id, input.companyId))
.returning();
const company = updated[0] as typeof companies.$inferSelect;
const snapshot = await buildNodeSnapshot(tx, 'company', company.id);
await appendHierarchyEvent(tx, {
actorId: ctx.actorId,
verb: 'visibility_change',
targetKind: 'company',
targetId: company.id,
// Old and new values are event content (§5.5).
targetSnapshot: {
...snapshot,
previousVisibility: previous.visibility,
visibility: company.visibility,
},
correlationId: ctx.correlationId,
idempotencyKey: ctx.idempotencyKey,
});
return { ok: true, company: companyView(company) };
});
}
deleteCompany(input: {
actorId: string;
companyId: string;
idempotencyKey?: string;
}): Promise<HierarchyResult<{ deletedId: string }>> {
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
if (!(await this.requireOwner(tx, ctx.actorId, 'company', input.companyId))) {
return NOT_FOUND;
}
const children = await tx
.select({ id: estates.id })
.from(estates)
.where(eq(estates.companyId, input.companyId))
.limit(1);
if (children.length > 0) return conflict('company still has estates');
// Snapshot and grants are read before the delete; target FKs cascade
// the grant rows, and each cascaded deletion is audited (§5.2).
const snapshot = await buildNodeSnapshot(tx, 'company', input.companyId);
const grants = await tx
.select()
.from(hierarchyGrants)
.where(eq(hierarchyGrants.companyId, input.companyId));
await tx.delete(companies).where(eq(companies.id, input.companyId));
const deleted = await appendHierarchyEvent(tx, {
actorId: ctx.actorId,
verb: 'delete',
targetKind: 'company',
targetId: input.companyId,
targetSnapshot: { ...snapshot },
correlationId: ctx.correlationId,
idempotencyKey: ctx.idempotencyKey,
});
for (const grant of grants) {
await appendHierarchyEvent(tx, {
actorId: ctx.actorId,
verb: 'grant_revoke',
targetKind: 'grant',
targetId: grant.id,
targetSnapshot: grantSnapshot(grant),
correlationId: ctx.correlationId,
causationId: deleted.event.id,
idempotencyKey: `${ctx.idempotencyKey}:revoke:${grant.id}`,
});
}
return { ok: true, deletedId: input.companyId };
});
}
// ── estates ──────────────────────────────────────────────────────────────
/** Child creation requires owner on the parent and confers no grant (§4.3). */
createEstate(input: {
actorId: string;
companyId: string;
name: string;
slug: string;
idempotencyKey?: string;
}): Promise<HierarchyResult<{ estate: NodeView }>> {
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
if (!(await this.requireOwner(tx, ctx.actorId, 'company', input.companyId))) {
return NOT_FOUND;
}
const inserted = await tx
.insert(estates)
.values({ companyId: input.companyId, name: input.name, slug: input.slug })
.onConflictDoNothing()
.returning();
const estate = inserted[0];
if (!estate) return conflict('estate slug already exists in company');
const snapshot = await buildNodeSnapshot(tx, 'estate', estate.id);
await appendHierarchyEvent(tx, {
actorId: ctx.actorId,
verb: 'create',
targetKind: 'estate',
targetId: estate.id,
targetSnapshot: { ...snapshot },
correlationId: ctx.correlationId,
idempotencyKey: ctx.idempotencyKey,
});
return { ok: true, estate: { id: estate.id, name: estate.name, slug: estate.slug } };
});
}
renameEstate(input: {
actorId: string;
estateId: string;
name: string;
idempotencyKey?: string;
}): Promise<HierarchyResult<{ estate: NodeView }>> {
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
if (!(await this.requireOwner(tx, ctx.actorId, 'estate', input.estateId))) {
return NOT_FOUND;
}
const rows = await tx.select().from(estates).where(eq(estates.id, input.estateId)).limit(1);
const previous = rows[0];
if (!previous) return NOT_FOUND;
const updated = await tx
.update(estates)
.set({ name: input.name })
.where(eq(estates.id, input.estateId))
.returning();
const estate = updated[0] as typeof estates.$inferSelect;
const snapshot = await buildNodeSnapshot(tx, 'estate', estate.id);
await appendHierarchyEvent(tx, {
actorId: ctx.actorId,
verb: 'rename',
targetKind: 'estate',
targetId: estate.id,
targetSnapshot: { ...snapshot, previousName: previous.name },
correlationId: ctx.correlationId,
idempotencyKey: ctx.idempotencyKey,
});
return { ok: true, estate: { id: estate.id, name: estate.name, slug: estate.slug } };
});
}
/** Transfer requires effective owner on BOTH parents, evaluated in the transfer's own transaction (§5). */
transferEstate(input: {
actorId: string;
estateId: string;
destinationCompanyId: string;
idempotencyKey?: string;
}): Promise<HierarchyResult<{ estate: NodeView }>> {
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
const rows = await tx.select().from(estates).where(eq(estates.id, input.estateId)).limit(1);
const estate = rows[0];
if (!estate) return NOT_FOUND;
if (!(await this.requireOwner(tx, ctx.actorId, 'company', estate.companyId))) {
return NOT_FOUND;
}
if (!(await this.requireOwner(tx, ctx.actorId, 'company', input.destinationCompanyId))) {
return NOT_FOUND;
}
if (estate.companyId === input.destinationCompanyId) {
return conflict('estate already belongs to the destination company');
}
const collision = await tx
.select({ id: estates.id })
.from(estates)
.where(
and(eq(estates.companyId, input.destinationCompanyId), eq(estates.slug, estate.slug)),
)
.limit(1);
if (collision.length > 0) return conflict('destination company already has that estate slug');
const parents = await tx
.select({ id: companies.id, slug: companies.slug })
.from(companies)
.where(inArray(companies.id, [estate.companyId, input.destinationCompanyId]));
const source = parents.find((p) => p.id === estate.companyId);
const destination = parents.find((p) => p.id === input.destinationCompanyId);
if (!source || !destination) return NOT_FOUND;
await tx
.update(estates)
.set({ companyId: input.destinationCompanyId })
.where(eq(estates.id, input.estateId));
const snapshot = await buildNodeSnapshot(tx, 'estate', input.estateId);
await appendHierarchyEvent(tx, {
actorId: ctx.actorId,
verb: 'transfer',
targetKind: 'estate',
targetId: input.estateId,
targetSnapshot: { ...snapshot },
transferFrom: { kind: 'company', id: source.id, slug: source.slug },
transferTo: { kind: 'company', id: destination.id, slug: destination.slug },
correlationId: ctx.correlationId,
idempotencyKey: ctx.idempotencyKey,
});
return { ok: true, estate: { id: estate.id, name: estate.name, slug: estate.slug } };
});
}
deleteEstate(input: {
actorId: string;
estateId: string;
idempotencyKey?: string;
}): Promise<HierarchyResult<{ deletedId: string }>> {
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
if (!(await this.requireOwner(tx, ctx.actorId, 'estate', input.estateId))) {
return NOT_FOUND;
}
const children = await tx
.select({ id: platformProjects.id })
.from(platformProjects)
.where(eq(platformProjects.estateId, input.estateId))
.limit(1);
if (children.length > 0) return conflict('estate still has platform projects');
const snapshot = await buildNodeSnapshot(tx, 'estate', input.estateId);
const grants = await tx
.select()
.from(hierarchyGrants)
.where(eq(hierarchyGrants.estateId, input.estateId));
await tx.delete(estates).where(eq(estates.id, input.estateId));
const deleted = await appendHierarchyEvent(tx, {
actorId: ctx.actorId,
verb: 'delete',
targetKind: 'estate',
targetId: input.estateId,
targetSnapshot: { ...snapshot },
correlationId: ctx.correlationId,
idempotencyKey: ctx.idempotencyKey,
});
for (const grant of grants) {
await appendHierarchyEvent(tx, {
actorId: ctx.actorId,
verb: 'grant_revoke',
targetKind: 'grant',
targetId: grant.id,
targetSnapshot: grantSnapshot(grant),
correlationId: ctx.correlationId,
causationId: deleted.event.id,
idempotencyKey: `${ctx.idempotencyKey}:revoke:${grant.id}`,
});
}
return { ok: true, deletedId: input.estateId };
});
}
// ── platform projects ────────────────────────────────────────────────────
createPlatformProject(input: {
actorId: string;
estateId: string;
name: string;
slug: string;
idempotencyKey?: string;
}): Promise<HierarchyResult<{ platformProject: NodeView }>> {
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
if (!(await this.requireOwner(tx, ctx.actorId, 'estate', input.estateId))) {
return NOT_FOUND;
}
const inserted = await tx
.insert(platformProjects)
.values({ estateId: input.estateId, name: input.name, slug: input.slug })
.onConflictDoNothing()
.returning();
const project = inserted[0];
if (!project) return conflict('platform project slug already exists in estate');
const snapshot = await buildNodeSnapshot(tx, 'platform_project', project.id);
await appendHierarchyEvent(tx, {
actorId: ctx.actorId,
verb: 'create',
targetKind: 'platform_project',
targetId: project.id,
targetSnapshot: { ...snapshot },
correlationId: ctx.correlationId,
idempotencyKey: ctx.idempotencyKey,
});
return {
ok: true,
platformProject: { id: project.id, name: project.name, slug: project.slug },
};
});
}
renamePlatformProject(input: {
actorId: string;
platformProjectId: string;
name: string;
idempotencyKey?: string;
}): Promise<HierarchyResult<{ platformProject: NodeView }>> {
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
if (
!(await this.requireOwner(tx, ctx.actorId, 'platform_project', input.platformProjectId))
) {
return NOT_FOUND;
}
const rows = await tx
.select()
.from(platformProjects)
.where(eq(platformProjects.id, input.platformProjectId))
.limit(1);
const previous = rows[0];
if (!previous) return NOT_FOUND;
const updated = await tx
.update(platformProjects)
.set({ name: input.name })
.where(eq(platformProjects.id, input.platformProjectId))
.returning();
const project = updated[0] as typeof platformProjects.$inferSelect;
const snapshot = await buildNodeSnapshot(tx, 'platform_project', project.id);
await appendHierarchyEvent(tx, {
actorId: ctx.actorId,
verb: 'rename',
targetKind: 'platform_project',
targetId: project.id,
targetSnapshot: { ...snapshot, previousName: previous.name },
correlationId: ctx.correlationId,
idempotencyKey: ctx.idempotencyKey,
});
return {
ok: true,
platformProject: { id: project.id, name: project.name, slug: project.slug },
};
});
}
transferPlatformProject(input: {
actorId: string;
platformProjectId: string;
destinationEstateId: string;
idempotencyKey?: string;
}): Promise<HierarchyResult<{ platformProject: NodeView }>> {
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
const rows = await tx
.select()
.from(platformProjects)
.where(eq(platformProjects.id, input.platformProjectId))
.limit(1);
const project = rows[0];
if (!project) return NOT_FOUND;
if (!(await this.requireOwner(tx, ctx.actorId, 'estate', project.estateId))) {
return NOT_FOUND;
}
if (!(await this.requireOwner(tx, ctx.actorId, 'estate', input.destinationEstateId))) {
return NOT_FOUND;
}
if (project.estateId === input.destinationEstateId) {
return conflict('platform project already belongs to the destination estate');
}
const collision = await tx
.select({ id: platformProjects.id })
.from(platformProjects)
.where(
and(
eq(platformProjects.estateId, input.destinationEstateId),
eq(platformProjects.slug, project.slug),
),
)
.limit(1);
if (collision.length > 0) {
return conflict('destination estate already has that platform project slug');
}
const parents = await tx
.select({ id: estates.id, slug: estates.slug })
.from(estates)
.where(inArray(estates.id, [project.estateId, input.destinationEstateId]));
const source = parents.find((p) => p.id === project.estateId);
const destination = parents.find((p) => p.id === input.destinationEstateId);
if (!source || !destination) return NOT_FOUND;
await tx
.update(platformProjects)
.set({ estateId: input.destinationEstateId })
.where(eq(platformProjects.id, input.platformProjectId));
const snapshot = await buildNodeSnapshot(tx, 'platform_project', input.platformProjectId);
await appendHierarchyEvent(tx, {
actorId: ctx.actorId,
verb: 'transfer',
targetKind: 'platform_project',
targetId: input.platformProjectId,
targetSnapshot: { ...snapshot },
transferFrom: { kind: 'estate', id: source.id, slug: source.slug },
transferTo: { kind: 'estate', id: destination.id, slug: destination.slug },
correlationId: ctx.correlationId,
idempotencyKey: ctx.idempotencyKey,
});
return {
ok: true,
platformProject: { id: project.id, name: project.name, slug: project.slug },
};
});
}
deletePlatformProject(input: {
actorId: string;
platformProjectId: string;
idempotencyKey?: string;
}): Promise<HierarchyResult<{ deletedId: string }>> {
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
if (
!(await this.requireOwner(tx, ctx.actorId, 'platform_project', input.platformProjectId))
) {
return NOT_FOUND;
}
const children = await tx
.select({ id: workspaces.id })
.from(workspaces)
.where(eq(workspaces.platformProjectId, input.platformProjectId))
.limit(1);
if (children.length > 0) return conflict('platform project still has workspaces');
const snapshot = await buildNodeSnapshot(tx, 'platform_project', input.platformProjectId);
const grants = await tx
.select()
.from(hierarchyGrants)
.where(eq(hierarchyGrants.platformProjectId, input.platformProjectId));
await tx.delete(platformProjects).where(eq(platformProjects.id, input.platformProjectId));
const deleted = await appendHierarchyEvent(tx, {
actorId: ctx.actorId,
verb: 'delete',
targetKind: 'platform_project',
targetId: input.platformProjectId,
targetSnapshot: { ...snapshot },
correlationId: ctx.correlationId,
idempotencyKey: ctx.idempotencyKey,
});
for (const grant of grants) {
await appendHierarchyEvent(tx, {
actorId: ctx.actorId,
verb: 'grant_revoke',
targetKind: 'grant',
targetId: grant.id,
targetSnapshot: grantSnapshot(grant),
correlationId: ctx.correlationId,
causationId: deleted.event.id,
idempotencyKey: `${ctx.idempotencyKey}:revoke:${grant.id}`,
});
}
return { ok: true, deletedId: input.platformProjectId };
});
}
// ── grants ───────────────────────────────────────────────────────────────
/** Grant management requires effective owner on the target (§4.1). */
createGrant(input: {
actorId: string;
userId: string;
targetKind: GrantTargetKind;
targetId: string;
role: HierarchyGrantRole;
idempotencyKey?: string;
}): Promise<HierarchyResult<{ grant: GrantView }>> {
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
if (!(await this.requireOwner(tx, ctx.actorId, input.targetKind, input.targetId))) {
return NOT_FOUND;
}
const subject = await tx
.select({ id: users.id })
.from(users)
.where(eq(users.id, input.userId))
.limit(1);
if (subject.length === 0) return conflict('subject user does not exist');
const inserted = await tx
.insert(hierarchyGrants)
.values({
userId: input.userId,
companyId: input.targetKind === 'company' ? input.targetId : null,
estateId: input.targetKind === 'estate' ? input.targetId : null,
platformProjectId: input.targetKind === 'platform_project' ? input.targetId : null,
role: input.role,
grantedBy: ctx.actorId,
})
.onConflictDoNothing()
.returning();
const grant = inserted[0];
if (!grant) return conflict('grant already exists');
await appendHierarchyEvent(tx, {
actorId: ctx.actorId,
verb: 'grant_create',
targetKind: 'grant',
targetId: grant.id,
targetSnapshot: grantSnapshot(grant),
correlationId: ctx.correlationId,
idempotencyKey: ctx.idempotencyKey,
});
return { ok: true, grant: grantView(grant) };
});
}
changeGrant(input: {
actorId: string;
grantId: string;
role: HierarchyGrantRole;
idempotencyKey?: string;
}): Promise<HierarchyResult<{ grant: GrantView }>> {
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
const rows = await tx
.select()
.from(hierarchyGrants)
.where(eq(hierarchyGrants.id, input.grantId))
.limit(1);
const existing = rows[0];
if (!existing) return NOT_FOUND;
const target = grantTarget(existing);
if (!(await this.requireOwner(tx, ctx.actorId, target.kind, target.id))) {
return NOT_FOUND;
}
// Team subjects are suspended (§1.4); the command surface never
// creates them, so this only fires on out-of-band rows.
if (!existing.userId) return conflict('team grant subjects are suspended');
if (existing.role === input.role) return conflict('grant already holds that role');
const duplicate = await tx
.select({ id: hierarchyGrants.id })
.from(hierarchyGrants)
.where(
and(
eq(hierarchyGrants.userId, existing.userId),
target.kind === 'company'
? eq(hierarchyGrants.companyId, target.id)
: target.kind === 'estate'
? eq(hierarchyGrants.estateId, target.id)
: eq(hierarchyGrants.platformProjectId, target.id),
eq(hierarchyGrants.role, input.role),
),
)
.limit(1);
if (duplicate.length > 0) {
return conflict('subject already holds that role on the target');
}
const updated = await tx
.update(hierarchyGrants)
.set({ role: input.role })
.where(eq(hierarchyGrants.id, input.grantId))
.returning();
const grant = updated[0] as GrantRow;
await appendHierarchyEvent(tx, {
actorId: ctx.actorId,
verb: 'grant_change',
targetKind: 'grant',
targetId: grant.id,
targetSnapshot: {
...grantSnapshot(grant),
previousRole: namespacedHierarchyRole(existing.role as HierarchyGrantRole),
},
correlationId: ctx.correlationId,
idempotencyKey: ctx.idempotencyKey,
});
return { ok: true, grant: grantView(grant) };
});
}
/** Revocation is row deletion (§6): the next evaluation denies, nothing lingers. */
revokeGrant(input: {
actorId: string;
grantId: string;
idempotencyKey?: string;
}): Promise<HierarchyResult<{ revokedId: string }>> {
return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => {
const rows = await tx
.select()
.from(hierarchyGrants)
.where(eq(hierarchyGrants.id, input.grantId))
.limit(1);
const existing = rows[0];
if (!existing) return NOT_FOUND;
const target = grantTarget(existing);
if (!(await this.requireOwner(tx, ctx.actorId, target.kind, target.id))) {
return NOT_FOUND;
}
await tx.delete(hierarchyGrants).where(eq(hierarchyGrants.id, input.grantId));
await appendHierarchyEvent(tx, {
actorId: ctx.actorId,
verb: 'grant_revoke',
targetKind: 'grant',
targetId: existing.id,
targetSnapshot: grantSnapshot(existing),
correlationId: ctx.correlationId,
idempotencyKey: ctx.idempotencyKey,
});
return { ok: true, revokedId: existing.id };
});
}
// ── reads ────────────────────────────────────────────────────────────────
/**
* The directory: every directory-class company, closed-field (§2.8). The
* sole ratified existence-disclosure carve-out (§6.7 / A2 §9.1.2).
*/
async listDirectory(): Promise<DirectoryEntry[]> {
return this.db
.select({ id: companies.id, name: companies.name, slug: companies.slug })
.from(companies)
.where(eq(companies.visibility, 'directory'))
.orderBy(asc(companies.name));
}
/** Companies the user holds any grant on (company or descendant, §2.8). */
async listGrantedCompanies(userId: string): Promise<CompanyView[]> {
const ids = await grantedCompanyIds(this.db, userId);
if (ids.length === 0) return [];
const rows = await this.db
.select()
.from(companies)
.where(inArray(companies.id, ids))
.orderBy(asc(companies.name));
return rows.map(companyView);
}
}
@@ -1,31 +0,0 @@
import {
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import type { HierarchyCommandFailure, HierarchyResult } from './hierarchy.repository.js';
/**
* Maps repository result unions onto HTTP exceptions. `not_found` carries
* one fixed message for every cause missing node and unauthorized caller
* are indistinguishable on the wire (contract 1 §6.7).
*/
@Injectable()
export class HierarchyService {
unwrap<T>(result: HierarchyResult<T>): T {
if (result.ok) return result;
throw this.toException(result);
}
private toException(failure: HierarchyCommandFailure): Error {
switch (failure.error) {
case 'not_found':
return new NotFoundException('hierarchy node not found');
case 'forbidden':
return new ForbiddenException(failure.message);
case 'conflict':
return new ConflictException(failure.message);
}
}
}
-8
View File
@@ -12,19 +12,12 @@ import { AppModule } from './app.module.js';
import { mountAuthHandler } from './auth/auth.controller.js';
import { mountMcpHandler } from './mcp/mcp.controller.js';
import { McpService } from './mcp/mcp.service.js';
import { mountSpaStatic } from './spa/serve-spa.js';
import { detectAndAssertTier, TierDetectionError } from '@mosaicstack/storage';
import { resolveGatewayConfigPath } from './env.js';
import { assertValidationPipeSeesDtoDecorators } from './validation-pipe-check.js';
async function bootstrap(): Promise<void> {
const logger = new Logger('Bootstrap');
// Fail loud BEFORE anything else if the global ValidationPipe cannot see
// the guarded DTOs' decorated properties (#1391): a broken metatype turns
// every request body into a 400 at first use; this surfaces it at boot.
assertValidationPipeSeesDtoDecorators();
if (!process.env['BETTER_AUTH_SECRET']) {
throw new Error('BETTER_AUTH_SECRET is required');
}
@@ -69,7 +62,6 @@ async function bootstrap(): Promise<void> {
mountAuthHandler(app);
mountMcpHandler(app, app.get(McpService));
await mountSpaStatic(app);
const port = Number(process.env['GATEWAY_PORT'] ?? 14242);
await app.listen(port, '0.0.0.0');
+24 -89
View File
@@ -176,18 +176,7 @@ describe('MCP actor identity and tool scope enforcement', () => {
).toBe(false);
expect(
deriveMcpToolScopesForUser({ role: 'platform-admin' }).has(MCP_TOOL_SCOPES.coord_list_tasks),
).toBe(false);
});
it('derives no scope elevation from any platform role (contract 2 §1.1 bypass retirement)', () => {
const memberScopes = deriveMcpToolScopesForUser({ role: 'member' });
for (const role of ['admin', 'platform-admin', 'super-admin', null, undefined]) {
const scopes = deriveMcpToolScopesForUser({ role });
expect([...scopes].sort()).toEqual([...memberScopes].sort());
expect(scopes.has(MCP_TOOL_SCOPES.brain_create_task)).toBe(false);
expect(scopes.has(MCP_TOOL_SCOPES.brain_update_task)).toBe(false);
expect(scopes.has(MCP_TOOL_SCOPES.coord_list_tasks)).toBe(false);
}
).toBe(true);
});
it('fails closed when scopes are not supplied by the authenticated context policy', () => {
@@ -322,20 +311,14 @@ describe('MCP actor identity and tool scope enforcement', () => {
]);
});
it('gives admin-role and platform-admin-role actors only owned content on brain reads (§1.1 retirement)', async () => {
// Contract 2 §1.1: users.role confers no content visibility. An actor whose
// role is 'admin', 'platform-admin', or 'super-admin' but who holds no
// ownership sees exactly what an unprivileged member with the same
// ownership would see — here, only the one project they own, and nothing
// tenant-wide or platform-wide.
const fixtures = {
it('enforces tenant boundaries for tenant-admin brain project, mission, and task reads', async () => {
const { service } = makeService({
projects: [
{ id: 'project-owned', ownerId: 'role-bearing-user', teamId: 'tenant-a', name: 'owned' },
{
id: 'project-tenant-a',
ownerId: 'other-user-a',
teamId: 'tenant-a',
name: 'same tenant, unowned',
name: 'same tenant',
},
{
id: 'project-tenant-b',
@@ -345,50 +328,39 @@ describe('MCP actor identity and tool scope enforcement', () => {
},
],
missions: [
{ id: 'mission-owned', projectId: 'project-owned' },
{ id: 'mission-tenant-a', tenantId: 'tenant-a', projectId: 'project-tenant-a' },
{ id: 'mission-tenant-b', tenantId: 'tenant-b', projectId: 'project-tenant-b' },
],
tasks: [
{ id: 'task-owned', projectId: 'project-owned', status: 'not-started' },
{ id: 'task-tenant-a', projectId: 'project-tenant-a', status: 'not-started' },
{ id: 'task-tenant-b', projectId: 'project-tenant-b', status: 'not-started' },
],
};
});
const { server, tools } = makeCapturingServer();
const actor = makeAdminActor('tenant-admin-user', 'tenant-a');
const actors = [
makeAdminActor('role-bearing-user', 'tenant-a'),
makePlatformAdminActor('role-bearing-user'),
];
service.registerTools(server, actor);
for (const actor of actors) {
const { service } = makeService(fixtures);
const { server, tools } = makeCapturingServer();
service.registerTools(server, actor);
const projects = JSON.parse(
(await getTool(tools, 'brain_list_projects').handler({})).content[0]!.text,
);
expect(projects.map((project: { id: string }) => project.id)).toEqual(['project-tenant-a']);
const projects = JSON.parse(
(await getTool(tools, 'brain_list_projects').handler({})).content[0]!.text,
);
expect(projects.map((project: { id: string }) => project.id)).toEqual(['project-owned']);
const missions = JSON.parse(
(await getTool(tools, 'brain_list_missions').handler({})).content[0]!.text,
);
expect(missions.map((mission: { id: string }) => mission.id)).toEqual(['mission-tenant-a']);
const missions = JSON.parse(
(await getTool(tools, 'brain_list_missions').handler({})).content[0]!.text,
);
expect(missions.map((mission: { id: string }) => mission.id)).toEqual(['mission-owned']);
const tasks = JSON.parse(
(await getTool(tools, 'brain_list_tasks').handler({})).content[0]!.text,
);
expect(tasks.map((task: { id: string }) => task.id)).toEqual(['task-owned']);
}
const tasks = JSON.parse(
(await getTool(tools, 'brain_list_tasks').handler({})).content[0]!.text,
);
expect(tasks.map((task: { id: string }) => task.id)).toEqual(['task-tenant-a']);
});
it('denies tenant-admin task writes outside the authenticated tenant', async () => {
const { service, brain } = makeService({
projects: [
// §1.1 retirement: content visibility comes from ownership, not the
// tenant-admin role — the acting user owns the tenant-a project.
{ id: 'project-tenant-a', ownerId: 'tenant-admin-user', teamId: 'tenant-a' },
{ id: 'project-tenant-a', ownerId: 'other-user-a', teamId: 'tenant-a' },
{ id: 'project-tenant-b', ownerId: 'other-user-b', teamId: 'tenant-b' },
],
missions: [
@@ -401,29 +373,7 @@ describe('MCP actor identity and tool scope enforcement', () => {
],
});
const { server, tools } = makeCapturingServer();
// Platform role no longer derives task-write scopes (§1.1 retirement):
// a role-derived admin actor is scope-denied before any tenant logic.
const roleDerivedAdmin = makeAdminActor('tenant-admin-user', 'tenant-a');
service.registerTools(server, roleDerivedAdmin);
await expect(
getTool(tools, 'brain_create_task').handler({ title: 'role-derived write' }),
).rejects.toThrow('MCP tool scope denied');
expect(brain.tasks.create).not.toHaveBeenCalled();
// The tenant-scoping checks below sit behind the scope gate; exercise
// them with explicitly granted task-write scopes (how grant-mapped
// scopes will arrive), not with a platform role.
tools.clear();
const actor = createMcpActorContext({
userId: 'tenant-admin-user',
tenantId: 'tenant-a',
role: 'member',
scopes: [
...deriveMcpToolScopesForUser({ role: 'member' }),
MCP_TOOL_SCOPES.brain_create_task,
MCP_TOOL_SCOPES.brain_update_task,
],
});
const actor = makeAdminActor('tenant-admin-user', 'tenant-a');
service.registerTools(server, actor);
@@ -466,7 +416,7 @@ describe('MCP actor identity and tool scope enforcement', () => {
);
});
it('denies coordination tools to every role-derived actor and keeps the granted path server-derived', async () => {
it('keeps admin-only coordination tools on server-derived paths', async () => {
const { service, coord } = makeService();
const { server, tools } = makeCapturingServer();
const member = makeMemberActor('authenticated-user');
@@ -483,25 +433,10 @@ describe('MCP actor identity and tool scope enforcement', () => {
const tenantAdminTool = getTool(tools, 'coord_list_tasks');
await expect(tenantAdminTool.handler({})).rejects.toThrow('MCP tool scope denied: coord:read');
// §1.1 retirement: platform-admin no longer derives coord scopes either.
tools.clear();
service.registerTools(server, platformAdmin);
const platformAdminTool = getTool(tools, 'coord_list_tasks');
await expect(platformAdminTool.handler({})).rejects.toThrow(
'MCP tool scope denied: coord:read',
);
// An explicitly granted coord:read scope reaches the server-derived
// path (caller-supplied projectPath is stripped by the schema).
tools.clear();
const grantedActor = createMcpActorContext({
userId: 'granted-user',
role: 'member',
scopes: [MCP_TOOL_SCOPES.coord_list_tasks],
});
service.registerTools(server, grantedActor);
const grantedTool = getTool(tools, 'coord_list_tasks');
await grantedTool.handler({ projectPath: '/tmp/victim' });
await platformAdminTool.handler({ projectPath: '/tmp/victim' });
expect(coord.listTasks).toHaveBeenCalledWith(process.cwd());
});
+57 -19
View File
@@ -63,6 +63,20 @@ interface SessionEntry {
actor: McpActorContext;
}
const GLOBAL_ADMIN_MCP_SCOPES = new Set<McpToolScope>(Object.values(MCP_TOOL_SCOPES));
const TENANT_ADMIN_MCP_SCOPES = new Set<McpToolScope>([
MCP_TOOL_SCOPES.brain_list_projects,
MCP_TOOL_SCOPES.brain_get_project,
MCP_TOOL_SCOPES.brain_list_tasks,
MCP_TOOL_SCOPES.brain_create_task,
MCP_TOOL_SCOPES.brain_update_task,
MCP_TOOL_SCOPES.brain_list_missions,
MCP_TOOL_SCOPES.brain_list_conversations,
MCP_TOOL_SCOPES.memory_search,
MCP_TOOL_SCOPES.memory_get_preferences,
MCP_TOOL_SCOPES.memory_save_preference,
MCP_TOOL_SCOPES.memory_save_insight,
]);
const MEMBER_MCP_SCOPES = new Set<McpToolScope>([
MCP_TOOL_SCOPES.brain_list_projects,
MCP_TOOL_SCOPES.brain_get_project,
@@ -75,17 +89,15 @@ const MEMBER_MCP_SCOPES = new Set<McpToolScope>([
MCP_TOOL_SCOPES.memory_save_insight,
]);
/**
* Contract 2 §1.1: platform role confers NO MCP scope elevation the
* former tenant-admin/global-admin scope sets keyed on users.role are
* retired. Every authenticated user receives the base member set; task
* writes and coordination scopes attach to explicit hierarchy grants when
* the MCP grant mapping lands, never to a platform role. The role
* parameter is kept for caller compatibility and deliberately ignored.
*/
export function deriveMcpToolScopesForUser(_input: {
export function deriveMcpToolScopesForUser(input: {
role?: string | null;
}): ReadonlySet<McpToolScope> {
if (input.role === 'platform-admin' || input.role === 'super-admin') {
return new Set(GLOBAL_ADMIN_MCP_SCOPES);
}
if (input.role === 'admin') {
return new Set(TENANT_ADMIN_MCP_SCOPES);
}
return new Set(MEMBER_MCP_SCOPES);
}
@@ -156,22 +168,41 @@ type TaskLike = TenantScopedLike & {
userId?: string | null;
};
/**
* Contract 2 §1.1: `users.role` confers NO content visibility the former
* global-admin/tenant-admin filter short-circuits keyed on the platform role
* are retired along with the role-derived scope sets. Content reaches an MCP
* actor through ownership only; widened access arrives as explicit hierarchy
* grants when the MCP grant mapping lands.
*/
function isGlobalAdminActor(actor: McpActorContext): boolean {
return actor.role === 'platform-admin' || actor.role === 'super-admin';
}
function isTenantAdminActor(actor: McpActorContext): boolean {
return actor.role === 'admin';
}
function matchesTenant(actor: McpActorContext, record: TenantScopedLike): boolean {
return (
record.tenantId === actor.tenantId ||
record.organizationId === actor.tenantId ||
record.teamId === actor.tenantId
);
}
function filterProjectsForActor<T extends ProjectLike>(actor: McpActorContext, projects: T[]): T[] {
return projects.filter((project) => project.ownerId === actor.userId);
if (isGlobalAdminActor(actor)) return projects;
return projects.filter(
(project) =>
project.ownerId === actor.userId ||
(isTenantAdminActor(actor) && matchesTenant(actor, project)),
);
}
function filterMissionsByDirectActorScope<T extends MissionLike>(
actor: McpActorContext,
missions: T[],
): T[] {
return missions.filter((mission) => mission.userId === actor.userId);
if (isGlobalAdminActor(actor)) return missions;
return missions.filter(
(mission) =>
mission.userId === actor.userId ||
(isTenantAdminActor(actor) && matchesTenant(actor, mission)),
);
}
function scopesEqual(left: ReadonlySet<McpToolScope>, right: ReadonlySet<McpToolScope>): boolean {
@@ -262,6 +293,7 @@ export class McpService implements OnModuleDestroy {
}
private async isProjectAuthorized(actor: McpActorContext, projectId: string): Promise<boolean> {
if (isGlobalAdminActor(actor)) return true;
const project = (await this.brain.projects.findById(projectId)) as ProjectLike | undefined;
return project ? filterProjectsForActor(actor, [project]).length === 1 : false;
}
@@ -270,6 +302,8 @@ export class McpService implements OnModuleDestroy {
actor: McpActorContext,
missions: T[],
): Promise<T[]> {
if (isGlobalAdminActor(actor)) return missions;
const projects = (await this.brain.projects.findAll()) as ProjectLike[];
const projectIds = new Set(
filterProjectsForActor(actor, projects).map((project) => project.id),
@@ -283,6 +317,7 @@ export class McpService implements OnModuleDestroy {
}
private async isMissionAuthorized(actor: McpActorContext, missionId: string): Promise<boolean> {
if (isGlobalAdminActor(actor)) return true;
const mission = (await this.brain.missions.findById(missionId)) as MissionLike | undefined;
if (!mission) return false;
return (await this.filterMissionsForActor(actor, [mission])).length === 1;
@@ -304,7 +339,7 @@ export class McpService implements OnModuleDestroy {
actor: McpActorContext,
refs: { projectId?: string | null; missionId?: string | null },
): Promise<void> {
if (!refs.projectId && !refs.missionId) {
if (!isGlobalAdminActor(actor) && !refs.projectId && !refs.missionId) {
throw new Error('MCP task scope denied');
}
await this.assertTaskReferencesAuthorized(actor, refs);
@@ -314,6 +349,8 @@ export class McpService implements OnModuleDestroy {
actor: McpActorContext,
tasks: T[],
): Promise<T[]> {
if (isGlobalAdminActor(actor)) return tasks;
const [projects, missions] = await Promise.all([
this.brain.projects.findAll(),
this.brain.missions.findAll(),
@@ -330,6 +367,7 @@ export class McpService implements OnModuleDestroy {
return tasks.filter(
(task) =>
task.userId === actor.userId ||
(isTenantAdminActor(actor) && matchesTenant(actor, task)) ||
(typeof task.projectId === 'string' && projectIds.has(task.projectId)) ||
(typeof task.missionId === 'string' && missionIds.has(task.missionId)),
);
@@ -108,13 +108,11 @@ export class MissionsController {
) {
const mission = await this.brain.missions.findByIdAndUser(missionId, user.id);
if (!mission) throw new NotFoundException('Mission not found');
// dto.status is deliberately not forwarded: mission_tasks.status is
// write-prohibited through the N-1 window (SHARED-CONTRACT §5.1 phase 1);
// the repo strips it as well.
return this.brain.missionTasks.create({
missionId,
taskId: dto.taskId,
userId: user.id,
status: dto.status,
description: dto.description,
notes: dto.notes,
pr: dto.pr,
-12
View File
@@ -77,12 +77,6 @@ export class CreateMissionTaskDto {
@IsUUID()
taskId?: string;
/**
* @deprecated Accepted for N-1 wire compatibility but ignored: mission_tasks.status
* is write-prohibited through the migration window (SHARED-CONTRACT §5.1 phase 1).
* The field stays declared because the global ValidationPipe runs with
* forbidNonWhitelisted, and removing it would 400 frozen legacy consumers.
*/
@IsOptional()
@IsIn(taskStatuses)
status?: 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled';
@@ -108,12 +102,6 @@ export class UpdateMissionTaskDto {
@IsUUID()
taskId?: string;
/**
* @deprecated Accepted for N-1 wire compatibility but ignored: mission_tasks.status
* is write-prohibited through the migration window (SHARED-CONTRACT §5.1 phase 1).
* The field stays declared because the global ValidationPipe runs with
* forbidNonWhitelisted, and removing it would 400 frozen legacy consumers.
*/
@IsOptional()
@IsIn(taskStatuses)
status?: 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled';
@@ -12,10 +12,6 @@ import { RuntimeProviderService } from '../agent/runtime-provider-registry.servi
import { ChatGateway } from '../chat/chat.gateway.js';
import { CommandAuthorizationService } from '../commands/command-authorization.service.js';
import { validateDiscordServiceToken } from '../chat/chat.gateway-auth.js';
import { ChatRuntimeRouter } from '../chat/chat-runtime-router.js';
import { EmbeddedChatRuntime } from '../chat/embedded-chat.runtime.js';
import { HarnessChatRuntime } from '../chat/harness-chat.runtime.js';
import { HarnessRegistry } from '../harness/harness.registry.js';
import { DiscordReplayProtector } from './discord-replay-protector.js';
const SERVICE_TOKEN = 'test-service-token';
@@ -29,7 +25,6 @@ const ENV_KEYS = [
'DISCORD_ALLOWED_USER_IDS',
'MOSAIC_AGENT_NAME',
'MOSAIC_AGENT_CONFIG_ID',
'CHAT_HARNESS_RUNTIME',
] as const;
const savedEnv = new Map<string, string | undefined>();
@@ -155,57 +150,6 @@ function createPayload(overrides: Partial<DiscordIngressPayload> = {}): DiscordI
};
}
/**
* Task 5 fence (C): the Discord SEND path runs through the exclusive {@link ChatRuntimeRouter},
* constructed here in `pi-rpc` mode with a fully-resolved runtime (`active` = harness). A verified
* Discord *service* turn must nonetheless execute on the {@link EmbeddedChatRuntime} never the
* harness, never the routing engine per the Q1/Q2 adjudication: the router owns a dedicated
* verified-ingress dispatch that delegates to embedded regardless of mode, with zero harness
* fallback. The gateway is given the router in the former direct-`AgentService` constructor slot.
*
* RED today: production still reads that slot as a bare `AgentService`, so `this.agentService`
* resolves to the router, `getSession(...)` is not a function, the send path throws and is caught
* (an `error` is emitted and the handler returns) BEFORE it ever reaches the embedded runtime. The
* failure is behavioural wiring collection, DI, and `onModuleInit` all succeed. GREEN re-routes
* the verified Discord dispatch through the router into the embedded runtime, satisfying the
* preserved create/prompt assertions without weakening any control. `harnessConversations.append`
* proves the harness path is never touched even though the pi-rpc router resolved it as `active`.
*
* Correction #4 is proved behaviourally, not by naming an accessor: the verified-ingress dispatch
* is reachable only from the fully-verified `discordService` branch (the create/prompt tests below)
* and never from a browser-emittable socket event (the browser-forgery refusal test).
*/
function readyPiRpcRegistry(): HarnessRegistry {
const registry = new HarnessRegistry();
// A registered 'pi' adapter + an available (non-sentinel) conversation service let the pi-rpc
// router resolve `active` = harness instead of failing closed at init, so these tests model the
// real hostile condition — the harness runtime IS live — rather than a degraded router.
registry.register({ id: 'pi' } as never);
return registry;
}
function piRpcRouterFronting(
agentService: unknown,
harnessConversations: { append: ReturnType<typeof vi.fn> },
): ChatRuntimeRouter {
const routerConversationServiceTripwire = {
append: () => {
throw new Error('router conversation service must not be resolved on the Discord path');
},
};
const embedded = new EmbeddedChatRuntime(agentService as never);
const harness = new HarnessChatRuntime(harnessConversations as never);
const router = new ChatRuntimeRouter(
readyPiRpcRegistry(),
routerConversationServiceTripwire as never,
embedded,
harness,
'pi-rpc',
);
router.onModuleInit();
return router;
}
describe('Discord ingress security', () => {
it('keeps legacy role-only bindings valid while withholding privileged actor identity', () => {
const [binding] = parseDiscordInteractionBindings(
@@ -489,7 +433,6 @@ describe('Discord ingress security', () => {
it("selects each binding's trusted logical-agent config when creating Discord sessions", async () => {
configureDiscordEnv();
process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc';
process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-001,channel-002';
process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([
{
@@ -546,9 +489,8 @@ describe('Discord ingress security', () => {
},
};
const routingEngine = { resolve: vi.fn() };
const harnessConversations = { append: vi.fn() };
const gateway = new ChatGateway(
piRpcRouterFronting(agentService, harnessConversations) as never,
agentService as never,
{} as never,
brain as never,
{} as never,
@@ -589,575 +531,6 @@ describe('Discord ingress security', () => {
expect.objectContaining({ agentConfigId: 'agent-config-orion' }),
);
expect(routingEngine.resolve).not.toHaveBeenCalled();
// Even though the pi-rpc router resolved the harness as `active`, verified Discord ingress must
// never touch it — the create path stays on the embedded runtime.
expect(harnessConversations.append).not.toHaveBeenCalled();
});
it('dispatches a verified Discord SEND once and drops a byte-identical replay with zero additional dispatch/persist/ack (Task 5 G4)', async () => {
configureDiscordEnv();
process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc';
process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-001';
process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([
{
instanceId: 'Nova',
agentConfigId: 'agent-config-nova',
guildId: 'guild-001',
channelId: 'channel-001',
pairedUsers: {
'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' },
},
},
]);
const session = {
provider: 'configured-provider',
modelId: 'configured-model',
agentConfigId: 'agent-config-nova',
agentName: 'Nova',
piSession: {
thinkingLevel: 'medium',
getAvailableThinkingLevels: (): string[] => ['medium'],
},
};
const createSession = vi.fn().mockResolvedValue(session);
const prompt = vi.fn().mockResolvedValue(undefined);
const agentService = {
getSession: vi.fn().mockReturnValue(undefined),
createSession,
recordMessage: vi.fn(),
onEvent: vi.fn().mockReturnValue((): void => undefined),
addChannel: vi.fn(),
removeChannel: vi.fn(),
prompt,
};
const addMessage = vi.fn().mockResolvedValue({ id: 'discord-persisted-message' });
const brain = {
agents: { findById: vi.fn((id: string) => Promise.resolve({ id, name: 'Nova' })) },
conversations: {
findById: vi.fn().mockResolvedValue({ id: 'Nova:discord:channel-001' }),
findMessages: vi.fn().mockResolvedValue([]),
create: vi.fn().mockResolvedValue(undefined),
update: vi.fn().mockResolvedValue(undefined),
addMessage,
},
};
const harnessConversations = { append: vi.fn() };
const gateway = new ChatGateway(
piRpcRouterFronting(agentService, harnessConversations) as never,
{} as never,
brain as never,
{} as never,
{} as never,
{ resolve: vi.fn() } as never,
);
const client = {
id: 'discord-client-replay',
data: { discordService: true },
emit: vi.fn(),
};
const ackCount = (): number =>
client.emit.mock.calls.filter((call) => call[0] === 'message:ack').length;
// One fully-valid signed envelope; the replay reuses the SAME object (same messageId).
const envelope = ingressEnvelope('verified once', 'discord-replay-001', {
conversationId: 'Nova:discord:channel-001',
});
// First delivery: the verified-Discord SEND runs the full embedded dispatch exactly once.
await gateway.handleMessage(client as never, envelope);
expect(createSession).toHaveBeenCalledTimes(1);
expect(prompt).toHaveBeenCalledTimes(1);
expect(addMessage).toHaveBeenCalledTimes(1);
expect(ackCount()).toBe(1);
// Byte-identical replay: the messageId is already claimed, so resolveDiscordIngress returns
// null and the SEND handler bails before dispatch/persist/ack. Every effect stays at exactly one.
await gateway.handleMessage(client as never, envelope);
expect(createSession).toHaveBeenCalledTimes(1);
expect(prompt).toHaveBeenCalledTimes(1);
expect(addMessage).toHaveBeenCalledTimes(1);
expect(ackCount()).toBe(1);
// The harness runtime is never touched on either delivery.
expect(harnessConversations.append).not.toHaveBeenCalled();
});
it('a verified SEND that fails the configured service identity consumes no replay claim, so a corrected byte-identical retry dispatches/persists/acks exactly once and a later duplicate stays fail-closed (Task 5 item 4 — claim ordering)', async () => {
configureDiscordEnv();
process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc';
process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-001';
process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([
{
instanceId: 'Nova',
agentConfigId: 'agent-config-nova',
guildId: 'guild-001',
channelId: 'channel-001',
pairedUsers: {
'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' },
},
},
]);
const session = {
provider: 'configured-provider',
modelId: 'configured-model',
agentConfigId: 'agent-config-nova',
agentName: 'Nova',
piSession: {
thinkingLevel: 'medium',
getAvailableThinkingLevels: (): string[] => ['medium'],
},
};
const createSession = vi.fn().mockResolvedValue(session);
const prompt = vi.fn().mockResolvedValue(undefined);
const agentService = {
getSession: vi.fn().mockReturnValue(undefined),
createSession,
recordMessage: vi.fn(),
onEvent: vi.fn().mockReturnValue((): void => undefined),
addChannel: vi.fn(),
removeChannel: vi.fn(),
prompt,
};
const addMessage = vi.fn().mockResolvedValue({ id: 'discord-persisted-message' });
const brain = {
agents: { findById: vi.fn((id: string) => Promise.resolve({ id, name: 'Nova' })) },
conversations: {
findById: vi.fn().mockResolvedValue({ id: 'Nova:discord:channel-001' }),
findMessages: vi.fn().mockResolvedValue([]),
create: vi.fn().mockResolvedValue(undefined),
update: vi.fn().mockResolvedValue(undefined),
addMessage,
},
};
const harnessConversations = { append: vi.fn() };
const gateway = new ChatGateway(
piRpcRouterFronting(agentService, harnessConversations) as never,
{} as never,
brain as never,
{} as never,
{} as never,
{ resolve: vi.fn() } as never,
);
const client = {
id: 'discord-client-claim-ordering',
data: { discordService: true },
emit: vi.fn(),
};
const ackCount = (): number =>
client.emit.mock.calls.filter((call) => call[0] === 'message:ack').length;
// A single fully-valid signed envelope, reused byte-for-byte across all three deliveries.
const envelope = ingressEnvelope('verified once with late identity', 'discord-order-001', {
conversationId: 'Nova:discord:channel-001',
});
// (1) Configured service identity is MISSING. The envelope is validly signed and passes the
// binding + route checks, but the SEND must refuse at the identity gate BEFORE any claim
// or effect. If the claim fires ahead of that gate, this delivery silently burns the
// replay claim for `discord-order-001` even though nothing dispatched.
delete process.env['DISCORD_SERVICE_USER_ID'];
await gateway.handleMessage(client as never, envelope);
expect(createSession).toHaveBeenCalledTimes(0);
expect(prompt).toHaveBeenCalledTimes(0);
expect(addMessage).toHaveBeenCalledTimes(0);
expect(ackCount()).toBe(0);
// (2) Identity is now configured; the operator resends the SAME envelope byte-for-byte. Because
// step (1) consumed no claim, this corrected retry claims once and runs the full embedded
// dispatch exactly once. (Under the pre-fix ordering the claim was already spent in step (1),
// so this retry is dropped as a replay and never dispatches — the RED this test drives.)
process.env['DISCORD_SERVICE_USER_ID'] = 'discord-service';
await gateway.handleMessage(client as never, envelope);
expect(createSession).toHaveBeenCalledTimes(1);
expect(prompt).toHaveBeenCalledTimes(1);
expect(addMessage).toHaveBeenCalledTimes(1);
expect(ackCount()).toBe(1);
// (3) A genuine duplicate after a committed turn stays fail-closed: the claim taken in step (2)
// blocks it, so every effect remains at exactly one.
await gateway.handleMessage(client as never, envelope);
expect(createSession).toHaveBeenCalledTimes(1);
expect(prompt).toHaveBeenCalledTimes(1);
expect(addMessage).toHaveBeenCalledTimes(1);
expect(ackCount()).toBe(1);
expect(harnessConversations.append).not.toHaveBeenCalled();
});
it('a verified SEND whose configured agent record fails reconciliation consumes no replay claim, so a corrected byte-identical retry dispatches/persists/acks exactly once (Task 5 finding 3)', async () => {
configureDiscordEnv();
process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc';
process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-001';
process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([
{
instanceId: 'Nova',
agentConfigId: 'agent-config-nova',
guildId: 'guild-001',
channelId: 'channel-001',
pairedUsers: {
'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' },
},
},
]);
const session = {
provider: 'configured-provider',
modelId: 'configured-model',
agentConfigId: 'agent-config-nova',
agentName: 'Nova',
piSession: {
thinkingLevel: 'medium',
getAvailableThinkingLevels: (): string[] => ['medium'],
},
};
const createSession = vi.fn().mockResolvedValue(session);
const prompt = vi.fn().mockResolvedValue(undefined);
const agentService = {
getSession: vi.fn().mockReturnValue(undefined),
createSession,
recordMessage: vi.fn(),
onEvent: vi.fn().mockReturnValue((): void => undefined),
addChannel: vi.fn(),
removeChannel: vi.fn(),
prompt,
};
const addMessage = vi.fn().mockResolvedValue({ id: 'discord-persisted-message' });
// The durable agent record does not reconcile on the first delivery (its name no longer matches
// the verified binding's instance id), then reconciles cleanly on the corrected retry.
const findAgent = vi
.fn()
.mockResolvedValueOnce({ id: 'agent-config-nova', name: 'Renamed-Away' })
.mockResolvedValue({ id: 'agent-config-nova', name: 'Nova' });
const brain = {
agents: { findById: findAgent },
conversations: {
findById: vi.fn().mockResolvedValue({ id: 'Nova:discord:channel-001' }),
findMessages: vi.fn().mockResolvedValue([]),
create: vi.fn().mockResolvedValue(undefined),
update: vi.fn().mockResolvedValue(undefined),
addMessage,
},
};
const harnessConversations = { append: vi.fn() };
const gateway = new ChatGateway(
piRpcRouterFronting(agentService, harnessConversations) as never,
{} as never,
brain as never,
{} as never,
{} as never,
{ resolve: vi.fn() } as never,
);
const client = {
id: 'discord-client-reconcile',
data: { discordService: true },
emit: vi.fn(),
};
const ackCount = (): number =>
client.emit.mock.calls.filter((call) => call[0] === 'message:ack').length;
const envelope = ingressEnvelope(
'verified once with stale agent record',
'discord-reconcile-001',
{
conversationId: 'Nova:discord:channel-001',
},
);
// (1) The configured-agent reconcile runs BEFORE the replay claim. A mismatch refuses the turn
// and, crucially, consumes no claim for discord-reconcile-001 — nothing dispatches.
await gateway.handleMessage(client as never, envelope);
expect(createSession).toHaveBeenCalledTimes(0);
expect(prompt).toHaveBeenCalledTimes(0);
expect(addMessage).toHaveBeenCalledTimes(0);
expect(ackCount()).toBe(0);
// (2) The record now reconciles; because step (1) took no claim, this byte-identical retry claims
// once and runs the full embedded dispatch exactly once. (Pre-fix, the claim was spent ahead
// of the reconcile in step (1), so this retry was dropped as a replay — the RED this drives.)
await gateway.handleMessage(client as never, envelope);
expect(createSession).toHaveBeenCalledTimes(1);
expect(prompt).toHaveBeenCalledTimes(1);
expect(addMessage).toHaveBeenCalledTimes(1);
expect(ackCount()).toBe(1);
// (3) A genuine duplicate after the committed turn stays fail-closed.
await gateway.handleMessage(client as never, envelope);
expect(createSession).toHaveBeenCalledTimes(1);
expect(prompt).toHaveBeenCalledTimes(1);
expect(addMessage).toHaveBeenCalledTimes(1);
expect(ackCount()).toBe(1);
expect(harnessConversations.append).not.toHaveBeenCalled();
});
it('a verified SEND refuses to reuse a same-scope embedded session minted under a different configured identity, with zero prompt/persist/ack (Task 5 finding 3)', async () => {
configureDiscordEnv();
process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc';
process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-001';
process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([
{
instanceId: 'Nova',
agentConfigId: 'agent-config-nova',
guildId: 'guild-001',
channelId: 'channel-001',
pairedUsers: {
'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' },
},
},
]);
// A live session already exists for this conversation/scope, but it was minted under a DIFFERENT
// configured agent (Orion). The verified binding reconciles to Nova, so reusing this session would
// execute one agent's turn under another agent's verified label — the reuse guard must refuse it.
const foreignIdentitySession = {
provider: 'configured-provider',
modelId: 'configured-model',
agentConfigId: 'agent-config-orion',
agentName: 'Orion',
piSession: {
thinkingLevel: 'medium',
getAvailableThinkingLevels: (): string[] => ['medium'],
},
};
const prompt = vi.fn().mockResolvedValue(undefined);
const createSession = vi.fn().mockResolvedValue(foreignIdentitySession);
const agentService = {
getSession: vi.fn().mockReturnValue(foreignIdentitySession),
createSession,
recordMessage: vi.fn(),
onEvent: vi.fn().mockReturnValue((): void => undefined),
addChannel: vi.fn(),
removeChannel: vi.fn(),
prompt,
};
const addMessage = vi.fn().mockResolvedValue({ id: 'discord-persisted-message' });
const brain = {
agents: { findById: vi.fn((id: string) => Promise.resolve({ id, name: 'Nova' })) },
conversations: {
findById: vi.fn().mockResolvedValue({ id: 'Nova:discord:channel-001' }),
findMessages: vi.fn().mockResolvedValue([]),
create: vi.fn().mockResolvedValue(undefined),
update: vi.fn().mockResolvedValue(undefined),
addMessage,
},
};
const harnessConversations = { append: vi.fn() };
const gateway = new ChatGateway(
piRpcRouterFronting(agentService, harnessConversations) as never,
{} as never,
brain as never,
{} as never,
{} as never,
{ resolve: vi.fn() } as never,
);
const client = {
id: 'discord-client-identity-swap',
data: { discordService: true },
emit: vi.fn(),
};
await gateway.handleMessage(
client as never,
ingressEnvelope('reuse under a different identity', 'discord-identity-swap-001', {
conversationId: 'Nova:discord:channel-001',
}),
);
// Refused at the embedded reuse guard: no prompt, no persist, no ack — only a typed refusal.
expect(prompt).not.toHaveBeenCalled();
expect(addMessage).not.toHaveBeenCalled();
expect(client.emit).not.toHaveBeenCalledWith('message:ack', expect.anything());
expect(client.emit).toHaveBeenCalledWith(
'error',
expect.objectContaining({ conversationId: 'Nova:discord:channel-001' }),
);
expect(harnessConversations.append).not.toHaveBeenCalled();
});
it('a verified SEND whose configured agent record resolves under a different id fails reconciliation, consumes no replay claim, and a corrected byte-identical retry dispatches/persists/acks exactly once (Task 5 finding 3 — id axis)', async () => {
configureDiscordEnv();
process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc';
process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-001';
process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([
{
instanceId: 'Nova',
agentConfigId: 'agent-config-nova',
guildId: 'guild-001',
channelId: 'channel-001',
pairedUsers: {
'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' },
},
},
]);
const session = {
provider: 'configured-provider',
modelId: 'configured-model',
agentConfigId: 'agent-config-nova',
agentName: 'Nova',
piSession: {
thinkingLevel: 'medium',
getAvailableThinkingLevels: (): string[] => ['medium'],
},
};
const createSession = vi.fn().mockResolvedValue(session);
const prompt = vi.fn().mockResolvedValue(undefined);
const agentService = {
getSession: vi.fn().mockReturnValue(undefined),
createSession,
recordMessage: vi.fn(),
onEvent: vi.fn().mockReturnValue((): void => undefined),
addChannel: vi.fn(),
removeChannel: vi.fn(),
prompt,
};
const addMessage = vi.fn().mockResolvedValue({ id: 'discord-persisted-message' });
// The name matches the verified binding, but the record's own id is a DIFFERENT agent config —
// an aliased/substituted lookup. Exact-id reconciliation must refuse it on the first delivery,
// then admit the corrected record whose id matches the binding.
const findAgent = vi
.fn()
.mockResolvedValueOnce({ id: 'agent-config-elsewhere', name: 'Nova' })
.mockResolvedValue({ id: 'agent-config-nova', name: 'Nova' });
const brain = {
agents: { findById: findAgent },
conversations: {
findById: vi.fn().mockResolvedValue({ id: 'Nova:discord:channel-001' }),
findMessages: vi.fn().mockResolvedValue([]),
create: vi.fn().mockResolvedValue(undefined),
update: vi.fn().mockResolvedValue(undefined),
addMessage,
},
};
const harnessConversations = { append: vi.fn() };
const gateway = new ChatGateway(
piRpcRouterFronting(agentService, harnessConversations) as never,
{} as never,
brain as never,
{} as never,
{} as never,
{ resolve: vi.fn() } as never,
);
const client = {
id: 'discord-client-reconcile-id',
data: { discordService: true },
emit: vi.fn(),
};
const ackCount = (): number =>
client.emit.mock.calls.filter((call) => call[0] === 'message:ack').length;
const envelope = ingressEnvelope(
'verified once with aliased agent id',
'discord-reconcile-id-001',
{
conversationId: 'Nova:discord:channel-001',
},
);
// (1) The record's id differs from the binding's agentConfigId. Exact-id reconcile refuses the
// turn BEFORE the replay claim, so nothing dispatches and the claim stays available.
await gateway.handleMessage(client as never, envelope);
expect(createSession).toHaveBeenCalledTimes(0);
expect(prompt).toHaveBeenCalledTimes(0);
expect(addMessage).toHaveBeenCalledTimes(0);
expect(ackCount()).toBe(0);
// (2) The record now reconciles on both id and name; because step (1) took no claim, this
// byte-identical retry claims once and runs the full embedded dispatch exactly once.
await gateway.handleMessage(client as never, envelope);
expect(createSession).toHaveBeenCalledTimes(1);
expect(prompt).toHaveBeenCalledTimes(1);
expect(addMessage).toHaveBeenCalledTimes(1);
expect(ackCount()).toBe(1);
// (3) A genuine duplicate after the committed turn stays fail-closed.
await gateway.handleMessage(client as never, envelope);
expect(createSession).toHaveBeenCalledTimes(1);
expect(prompt).toHaveBeenCalledTimes(1);
expect(addMessage).toHaveBeenCalledTimes(1);
expect(ackCount()).toBe(1);
expect(harnessConversations.append).not.toHaveBeenCalled();
});
it('a verified SEND refuses a freshly minted same-scope session whose identity differs from the reconciled configured agent, with zero prompt/persist/ack (Task 5 finding 3 — post-create)', async () => {
configureDiscordEnv();
process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc';
process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-001';
process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([
{
instanceId: 'Nova',
agentConfigId: 'agent-config-nova',
guildId: 'guild-001',
channelId: 'channel-001',
pairedUsers: {
'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' },
},
},
]);
// No live session exists for this scope, so the runtime MINTS one — but createSession returns a
// session carrying a DIFFERENT configured identity (Orion) than the reconciled binding (Nova).
// The post-create identity recheck must refuse it rather than dispatch one agent's turn under
// another agent's verified label. (The existing reuse test covers the getSession path; this
// covers the createSession path scrappy flagged as unvalidated.)
const mintedForeignSession = {
provider: 'configured-provider',
modelId: 'configured-model',
agentConfigId: 'agent-config-orion',
agentName: 'Orion',
piSession: {
thinkingLevel: 'medium',
getAvailableThinkingLevels: (): string[] => ['medium'],
},
};
const prompt = vi.fn().mockResolvedValue(undefined);
const createSession = vi.fn().mockResolvedValue(mintedForeignSession);
const agentService = {
getSession: vi.fn().mockReturnValue(undefined),
createSession,
recordMessage: vi.fn(),
onEvent: vi.fn().mockReturnValue((): void => undefined),
addChannel: vi.fn(),
removeChannel: vi.fn(),
prompt,
};
const addMessage = vi.fn().mockResolvedValue({ id: 'discord-persisted-message' });
const brain = {
agents: { findById: vi.fn((id: string) => Promise.resolve({ id, name: 'Nova' })) },
conversations: {
findById: vi.fn().mockResolvedValue({ id: 'Nova:discord:channel-001' }),
findMessages: vi.fn().mockResolvedValue([]),
create: vi.fn().mockResolvedValue(undefined),
update: vi.fn().mockResolvedValue(undefined),
addMessage,
},
};
const harnessConversations = { append: vi.fn() };
const gateway = new ChatGateway(
piRpcRouterFronting(agentService, harnessConversations) as never,
{} as never,
brain as never,
{} as never,
{} as never,
{ resolve: vi.fn() } as never,
);
const client = {
id: 'discord-client-postcreate-mismatch',
data: { discordService: true },
emit: vi.fn(),
};
await gateway.handleMessage(
client as never,
ingressEnvelope('mint under a different identity', 'discord-postcreate-001', {
conversationId: 'Nova:discord:channel-001',
}),
);
// The freshly minted session failed the post-create identity recheck: refused with a typed
// error, no prompt, no persist, no ack.
expect(createSession).toHaveBeenCalledTimes(1);
expect(prompt).not.toHaveBeenCalled();
expect(addMessage).not.toHaveBeenCalled();
expect(client.emit).not.toHaveBeenCalledWith('message:ack', expect.anything());
expect(client.emit).toHaveBeenCalledWith(
'error',
expect.objectContaining({ conversationId: 'Nova:discord:channel-001' }),
);
expect(harnessConversations.append).not.toHaveBeenCalled();
});
it('retains validated persisted attachments in resumed conversation history', async () => {
@@ -1220,16 +593,11 @@ describe('Discord ingress security', () => {
it('preserves authenticated attachment metadata through persistence and agent dispatch', async () => {
configureDiscordEnv();
process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc';
const prompt = vi.fn().mockResolvedValue(undefined);
const addMessage = vi.fn().mockResolvedValue({ id: 'discord-persisted-message' });
const addMessage = vi.fn().mockResolvedValue(undefined);
const session = {
provider: 'test-provider',
modelId: 'test-model',
// The reused embedded session carries the SAME reconciled identity as the verified binding,
// so the finding-3 session-reuse guard admits it rather than refusing an identity swap.
agentConfigId: 'agent-config-nova',
agentName: 'Nova',
piSession: {
thinkingLevel: 'medium',
getAvailableThinkingLevels: (): string[] => ['medium'],
@@ -1243,7 +611,6 @@ describe('Discord ingress security', () => {
prompt,
};
const brain = {
agents: { findById: vi.fn((id: string) => Promise.resolve({ id, name: 'Nova' })) },
conversations: {
findById: vi.fn().mockResolvedValue({ id: 'Nova:discord:channel-001' }),
create: vi.fn().mockResolvedValue(undefined),
@@ -1251,9 +618,8 @@ describe('Discord ingress security', () => {
addMessage,
},
};
const harnessConversations = { append: vi.fn() };
const gateway = new ChatGateway(
piRpcRouterFronting(agentService, harnessConversations) as never,
agentService as never,
{} as never,
brain as never,
{} as never,
@@ -1301,66 +667,6 @@ describe('Discord ingress security', () => {
}),
'discord-service',
);
// The verified Discord prompt dispatch stays on the embedded runtime; the pi-rpc harness that
// the router resolved as `active` is never reached.
expect(harnessConversations.append).not.toHaveBeenCalled();
});
it('refuses a browser-forged Discord ingress envelope in pi-rpc with a fixed typed refusal and zero dispatch', async () => {
// Correction #2 + #4 (behavioural). A browser socket is never `discordService` (that flag is
// set only on a valid service-token handshake), so it cannot forge the trusted Discord path by
// emitting an envelope-shaped payload. In pi-rpc it must receive a FIXED TYPED refusal
// (`runtime_unsupported`, the same typed code the sibling harness-fence uses) and reach neither
// the forced Discord service scope, the verified Discord operation, the embedded runtime, nor
// the harness. There is no dedicated socket event for verified ingress — the only ingress
// surface is the generic `message` handler, and a non-service client is refused there.
//
// RED today: a non-service client emitting an envelope-shaped payload falls to the browser
// branch, fails the chat-message shape check, and is dropped SILENTLY (a warn + return) with no
// typed refusal emitted — so the refusal assertion fails. Collection and construction succeed;
// the gap is behavioural. GREEN emits the fixed typed refusal before any dispatch.
configureDiscordEnv();
process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc';
const agentService = {
getSession: vi.fn().mockReturnValue(undefined),
createSession: vi.fn(),
recordMessage: vi.fn(),
onEvent: vi.fn().mockReturnValue((): void => undefined),
addChannel: vi.fn(),
prompt: vi.fn().mockResolvedValue(undefined),
};
const harnessConversations = { append: vi.fn() };
const routingEngine = { resolve: vi.fn() };
const gateway = new ChatGateway(
piRpcRouterFronting(agentService, harnessConversations) as never,
{} as never,
{ conversations: { addMessage: vi.fn().mockResolvedValue(undefined) } } as never,
{} as never,
{} as never,
routingEngine as never,
);
const client = {
id: 'browser-forging-discord',
data: { discordService: false },
emit: vi.fn(),
};
await gateway.handleMessage(
client as never,
ingressEnvelope('forged from a browser', 'browser-forgery-001', {
conversationId: 'Nova:discord:channel-001',
}),
);
const refusal = client.emit.mock.calls.find(
([, payload]) => (payload as { code?: string } | undefined)?.code === 'runtime_unsupported',
);
expect(refusal).toBeDefined();
expect(client.emit).not.toHaveBeenCalledWith('message:ack', expect.anything());
expect(agentService.createSession).not.toHaveBeenCalled();
expect(agentService.prompt).not.toHaveBeenCalled();
expect(harnessConversations.append).not.toHaveBeenCalled();
expect(routingEngine.resolve).not.toHaveBeenCalled();
});
it('accepts a thread message through its allowed bound parent channel', () => {
@@ -159,7 +159,6 @@ describe('ReloadService — /reload command sanitizes plugin errors', () => {
reloadService,
mockChatGateway as never,
mockMcpClient as never,
{ authorize: vi.fn().mockResolvedValue({ allowed: true }) } as never,
);
const payload: SlashCommandPayload = { command: 'reload', conversationId: 'conv-1' };
-192
View File
@@ -1,192 +0,0 @@
/**
* E2E integration test SPA static serving (Phase P5 cutover, #1444; tests
* added in P6, #1445, review follow-up SF1 on PR #1453).
*
* Boots a real Nest+Fastify app the way main.ts does (mountSpaStatic after the
* controllers) against a fixture dist directory, and pins the serving
* contract:
*
* 1. `/` and client-side deep links fall back to index.html.
* 2. Declared API routes win over the catch-all.
* 3. Unknown backend paths (/api, /mcp, /socket.io) are JSON 404s, never the
* SPA page including with a query string (`/api?x=1`).
* 4. Static files are served exactly; hashed /assets/ files get immutable
* cache headers, everything else revalidates (max-age=0), and a missing
* /assets/ file is a 404 never the SPA fallback.
* 5. WEB_DIST_DIR unset disables SPA serving entirely.
* 6. WEB_DIST_DIR pointing at a directory without index.html fails at boot.
*/
import 'reflect-metadata';
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { describe, it, expect, afterAll, beforeAll } from 'vitest';
import { Test } from '@nestjs/testing';
import { Controller, Get, type INestApplication } from '@nestjs/common';
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
import request from 'supertest';
import { mountSpaStatic } from './serve-spa.js';
const INDEX_HTML = '<!doctype html><html><body>mosaic spa fixture</body></html>\n';
const ASSET_JS = 'console.log("hashed asset");\n';
@Controller('api/spa-test')
class SpaTestController {
@Get('ping')
ping(): { ok: boolean } {
return { ok: true };
}
}
async function createApp(): Promise<INestApplication> {
const moduleRef = await Test.createTestingModule({
controllers: [SpaTestController],
}).compile();
const app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
await app.init();
// Mirror main.ts ordering: SPA mounting happens after the app (and its
// controllers) exist, before listen.
await mountSpaStatic(app as NestFastifyApplication);
await (app as NestFastifyApplication).getHttpAdapter().getInstance().ready();
return app;
}
describe('SPA static serving — fixture dist dir', () => {
let app: INestApplication;
let distDir: string;
let previousWebDistDir: string | undefined;
beforeAll(async () => {
distDir = await mkdtemp(path.join(tmpdir(), 'serve-spa-fixture-'));
await writeFile(path.join(distDir, 'index.html'), INDEX_HTML);
await writeFile(path.join(distDir, 'favicon.svg'), '<svg></svg>\n');
await mkdir(path.join(distDir, 'assets'), { recursive: true });
await writeFile(path.join(distDir, 'assets', 'app-abc123.js'), ASSET_JS);
previousWebDistDir = process.env['WEB_DIST_DIR'];
process.env['WEB_DIST_DIR'] = distDir;
app = await createApp();
});
afterAll(async () => {
if (previousWebDistDir === undefined) {
delete process.env['WEB_DIST_DIR'];
} else {
process.env['WEB_DIST_DIR'] = previousWebDistDir;
}
await app.close();
await rm(distDir, { recursive: true, force: true });
});
it('serves index.html at /', async () => {
const res = await request(app.getHttpServer()).get('/');
expect(res.status).toBe(200);
expect(res.text).toBe(INDEX_HTML);
expect(res.headers['content-type']).toContain('text/html');
});
it('falls back to index.html for client-side deep links', async () => {
for (const deepLink of ['/chat', '/projects/42', '/settings']) {
const res = await request(app.getHttpServer()).get(deepLink);
expect(res.status, deepLink).toBe(200);
expect(res.text, deepLink).toBe(INDEX_HTML);
}
});
it('declared API routes win over the SPA catch-all', async () => {
const res = await request(app.getHttpServer()).get('/api/spa-test/ping');
expect(res.status).toBe(200);
expect(res.body).toEqual({ ok: true });
});
it('unknown backend paths are JSON 404s, never the SPA page', async () => {
for (const backendPath of ['/api/nope', '/api', '/mcp/nope', '/socket.io/nope']) {
const res = await request(app.getHttpServer()).get(backendPath);
expect(res.status, backendPath).toBe(404);
expect(res.headers['content-type'], backendPath).toContain('application/json');
expect(res.body, backendPath).toMatchObject({ error: 'Not Found', statusCode: 404 });
}
});
it('a backend path with a query string is still a backend 404 (/api?x=1)', async () => {
const res = await request(app.getHttpServer()).get('/api?x=1');
expect(res.status).toBe(404);
expect(res.headers['content-type']).toContain('application/json');
});
it('serves static files exactly', async () => {
const res = await request(app.getHttpServer()).get('/favicon.svg');
expect(res.status).toBe(200);
// supertest buffers image/svg+xml as a Buffer body, not res.text.
const body = res.text || (res.body as Buffer).toString('utf8');
expect(body).toBe('<svg></svg>\n');
});
it('hashed /assets/ files get immutable cache headers', async () => {
const res = await request(app.getHttpServer()).get('/assets/app-abc123.js');
expect(res.status).toBe(200);
expect(res.text).toBe(ASSET_JS);
expect(res.headers['cache-control']).toBe('public, max-age=31536000, immutable');
});
it('missing /assets/ files are 404s, never the SPA page with an immutable header', async () => {
// The exact request a browser with a stale index.html makes after a
// deploy: the old hashed filename. Serving index.html here would poison
// caches with a year-long immutable entry whose body is HTML.
for (const missingAsset of ['/assets/app-old999.js', '/assets/app-old999.js?v=1']) {
const res = await request(app.getHttpServer()).get(missingAsset);
expect(res.status, missingAsset).toBe(404);
expect(res.text, missingAsset).not.toContain('mosaic spa fixture');
// The 404 carries no cache-control at all; ?? '' keeps the assertion valid.
expect(res.headers['cache-control'] ?? '', missingAsset).not.toContain('immutable');
}
});
it('index.html and non-asset files revalidate (no immutable caching)', async () => {
for (const revalidating of ['/', '/chat', '/favicon.svg']) {
const res = await request(app.getHttpServer()).get(revalidating);
expect(res.headers['cache-control'], revalidating).not.toContain('immutable');
}
});
it('non-GET unmatched requests keep the stock 404 (catch-all is GET/HEAD only)', async () => {
const res = await request(app.getHttpServer()).post('/chat');
expect(res.status).toBe(404);
expect(res.text).not.toContain('mosaic spa fixture');
});
});
describe('SPA static serving — configuration edges', () => {
it('WEB_DIST_DIR unset disables SPA serving', async () => {
const previous = process.env['WEB_DIST_DIR'];
delete process.env['WEB_DIST_DIR'];
try {
const app = await createApp();
const res = await request(app.getHttpServer()).get('/chat');
expect(res.status).toBe(404);
await app.close();
} finally {
if (previous !== undefined) {
process.env['WEB_DIST_DIR'] = previous;
}
}
});
it('WEB_DIST_DIR without index.html fails at boot', async () => {
const emptyDir = await mkdtemp(path.join(tmpdir(), 'serve-spa-empty-'));
const previous = process.env['WEB_DIST_DIR'];
process.env['WEB_DIST_DIR'] = emptyDir;
try {
await expect(createApp()).rejects.toThrow(/index\.html.*does not exist/);
} finally {
if (previous === undefined) {
delete process.env['WEB_DIST_DIR'];
} else {
process.env['WEB_DIST_DIR'] = previous;
}
await rm(emptyDir, { recursive: true, force: true });
}
});
});
-106
View File
@@ -1,106 +0,0 @@
import { existsSync } from 'node:fs';
import path from 'node:path';
import { Logger } from '@nestjs/common';
import fastifyStatic from '@fastify/static';
import type { NestFastifyApplication } from '@nestjs/platform-fastify';
/** Request paths that belong to the backend, never to the SPA fallback. */
const BACKEND_PREFIXES = ['/api', '/mcp', '/socket.io'] as const;
function isBackendPath(url: string): boolean {
// Match on the path only: `/api?x=1` is a backend request, and the query
// string must never turn it into an SPA fallback.
const pathOnly = url.split('?', 1)[0] ?? url;
return BACKEND_PREFIXES.some(
(prefix) => pathOnly === prefix || pathOnly.startsWith(`${prefix}/`),
);
}
/**
* Serve the built web SPA bundle (Phase P5 cutover, #1444).
*
* WEB_DIST_DIR unset: SPA serving is disabled dev runs the Vite dev server,
* which proxies /api and /socket.io here. WEB_DIST_DIR set but not holding a
* built bundle: fail at boot, because a gateway configured to serve the UI
* silently serving 404s is an outage, not a degraded mode.
*
* Static files get exact routes (wildcard: false, so nothing shadows the API
* routes); every other GET/HEAD outside the backend prefixes falls back to
* index.html so client-side routes deep-link correctly.
*/
export async function mountSpaStatic(app: NestFastifyApplication): Promise<void> {
const logger = new Logger('SpaStatic');
const distDir = process.env['WEB_DIST_DIR'];
if (!distDir) {
logger.log('WEB_DIST_DIR not set; SPA serving disabled (dev mode uses the Vite dev server)');
return;
}
const root = path.resolve(distDir);
const indexFile = path.join(root, 'index.html');
if (!existsSync(indexFile)) {
throw new Error(`WEB_DIST_DIR is '${distDir}' but '${indexFile}' does not exist`);
}
// Default cache semantics: public, max-age=0 with ETag/Last-Modified, so
// every response revalidates (304 when unchanged). Always correct, including
// for index.html after a deploy.
await app.register(
fastifyStatic as never,
{
root,
wildcard: false,
index: false,
} as never,
);
const fastify = app.getHttpAdapter().getInstance();
// Files under /assets/ carry a content hash in their name (Vite emits them
// that way), so they get long-lived immutable caching: a changed file is a
// new URL, never a stale cache hit. An onSend hook rather than the plugin's
// `setHeaders` option, because @fastify/static applies its own computed
// cache-control (reply.headers) after calling setHeaders, overriding it.
fastify.addHook('onSend', (req, reply, payload, done) => {
const pathOnly = (req.raw.url ?? '').split('?', 1)[0] ?? '';
if (reply.statusCode === 200 && pathOnly.startsWith('/assets/')) {
void reply.header('cache-control', 'public, max-age=31536000, immutable');
}
done(null, payload);
});
// A wildcard route, not setNotFoundHandler: Nest installs its own not-found
// handler during init and Fastify allows only one. find-my-way matches
// most-specific-first, so every declared route (API, static files) wins over
// this catch-all; non-GET unmatched requests keep Fastify's stock 404.
fastify.get('/*', (req, reply) => {
const url = req.raw.url ?? '';
const pathOnly = url.split('?', 1)[0] ?? url;
if (isBackendPath(url)) {
// An unknown backend path is an API 404, never the SPA page.
void reply.code(404).send({
message: `Route ${req.raw.method ?? 'GET'}:${url} not found`,
error: 'Not Found',
statusCode: 404,
});
return;
}
if (pathOnly === '/assets' || pathOnly.startsWith('/assets/')) {
// A missing hashed asset — typically a browser holding a stale
// index.html after a deploy — must 404. Falling through to the SPA
// fallback would return index.html as the asset body, and the onSend
// hook above would stamp it with a year-long immutable cache-control.
void reply.code(404).send({
message: `Asset ${pathOnly} not found`,
error: 'Not Found',
statusCode: 404,
});
return;
}
// sendFile is decorated by @fastify/static; its type augmentation targets
// a different fastify copy in the pnpm tree than the Nest adapter's.
(reply as unknown as { sendFile: (file: string) => unknown }).sendFile('index.html');
});
logger.log(`Serving SPA bundle from ${root}`);
}
@@ -1,104 +0,0 @@
/**
* Boot-time ValidationPipe metatype self-check (#1391).
*
* The check exists to fail loud at boot when the global pipe cannot see a
* guarded DTO's decorated properties the #436 class-erasure signature and
* its dependency-graph cousins. Red/green arms:
*
* GREEN real module state: BootstrapSetupDto's three properties are
* decorated and visible through the globalThis-shared storage.
* RED a control class with NO decorators (the erasure shape): the
* check throws PipeMetatypeCheckError naming every property.
* RED-2 a control where one property is decorated and two are not: the
* error names exactly the missing two the miss list is precise,
* not a blanket failure.
*/
import { describe, expect, it } from 'vitest';
import { IsString } from 'class-validator';
import {
assertValidationPipeSeesDtoDecorators,
PipeMetatypeCheckError,
} from './validation-pipe-check.js';
describe('assertValidationPipeSeesDtoDecorators (#1391 boot check)', () => {
it('GREEN: passes on real module state (decorated DTO visible to the pipe)', () => {
expect(() => assertValidationPipeSeesDtoDecorators()).not.toThrow();
});
it('RED control: a class whose properties lost their decorators throws, naming them', async () => {
// Simulate metatype erasure: an undecorated class standing where a
// decorated DTO should be. Redefine the guard table for the test by
// importing the module and pointing its table at the eroded class —
// the check reads the table at call time, so a fresh module instance
// with a swapped table reproduces the boot failure deterministically.
const { PIPE_GUARDED_DTOS } = await import('./validation-pipe-check.js');
class ErodedDto {
name?: string;
email?: string;
password?: string;
}
const original = PIPE_GUARDED_DTOS[0];
expect(original).toBeDefined();
// Swap in the eroded target (same declared properties, zero decorators).
(
PIPE_GUARDED_DTOS as unknown as Array<{ name: string; target: object; properties: string[] }>
).splice(0, PIPE_GUARDED_DTOS.length, {
name: 'ErodedDto',
target: ErodedDto,
properties: ['name', 'email', 'password'],
});
try {
expect(() => assertValidationPipeSeesDtoDecorators()).toThrow(PipeMetatypeCheckError);
try {
assertValidationPipeSeesDtoDecorators();
} catch (err) {
const message = err instanceof Error ? err.message : '';
expect(message).toContain('ErodedDto.name');
expect(message).toContain('ErodedDto.email');
expect(message).toContain('ErodedDto.password');
}
} finally {
// Restore real module state for any later test in this file.
(PIPE_GUARDED_DTOS as unknown as unknown[]).splice(0, PIPE_GUARDED_DTOS.length, original);
}
// And confirm the restore is real.
expect(() => assertValidationPipeSeesDtoDecorators()).not.toThrow();
});
it('RED-2 control: a partially decorated class names exactly the missing properties', async () => {
const { PIPE_GUARDED_DTOS } = await import('./validation-pipe-check.js');
class HalfErodedDto {
@IsString()
name?: string;
email?: string;
password?: string;
}
const original = PIPE_GUARDED_DTOS[0];
(
PIPE_GUARDED_DTOS as unknown as Array<{ name: string; target: object; properties: string[] }>
).splice(0, PIPE_GUARDED_DTOS.length, {
name: 'HalfErodedDto',
target: HalfErodedDto,
properties: ['name', 'email', 'password'],
});
try {
try {
assertValidationPipeSeesDtoDecorators();
expect.unreachable('partially decorated DTO must fail the boot check');
} catch (err) {
const message = err instanceof Error ? err.message : '';
expect(message).toContain('HalfErodedDto.email');
expect(message).toContain('HalfErodedDto.password');
expect(message).not.toContain('HalfErodedDto.name has no');
}
} finally {
(PIPE_GUARDED_DTOS as unknown as unknown[]).splice(0, PIPE_GUARDED_DTOS.length, original);
}
});
});
-186
View File
@@ -1,186 +0,0 @@
import 'reflect-metadata';
import { getMetadataStorage } from 'class-validator';
import { BootstrapSetupDto } from './admin/bootstrap.dto.js';
import {
ChangeCompanyVisibilityDto,
ChangeGrantDto,
CreateCompanyDto,
CreateEstateDto,
CreateGrantDto,
CreatePlatformProjectDto,
DeleteNodeDto,
RenameNodeDto,
TransferEstateDto,
TransferPlatformProjectDto,
} from './hierarchy/hierarchy.dto.js';
import {
EnrollAgentDto,
EnrollCredentialDto,
GetEnrollmentQueryDto,
} from './enrollment/enrollment.dto.js';
/**
* Boot-time self-check: the global ValidationPipe must be able to SEE the
* decorated properties of the DTOs it guards (#1391, #436 class).
*
* WHY THIS EXISTS. When Nest resolves a @Body() metatype to Object via
* `import type` class erasure (#436), or a dependency graph where the
* controller's decorators and the application's route enhancers disagree
* (#1391's hypothesized dual-@nestjs/common on a mixed install) the
* ValidationPipe's whitelist treats every property as forbidden. The first
* symptom is a 400 on the FIRST bootstrap attempt of a fresh install, the
* worst place to discover wiring damage: the operator cannot tell a broken
* payload from a broken daemon.
*
* This check fails LOUD at boot instead: if the pipe cannot see the DTO's
* decorated properties, the gateway refuses to start with a named cause.
* It catches the whole class erasure, decorator metadata loss on every
* host, at the moment the damage exists rather than at first use.
*
* Storage sharing note: class-validator keys its metadata storage on
* globalThis, so duplicate package copies do NOT hide metadata (measured,
* #1391 diagnosis). What hides it is losing the metatype itself, which is
* what this asserts against.
*/
/**
* DTOs the global pipe guards, mapped to the properties the whitelist must
* admit. Target is the CONSTRUCTOR (the object class itself): class-validator
* decorators register metadata keyed on the constructor, and its executor
* looks up `object.constructor` (ValidationExecutor.js:50) the probe
* through `prototype` returns zero. Extend when adding DTOs to the app.
*/
export const PIPE_GUARDED_DTOS: Array<{
name: string;
target: abstract new (...args: never[]) => unknown;
properties: string[];
}> = [
{
name: 'BootstrapSetupDto',
target: BootstrapSetupDto,
properties: ['name', 'email', 'password'],
},
{
name: 'CreateCompanyDto',
target: CreateCompanyDto,
properties: ['name', 'slug', 'idempotencyKey'],
},
{
name: 'RenameNodeDto',
target: RenameNodeDto,
properties: ['name', 'idempotencyKey'],
},
{
name: 'ChangeCompanyVisibilityDto',
target: ChangeCompanyVisibilityDto,
properties: ['visibility', 'idempotencyKey'],
},
{
name: 'DeleteNodeDto',
target: DeleteNodeDto,
properties: ['idempotencyKey'],
},
{
name: 'CreateEstateDto',
target: CreateEstateDto,
properties: ['companyId', 'name', 'slug', 'idempotencyKey'],
},
{
name: 'CreatePlatformProjectDto',
target: CreatePlatformProjectDto,
properties: ['estateId', 'name', 'slug', 'idempotencyKey'],
},
{
name: 'TransferEstateDto',
target: TransferEstateDto,
properties: ['destinationCompanyId', 'idempotencyKey'],
},
{
name: 'TransferPlatformProjectDto',
target: TransferPlatformProjectDto,
properties: ['destinationEstateId', 'idempotencyKey'],
},
{
name: 'CreateGrantDto',
target: CreateGrantDto,
properties: ['userId', 'targetKind', 'targetId', 'role', 'idempotencyKey'],
},
{
name: 'ChangeGrantDto',
target: ChangeGrantDto,
properties: ['role', 'idempotencyKey'],
},
{
name: 'EnrollAgentDto',
target: EnrollAgentDto,
properties: [
'harness',
'name',
'persona',
'model',
'provider',
'credential',
'idempotencyKey',
'correlationId',
'replayMode',
],
},
{
name: 'EnrollCredentialDto',
target: EnrollCredentialDto,
properties: ['mode', 'type', 'value'],
},
{
name: 'GetEnrollmentQueryDto',
target: GetEnrollmentQueryDto,
properties: ['correlationId'],
},
];
export class PipeMetatypeCheckError extends Error {
constructor(missing: string[]) {
super(
'ValidationPipe metatype check failed: ' +
missing.join('; ') +
'. The global ValidationPipe cannot see decorated DTO properties — ' +
'every request body would be rejected as non-whitelisted. ' +
'Check for import-type erasure or decorator metadata loss in the ' +
'dependency graph (see issues #436, #1391).',
);
this.name = 'PipeMetatypeCheckError';
}
}
/**
* Assert the pipe's whitelist can see every guarded DTO's decorated
* properties. Throws PipeMetatypeCheckError (fail-loud at boot) listing
* each miss. Pure function of module state: no I/O, safe to call twice.
*/
export function assertValidationPipeSeesDtoDecorators(): void {
const storage = getMetadataStorage();
const missing: string[] = [];
for (const dto of PIPE_GUARDED_DTOS) {
// class-validator records constraints keyed on the DTO's constructor
// (decorators run on the class), and its executor resolves them via
// object.constructor. A property with no recorded metadata is invisible
// to the whitelist — whatever the cause — and fails here.
// Signature mirrors ValidationExecutor.js:50 — (constructor, schema, always,
// strictGroups, groups?). No schema, always=true, no groups: every
// constraint regardless of grouping, which is what the whitelist sees.
const metadatas = storage.getTargetValidationMetadatas(dto.target, '', true, false);
const decorated = new Set(metadatas.map((m) => m.propertyName));
for (const property of dto.properties) {
if (!decorated.has(property)) {
missing.push(
`${dto.name}.${property} has no class-validator constraints visible to the pipe`,
);
}
}
}
if (missing.length > 0) {
throw new PipeMetatypeCheckError(missing);
}
}
@@ -1,123 +0,0 @@
import 'reflect-metadata';
import { type CanActivate, type ExecutionContext, type INestApplication } from '@nestjs/common';
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { AuthGuard } from '../auth/auth.guard.js';
import { TeamsController } from './teams.controller.js';
import { TeamsService } from './teams.service.js';
const teamAlpha = { id: 'team-alpha', name: 'Alpha' };
const teamBeta = { id: 'team-beta', name: 'Beta' };
// user-1 is a member of team-alpha only; admin-1 has role admin.
let currentUser: { id: string; role?: string } = { id: 'user-1' };
const teamsServiceMock = {
findAll: vi.fn(() => Promise.resolve([teamAlpha, teamBeta])),
findAllForUser: vi.fn((userId: string) =>
Promise.resolve(userId === 'user-1' ? [teamAlpha] : []),
),
findById: vi.fn((id: string) => Promise.resolve([teamAlpha, teamBeta].find((t) => t.id === id))),
listMembers: vi.fn(() => Promise.resolve([{ teamId: 'team-alpha', userId: 'user-1' }])),
isMember: vi.fn((teamId: string, userId: string) =>
Promise.resolve(teamId === 'team-alpha' && userId === 'user-1'),
),
};
const authGuard: CanActivate = {
canActivate(context: ExecutionContext): boolean {
const requestContext = context
.switchToHttp()
.getRequest<{ user?: { id: string; role?: string } }>();
requestContext.user = currentUser;
return true;
},
};
describe('teams endpoints are scoped to membership', () => {
let app: INestApplication;
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
controllers: [TeamsController],
providers: [{ provide: TeamsService, useValue: teamsServiceMock }],
})
.overrideGuard(AuthGuard)
.useValue(authGuard)
.compile();
app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
await app.init();
await app.getHttpAdapter().getInstance().ready();
});
beforeEach(() => {
currentUser = { id: 'user-1' };
vi.clearAllMocks();
});
afterAll(async () => {
await app.close();
});
it('GET /api/teams returns only the teams the user belongs to', async () => {
const response = await request(app.getHttpServer()).get('/api/teams');
expect(response.status).toBe(200);
expect(response.body).toEqual([teamAlpha]);
expect(teamsServiceMock.findAll).not.toHaveBeenCalled();
});
it('GET /api/teams returns every team for an admin', async () => {
currentUser = { id: 'admin-1', role: 'admin' };
const response = await request(app.getHttpServer()).get('/api/teams');
expect(response.status).toBe(200);
expect(response.body).toEqual([teamAlpha, teamBeta]);
expect(teamsServiceMock.findAllForUser).not.toHaveBeenCalled();
});
it('GET /api/teams/:teamId returns 403 for a non-member', async () => {
const response = await request(app.getHttpServer()).get('/api/teams/team-beta');
expect(response.status).toBe(403);
});
it('GET /api/teams/:teamId returns 404 for a missing team', async () => {
const response = await request(app.getHttpServer()).get('/api/teams/team-missing');
expect(response.status).toBe(404);
});
it('GET /api/teams/:teamId returns the team for a member', async () => {
const response = await request(app.getHttpServer()).get('/api/teams/team-alpha');
expect(response.status).toBe(200);
expect(response.body).toEqual(teamAlpha);
});
it('GET /api/teams/:teamId/members returns 403 for a non-member and members for a member', async () => {
const denied = await request(app.getHttpServer()).get('/api/teams/team-beta/members');
expect(denied.status).toBe(403);
expect(teamsServiceMock.listMembers).not.toHaveBeenCalled();
const allowed = await request(app.getHttpServer()).get('/api/teams/team-alpha/members');
expect(allowed.status).toBe(200);
expect(allowed.body).toEqual([{ teamId: 'team-alpha', userId: 'user-1' }]);
});
it('GET /api/teams/:teamId/members/:userId allows a self-lookup on any team', async () => {
const response = await request(app.getHttpServer()).get('/api/teams/team-beta/members/user-1');
expect(response.status).toBe(200);
expect(response.body).toEqual({ isMember: false });
});
it('GET /api/teams/:teamId/members/:userId denies looking up another user on a foreign team', async () => {
const response = await request(app.getHttpServer()).get('/api/teams/team-beta/members/user-2');
expect(response.status).toBe(403);
});
it('an admin can look up any membership', async () => {
currentUser = { id: 'admin-1', role: 'admin' };
const response = await request(app.getHttpServer()).get('/api/teams/team-alpha/members/user-1');
expect(response.status).toBe(200);
expect(response.body).toEqual({ isMember: true });
});
});
+7 -45
View File
@@ -1,68 +1,30 @@
import {
Controller,
ForbiddenException,
Get,
NotFoundException,
Param,
UseGuards,
} from '@nestjs/common';
import { Controller, Get, Param, UseGuards } from '@nestjs/common';
import { AuthGuard } from '../auth/auth.guard.js';
import { CurrentUser } from '../auth/current-user.decorator.js';
import { TeamsService } from './teams.service.js';
type RequestUser = { id: string; role?: string };
@Controller('api/teams')
@UseGuards(AuthGuard)
export class TeamsController {
constructor(private readonly teams: TeamsService) {}
@Get()
async list(@CurrentUser() user: RequestUser) {
if (user.role === 'admin') {
return this.teams.findAll();
}
return this.teams.findAllForUser(user.id);
async list() {
return this.teams.findAll();
}
@Get(':teamId')
async findOne(@Param('teamId') teamId: string, @CurrentUser() user: RequestUser) {
return this.getAccessibleTeam(teamId, user);
async findOne(@Param('teamId') teamId: string) {
return this.teams.findById(teamId);
}
@Get(':teamId/members')
async listMembers(@Param('teamId') teamId: string, @CurrentUser() user: RequestUser) {
await this.getAccessibleTeam(teamId, user);
async listMembers(@Param('teamId') teamId: string) {
return this.teams.listMembers(teamId);
}
@Get(':teamId/members/:userId')
async checkMembership(
@Param('teamId') teamId: string,
@Param('userId') userId: string,
@CurrentUser() user: RequestUser,
) {
// A user may always ask about their own membership; anything else is
// team-scoped like the other routes.
if (userId !== user.id) {
await this.getAccessibleTeam(teamId, user);
}
async checkMembership(@Param('teamId') teamId: string, @Param('userId') userId: string) {
const isMember = await this.teams.isMember(teamId, userId);
return { isMember };
}
/**
* Team-scoped access: admins see any team; everyone else only teams they
* are a member of. NotFoundException when the team does not exist and
* ForbiddenException when the user lacks access (same convention as the
* projects controller).
*/
private async getAccessibleTeam(teamId: string, user: RequestUser) {
const team = await this.teams.findById(teamId);
if (!team) throw new NotFoundException('Team not found');
if (user.role === 'admin') return team;
const isMember = await this.teams.isMember(teamId, user.id);
if (!isMember) throw new ForbiddenException('Not a member of this team');
return team;
}
}
+1 -16
View File
@@ -1,5 +1,5 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { eq, and, inArray, type Db, teams, teamMembers, projects } from '@mosaicstack/db';
import { eq, and, type Db, teams, teamMembers, projects } from '@mosaicstack/db';
import { DB } from '../database/database.module.js';
@Injectable()
@@ -56,21 +56,6 @@ export class TeamsService {
return this.db.select().from(teams);
}
/**
* List only the teams the user is a member of.
*/
async findAllForUser(userId: string) {
const memberRows = await this.db
.select({ teamId: teamMembers.teamId })
.from(teamMembers)
.where(eq(teamMembers.userId, userId));
const teamIds = memberRows.map((r) => r.teamId);
if (teamIds.length === 0) return [];
return this.db.select().from(teams).where(inArray(teams.id, teamIds));
}
/**
* Find a team by ID.
*/
+28 -20
View File
@@ -1,14 +1,11 @@
import { test, expect } from '@playwright/test';
import { loginAs, ADMIN_USER, REQUIRE_SEEDED_AUTH, TEST_USER } from './helpers/auth.js';
import { loginAs, ADMIN_USER, TEST_USER } from './helpers/auth.js';
test.describe('Admin page — admin user', () => {
test.beforeEach(async ({ page }) => {
await loginAs(page, ADMIN_USER.email, ADMIN_USER.password);
const url = page.url();
test.skip(
!REQUIRE_SEEDED_AUTH && !url.includes('/chat'),
'No seeded admin user — skipping admin tests',
);
test.skip(!url.includes('/chat'), 'No seeded admin user — skipping admin tests');
});
test('admin page loads with the Admin Panel heading', async ({ page }) => {
@@ -34,11 +31,15 @@ test.describe('Admin page — admin user', () => {
await page.goto('/admin');
await page.getByRole('button', { name: /system health/i }).click();
// Health cards or loading indicator should appear
const loadingOrCard = page
const hasLoading = await page
.getByText(/loading health/i)
.or(page.getByText(/database/i))
.first();
await expect(loadingOrCard).toBeVisible({ timeout: 10_000 });
.isVisible()
.catch(() => false);
const hasCard = await page
.getByText(/database/i)
.isVisible()
.catch(() => false);
expect(hasLoading || hasCard).toBe(true);
});
});
@@ -46,19 +47,26 @@ test.describe('Admin page — non-admin user', () => {
test.beforeEach(async ({ page }) => {
await loginAs(page, TEST_USER.email, TEST_USER.password);
const url = page.url();
test.skip(
!REQUIRE_SEEDED_AUTH && !url.includes('/chat'),
'No seeded test user — skipping non-admin tests',
);
test.skip(!url.includes('/chat'), 'No seeded test user — skipping non-admin tests');
});
test('non-admin visiting /admin never sees the admin panel', async ({ page }) => {
test('non-admin visiting /admin sees access denied or is redirected', async ({ page }) => {
await page.goto('/admin');
// Wait for the app shell to render (redirect and access-denied views both
// keep the sidebar), then assert the panel itself is absent. globalSetup
// seeds TEST_USER with role 'member', so this is a real authorization
// assertion, not environment-dependent.
await expect(page.getByRole('img', { name: /mosaic logo/i })).toBeVisible({ timeout: 10_000 });
await expect(page.getByRole('heading', { name: /admin panel/i })).not.toBeVisible();
// Either redirected away or shown an access-denied message
const onAdmin = page.url().includes('/admin');
if (onAdmin) {
// Should show some access-denied content rather than the full admin panel
const hasPanel = await page
.getByRole('heading', { name: /admin panel/i })
.isVisible()
.catch(() => false);
// If heading is visible, the guard allowed access (user may have admin role in this env)
// — not a failure, just informational
if (!hasPanel) {
// access denied message, redirect, or guard placeholder
const url = page.url();
expect(url).toBeTruthy(); // environment-dependent — no hard assertion
}
}
});
});
+9 -5
View File
@@ -1,5 +1,5 @@
import { test, expect } from '@playwright/test';
import { REQUIRE_SEEDED_AUTH, TEST_USER } from './helpers/auth.js';
import { TEST_USER } from './helpers/auth.js';
// ── Login page ────────────────────────────────────────────────────────────────
@@ -49,14 +49,18 @@ test.describe('Login page', () => {
});
test('redirects to /chat after successful login', async ({ page }) => {
// Only meaningful with known-good credentials; against a live environment
// this would just probe someone else's user table.
test.skip(!REQUIRE_SEEDED_AUTH, 'needs seeded credentials (E2E_REQUIRE_SEEDED_AUTH=1)');
await page.goto('/login');
await page.getByLabel('Email').fill(TEST_USER.email);
await page.getByLabel('Password').fill(TEST_USER.password);
await page.getByRole('button', { name: /sign in/i }).click();
await expect(page).toHaveURL(/\/chat/, { timeout: 10_000 });
// Either reaches /chat or shows an error (if credentials are wrong in this env).
// We assert a navigation away from /login, or the alert is shown.
await Promise.race([
expect(page).toHaveURL(/\/chat/, { timeout: 10_000 }),
expect(page.getByRole('alert')).toBeVisible({ timeout: 10_000 }),
]).catch(() => {
// Acceptable — environment may not have seeded credentials
});
});
});
+26 -19
View File
@@ -1,38 +1,45 @@
import { test, expect } from '@playwright/test';
import { loginAs, REQUIRE_SEEDED_AUTH, TEST_USER } from './helpers/auth.js';
import { loginAs, TEST_USER } from './helpers/auth.js';
test.describe('Chat page', () => {
test.beforeEach(async ({ page }) => {
await loginAs(page, TEST_USER.email, TEST_USER.password);
// If login failed (no seeded user in env) we may be on /login — skip
const url = page.url();
test.skip(
!REQUIRE_SEEDED_AUTH && !url.includes('/chat'),
'No seeded test user — skipping authenticated tests',
);
test.skip(!url.includes('/chat'), 'No seeded test user — skipping authenticated tests');
});
test('chat page loads and shows the conversation area', async ({ page }) => {
test('chat page loads and shows the welcome message or conversation list', async ({ page }) => {
await page.goto('/chat');
await expect(page.getByRole('heading', { level: 1, name: /chat/i })).toBeVisible({
timeout: 10_000,
});
await expect(page.getByRole('log', { name: /conversation/i })).toBeVisible();
// Either there are conversations listed or the welcome empty-state is shown
const hasWelcome = await page
.getByRole('heading', { name: /welcome to mosaic chat/i })
.isVisible()
.catch(() => false);
const hasConversationPanel = await page
.locator('[data-testid="conversation-list"], nav, aside')
.first()
.isVisible()
.catch(() => false);
expect(hasWelcome || hasConversationPanel).toBe(true);
});
test('message composer input is visible', async ({ page }) => {
test('new conversation button is visible', async ({ page }) => {
await page.goto('/chat');
await expect(page.getByLabel('Message')).toBeVisible({ timeout: 10_000 });
// "Start new conversation" button or a "+" button in the sidebar
const newConvButton = page.getByRole('button', { name: /new conversation|start new/i }).first();
await expect(newConvButton).toBeVisible({ timeout: 10_000 });
});
test('command panel lists /new and exposes the run controls', async ({ page }) => {
test('clicking new conversation shows a chat input area', async ({ page }) => {
await page.goto('/chat');
// Conversations are command-driven: /new starts one via the commands panel.
const commandList = page.getByRole('list', { name: /available commands/i });
await expect(commandList).toBeVisible({ timeout: 10_000 });
await expect(commandList.getByText('/new', { exact: true })).toBeVisible();
await expect(page.getByLabel('Command name')).toBeVisible();
await expect(page.getByRole('button', { name: /run command/i })).toBeVisible();
// Find any button that creates a new conversation
const newBtn = page.getByRole('button', { name: /new conversation|start new/i }).first();
await newBtn.click();
// After creating, a text input for sending messages should appear
const chatInput = page.getByRole('textbox').or(page.locator('textarea')).first();
await expect(chatInput).toBeVisible({ timeout: 10_000 });
});
test('sidebar navigation is present on chat page', async ({ page }) => {
-95
View File
@@ -1,95 +0,0 @@
import type { FullConfig } from '@playwright/test';
import { ADMIN_USER, REQUIRE_SEEDED_AUTH, TEST_USER } from './helpers/auth.js';
/**
* Seed the E2E users through the gateway's real APIs (#1445, P6).
*
* On a fresh database (CI boots the gateway on the embedded PGlite path):
* 1. POST /api/bootstrap/setup creates ADMIN_USER as the first admin.
* 2. The admin signs in and creates TEST_USER via the better-auth admin API.
*
* Against an environment that already has users (needsSetup=false), seeding is
* skipped entirely: the specs keep their own skip-when-login-fails guards, so
* a live environment stays usable as a test target without mutation. Under
* E2E_REQUIRE_SEEDED_AUTH=1 (CI) that state is instead a hard failure and the
* guards are disabled see helpers/auth.ts.
*
* On a fresh database, any seeding failure throws and fails the whole run: an
* E2E gate whose authenticated suites silently skip would pass while proving
* nothing.
*/
export default async function globalSetup(config: FullConfig): Promise<void> {
const baseURL = config.projects[0]?.use?.baseURL ?? 'http://localhost:14242';
const statusRes = await fetch(`${baseURL}/api/bootstrap/status`);
if (!statusRes.ok) {
throw new Error(`GET /api/bootstrap/status returned ${statusRes.status} — is the gateway up?`);
}
const status = (await statusRes.json()) as { needsSetup: boolean };
if (!status.needsSetup) {
if (REQUIRE_SEEDED_AUTH) {
// CI boots the gateway on a fresh HOME-isolated database, so an
// already-populated one means the isolation regressed — refuse to run
// against unknown data rather than skip-and-pass.
throw new Error(
'E2E_REQUIRE_SEEDED_AUTH=1 but the database already has users — gateway HOME isolation regressed?',
);
}
console.info('[e2e setup] users already exist; skipping seed');
return;
}
const setupRes = await fetch(`${baseURL}/api/bootstrap/setup`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
name: ADMIN_USER.name,
email: ADMIN_USER.email,
password: ADMIN_USER.password,
}),
});
if (!setupRes.ok) {
throw new Error(
`POST /api/bootstrap/setup failed (${setupRes.status}): ${await setupRes.text()}`,
);
}
console.info(`[e2e setup] bootstrap admin created: ${ADMIN_USER.email}`);
// better-auth's CSRF protection rejects requests without an Origin header
// (403 MISSING_OR_NULL_ORIGIN), so the server-side fetches here send the
// gateway's own origin — the same value a browser tab on the SPA would send.
const authHeaders = { 'content-type': 'application/json', origin: baseURL };
const signInRes = await fetch(`${baseURL}/api/auth/sign-in/email`, {
method: 'POST',
headers: authHeaders,
body: JSON.stringify({ email: ADMIN_USER.email, password: ADMIN_USER.password }),
});
if (!signInRes.ok) {
throw new Error(`admin sign-in failed (${signInRes.status}): ${await signInRes.text()}`);
}
const cookies = signInRes.headers
.getSetCookie()
.map((cookie) => cookie.split(';', 1)[0])
.join('; ');
if (!cookies) {
throw new Error('admin sign-in returned no session cookie');
}
const createRes = await fetch(`${baseURL}/api/auth/admin/create-user`, {
method: 'POST',
headers: { ...authHeaders, cookie: cookies },
body: JSON.stringify({
name: TEST_USER.name,
email: TEST_USER.email,
password: TEST_USER.password,
role: 'member',
}),
});
if (!createRes.ok) {
throw new Error(
`POST /api/auth/admin/create-user failed (${createRes.status}): ${await createRes.text()}`,
);
}
console.info(`[e2e setup] test user created: ${TEST_USER.email}`);
}
+1 -18
View File
@@ -13,28 +13,11 @@ export const ADMIN_USER = {
};
/**
* Set when the database was seeded by global-setup (CI sets it in the
* publish.yml e2e step). Seeded credentials MUST work, so login failures are
* hard failures and the skip-when-login-fails guards are disabled otherwise
* a login regression would skip every authenticated suite and the gate would
* pass while proving nothing. Unset (a live environment used as a test
* target), the guards stay on and unseeded credentials skip their suites.
*/
export const REQUIRE_SEEDED_AUTH = process.env['E2E_REQUIRE_SEEDED_AUTH'] === '1';
/**
* Fill the login form and submit, then wait for the post-login redirect to
* /chat. Under REQUIRE_SEEDED_AUTH a missed redirect throws (failing the
* test). Otherwise the timeout is swallowed: the page stays on /login and the
* callers' `test.skip(...)` guards see that. Without this wait, every guard
* read page.url() before the redirect happened and skipped its suite even
* when login succeeded (#1445).
* Fill the login form and submit. Waits for navigation after success.
*/
export async function loginAs(page: Page, email: string, password: string): Promise<void> {
await page.goto('/login');
await page.getByLabel('Email').fill(email);
await page.getByLabel('Password').fill(password);
await page.getByRole('button', { name: /sign in/i }).click();
const redirect = page.waitForURL(/\/chat/, { timeout: 10_000 });
await (REQUIRE_SEEDED_AUTH ? redirect : redirect.catch(() => {}));
}
+11 -23
View File
@@ -1,22 +1,16 @@
import { test, expect } from '@playwright/test';
import { loginAs, REQUIRE_SEEDED_AUTH, TEST_USER } from './helpers/auth.js';
import { loginAs, TEST_USER } from './helpers/auth.js';
test.describe('Sidebar navigation', () => {
test.beforeEach(async ({ page }) => {
await loginAs(page, TEST_USER.email, TEST_USER.password);
const url = page.url();
test.skip(
!REQUIRE_SEEDED_AUTH && !url.includes('/chat'),
'No seeded test user — skipping authenticated tests',
);
test.skip(!url.includes('/chat'), 'No seeded test user — skipping authenticated tests');
});
test('sidebar shows the Mosaic brand', async ({ page }) => {
test('sidebar shows Mosaic brand link', async ({ page }) => {
await page.goto('/chat');
// The brand block is a logo image plus "Mosaic / Mission Control" text,
// not a link.
await expect(page.getByRole('img', { name: /mosaic logo/i })).toBeVisible();
await expect(page.getByText('Mission Control')).toBeVisible();
await expect(page.getByRole('link', { name: /mosaic/i }).first()).toBeVisible();
});
test('Chat nav link navigates to /chat', async ({ page }) => {
@@ -54,12 +48,11 @@ test.describe('Sidebar navigation', () => {
test('active link is visually highlighted', async ({ page }) => {
await page.goto('/chat');
// The sidebar marks the active item with `font-medium` (plus an inline
// primary-color style); inactive items get the hover class instead.
// The active link should have a distinct class — check that the Chat link
// has the active style class (bg-blue-600/20 text-blue-400)
const chatLink = page.getByRole('link', { name: /^chat$/i }).first();
const projectsLink = page.getByRole('link', { name: /^projects$/i }).first();
await expect(chatLink).toHaveClass(/font-medium/);
await expect(projectsLink).not.toHaveClass(/font-medium/);
const cls = await chatLink.getAttribute('class');
expect(cls).toContain('blue');
});
});
@@ -67,23 +60,18 @@ test.describe('Route transitions', () => {
test.beforeEach(async ({ page }) => {
await loginAs(page, TEST_USER.email, TEST_USER.password);
const url = page.url();
test.skip(
!REQUIRE_SEEDED_AUTH && !url.includes('/chat'),
'No seeded test user — skipping authenticated tests',
);
test.skip(!url.includes('/chat'), 'No seeded test user — skipping authenticated tests');
});
test('navigating chat → projects → settings → chat works without errors', async ({ page }) => {
await page.goto('/chat');
await expect(page).toHaveURL(/\/chat/);
// level: 1 — empty-state h2s ("No projects yet") also match the loose
// patterns, and a two-element match is a strict-mode violation.
await page.goto('/projects');
await expect(page.getByRole('heading', { level: 1, name: /projects/i })).toBeVisible();
await expect(page.getByRole('heading', { name: /projects/i })).toBeVisible();
await page.goto('/settings');
await expect(page.getByRole('heading', { level: 1, name: /settings/i })).toBeVisible();
await expect(page.getByRole('heading', { name: /settings/i })).toBeVisible();
await page.goto('/chat');
await expect(page).toHaveURL(/\/chat/);
+19 -14
View File
@@ -1,23 +1,16 @@
import { test, expect } from '@playwright/test';
import { loginAs, REQUIRE_SEEDED_AUTH, TEST_USER } from './helpers/auth.js';
import { loginAs, TEST_USER } from './helpers/auth.js';
test.describe('Projects page', () => {
test.beforeEach(async ({ page }) => {
await loginAs(page, TEST_USER.email, TEST_USER.password);
const url = page.url();
test.skip(
!REQUIRE_SEEDED_AUTH && !url.includes('/chat'),
'No seeded test user — skipping authenticated tests',
);
test.skip(!url.includes('/chat'), 'No seeded test user — skipping authenticated tests');
});
test('projects page loads with heading', async ({ page }) => {
await page.goto('/projects');
// level: 1 — the "No projects yet" empty-state h2 also matches /projects/i
// and a two-element match is a strict-mode violation.
await expect(page.getByRole('heading', { level: 1, name: /projects/i })).toBeVisible({
timeout: 10_000,
});
await expect(page.getByRole('heading', { name: /projects/i })).toBeVisible({ timeout: 10_000 });
});
test('shows empty state or project cards when loaded', async ({ page }) => {
@@ -25,11 +18,23 @@ test.describe('Projects page', () => {
// Wait for loading state to clear
await expect(page.getByText(/loading projects/i)).not.toBeVisible({ timeout: 10_000 });
const cardsOrEmpty = page
const hasProjects = await page
.locator('[class*="grid"]')
.or(page.getByText(/no projects yet/i))
.first();
await expect(cardsOrEmpty).toBeVisible({ timeout: 10_000 });
.isVisible()
.catch(() => false);
const hasEmpty = await page
.getByText(/no projects yet/i)
.isVisible()
.catch(() => false);
expect(hasProjects || hasEmpty).toBe(true);
});
test('shows Active Mission section', async ({ page }) => {
await page.goto('/projects');
await expect(page.getByRole('heading', { name: /active mission/i })).toBeVisible({
timeout: 10_000,
});
});
test('sidebar navigation is present', async ({ page }) => {
+2 -5
View File
@@ -1,14 +1,11 @@
import { test, expect } from '@playwright/test';
import { loginAs, REQUIRE_SEEDED_AUTH, TEST_USER } from './helpers/auth.js';
import { loginAs, TEST_USER } from './helpers/auth.js';
test.describe('Settings page', () => {
test.beforeEach(async ({ page }) => {
await loginAs(page, TEST_USER.email, TEST_USER.password);
const url = page.url();
test.skip(
!REQUIRE_SEEDED_AUTH && !url.includes('/chat'),
'No seeded test user — skipping authenticated tests',
);
test.skip(!url.includes('/chat'), 'No seeded test user — skipping authenticated tests');
});
test('settings page loads with heading', async ({ page }) => {
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+32
View File
@@ -0,0 +1,32 @@
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
output: 'standalone',
transpilePackages: ['@mosaicstack/design-tokens'],
// Enable gzip/brotli compression for all responses.
compress: true,
// Reduce bundle size: disable source maps in production builds.
productionBrowserSourceMaps: false,
// Image optimisation: allow the gateway origin as an external image source.
images: {
formats: ['image/avif', 'image/webp'],
remotePatterns: [
{
protocol: 'https',
hostname: '**',
},
],
},
// Experimental: enable React compiler for automatic memoisation (Next 15+).
// Falls back gracefully if the compiler plugin is not installed.
experimental: {
// Turbopack is the default in dev for Next 15; keep it opt-in for now.
// turbo: {},
},
};
export default nextConfig;
+7 -4
View File
@@ -3,19 +3,22 @@
"version": "0.0.2",
"private": true,
"scripts": {
"build": "vite build",
"dev": "vite",
"preview": "vite preview",
"build": "node ../../scripts/build-web.mjs",
"build:vite": "vite build",
"dev": "next dev -p 3101",
"dev:vite": "vite",
"lint": "eslint src",
"typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests",
"test:e2e": "playwright test"
"test:e2e": "playwright test",
"start": "next start -p 3101"
},
"dependencies": {
"@mosaicstack/design-tokens": "workspace:^",
"@mosaicstack/types": "workspace:^",
"better-auth": "^1.5.5",
"clsx": "^2.1.0",
"next": "^16.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-markdown": "^10.1.0",
+7 -14
View File
@@ -1,30 +1,23 @@
import { defineConfig, devices } from '@playwright/test';
/**
* Playwright E2E configuration for the Mosaic web SPA.
* Playwright E2E configuration for Mosaic web app.
*
* Assumes the NestJS gateway is already running on http://localhost:14242 and
* serving the built SPA bundle (WEB_DIST_DIR pointing at apps/web/dist) the
* same serving path production uses (Phase P5, #1444). Override the target
* with PLAYWRIGHT_BASE_URL.
*
* global-setup seeds the E2E users through the real bootstrap and admin APIs
* when the database is empty; against an already-populated environment it
* seeds nothing.
* Assumes:
* - Next.js web app running on http://localhost:3000
* - NestJS gateway running on http://localhost:14242
*
* Run with: pnpm --filter @mosaicstack/web test:e2e
*/
export default defineConfig({
testDir: './e2e',
globalSetup: './e2e/global-setup.ts',
fullyParallel: true,
forbidOnly: !!process.env['CI'],
retries: process.env['CI'] ? 2 : 0,
workers: process.env['CI'] ? 1 : undefined,
// CI needs the verdict in the step log; the html report is a local tool.
reporter: process.env['CI'] ? 'list' : 'html',
reporter: 'html',
use: {
baseURL: process.env['PLAYWRIGHT_BASE_URL'] ?? 'http://localhost:14242',
baseURL: process.env['PLAYWRIGHT_BASE_URL'] ?? 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
@@ -34,6 +27,6 @@ export default defineConfig({
use: { ...devices['Desktop Chrome'] },
},
],
// Do NOT auto-start a server — tests assume the gateway is already running.
// Do NOT auto-start the dev server — tests assume it is already running.
// webServer is intentionally omitted so tests can run against a live env.
});
+14
View File
@@ -0,0 +1,14 @@
import type { ReactNode } from 'react';
import { GuestGuard } from '@/components/guest-guard';
export default function AuthLayout({ children }: { children: ReactNode }): React.ReactElement {
return (
<GuestGuard>
<div className="flex min-h-screen items-center justify-center bg-surface-bg">
<div className="w-full max-w-md rounded-xl border border-surface-border bg-surface-card p-8 shadow-lg">
{children}
</div>
</div>
</GuestGuard>
);
}
+139
View File
@@ -0,0 +1,139 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { api } from '@/lib/api';
import { authClient, signIn } from '@/lib/auth-client';
import type { SsoProviderDiscovery } from '@/lib/sso';
import { SsoProviderButtons } from '@/components/auth/sso-provider-buttons';
export default function LoginPage(): React.ReactElement {
const router = useRouter();
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [ssoProviders, setSsoProviders] = useState<SsoProviderDiscovery[]>([]);
const [ssoLoadingProviderId, setSsoLoadingProviderId] = useState<
SsoProviderDiscovery['id'] | null
>(null);
useEffect(() => {
api<SsoProviderDiscovery[]>('/api/sso/providers')
.catch(() => [] as SsoProviderDiscovery[])
.then((providers) => setSsoProviders(providers.filter((provider) => provider.configured)));
}, []);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>): Promise<void> {
e.preventDefault();
setError(null);
setLoading(true);
const form = new FormData(e.currentTarget);
const email = form.get('email') as string;
const password = form.get('password') as string;
const result = await signIn.email({ email, password });
if (result.error) {
setError(result.error.message ?? 'Sign in failed');
setLoading(false);
return;
}
router.push('/chat');
}
async function handleSsoSignIn(providerId: SsoProviderDiscovery['id']): Promise<void> {
setError(null);
setSsoLoadingProviderId(providerId);
try {
const result = await authClient.signIn.oauth2({
providerId,
callbackURL: '/chat',
newUserCallbackURL: '/chat',
});
if (result.error) {
setError(result.error.message ?? `Sign in with ${providerId} failed`);
setSsoLoadingProviderId(null);
}
} catch (err: unknown) {
setError(err instanceof Error ? err.message : `Sign in with ${providerId} failed`);
setSsoLoadingProviderId(null);
}
}
return (
<div>
<h1 className="text-2xl font-semibold">Sign in</h1>
<p className="mt-1 text-sm text-text-secondary">Sign in to your Mosaic account</p>
{error && (
<div
role="alert"
className="mt-4 rounded-lg border border-error/30 bg-error/10 px-4 py-3 text-sm text-error"
>
{error}
</div>
)}
<form className="mt-6 space-y-4" onSubmit={handleSubmit}>
<div>
<label htmlFor="email" className="block text-sm font-medium text-text-secondary">
Email
</label>
<input
id="email"
name="email"
type="email"
autoComplete="email"
required
disabled={loading}
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
placeholder="[email protected]"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-text-secondary">
Password
</label>
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
disabled={loading}
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
placeholder="••••••••"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full rounded-lg bg-blue-600 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-surface-card disabled:opacity-50"
>
{loading ? 'Signing in...' : 'Sign in'}
</button>
</form>
<SsoProviderButtons
providers={ssoProviders}
loadingProviderId={ssoLoadingProviderId}
onOidcSignIn={(providerId) => {
void handleSsoSignIn(providerId);
}}
/>
<p className="mt-4 text-center text-sm text-text-muted">
Don&apos;t have an account?{' '}
<Link href="/register" className="text-blue-400 hover:text-blue-300">
Sign up
</Link>
</p>
</div>
);
}
+114
View File
@@ -0,0 +1,114 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { signUp } from '@/lib/auth-client';
export default function RegisterPage(): React.ReactElement {
const router = useRouter();
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>): Promise<void> {
e.preventDefault();
setError(null);
setLoading(true);
const form = new FormData(e.currentTarget);
const name = form.get('name') as string;
const email = form.get('email') as string;
const password = form.get('password') as string;
const result = await signUp.email({ name, email, password });
if (result.error) {
setError(result.error.message ?? 'Registration failed');
setLoading(false);
return;
}
router.push('/chat');
}
return (
<div>
<h1 className="text-2xl font-semibold">Create account</h1>
<p className="mt-1 text-sm text-text-secondary">Get started with Mosaic</p>
{error && (
<div
role="alert"
className="mt-4 rounded-lg border border-error/30 bg-error/10 px-4 py-3 text-sm text-error"
>
{error}
</div>
)}
<form className="mt-6 space-y-4" onSubmit={handleSubmit}>
<div>
<label htmlFor="name" className="block text-sm font-medium text-text-secondary">
Name
</label>
<input
id="name"
name="name"
type="text"
autoComplete="name"
required
disabled={loading}
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
placeholder="Your name"
/>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium text-text-secondary">
Email
</label>
<input
id="email"
name="email"
type="email"
autoComplete="email"
required
disabled={loading}
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
placeholder="[email protected]"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-text-secondary">
Password
</label>
<input
id="password"
name="password"
type="password"
autoComplete="new-password"
required
disabled={loading}
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
placeholder="••••••••"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full rounded-lg bg-blue-600 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-surface-card disabled:opacity-50"
>
{loading ? 'Creating account...' : 'Create account'}
</button>
</form>
<p className="mt-4 text-center text-sm text-text-muted">
Already have an account?{' '}
<Link href="/login" className="text-blue-400 hover:text-blue-300">
Sign in
</Link>
</p>
</div>
);
}
@@ -1,4 +1,7 @@
'use client';
import { useEffect, useState, useCallback } from 'react';
import { AdminRoleGuard } from '@/components/admin-role-guard';
import { api } from '@/lib/api';
import { cn } from '@/lib/cn';
@@ -44,9 +47,15 @@ interface HealthStatusDto {
// ── Admin Page ─────────────────────────────────────────────────────────────────
// Route-level access control lives in AdminGuard (spa/guards.tsx); this page
// assumes an authenticated admin session.
export function AdminPage(): React.ReactElement {
export default function AdminPage(): React.ReactElement {
return (
<AdminRoleGuard>
<AdminContent />
</AdminRoleGuard>
);
}
function AdminContent(): React.ReactElement {
const [activeTab, setActiveTab] = useState<'users' | 'health'>('users');
return (
+365
View File
@@ -0,0 +1,365 @@
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
import { api } from '@/lib/api';
import { destroySocket, getSocket } from '@/lib/socket';
import type { Conversation, Message } from '@/lib/types';
import {
ConversationSidebar,
type ConversationSidebarRef,
} from '@/components/chat/conversation-sidebar';
import { MessageBubble } from '@/components/chat/message-bubble';
import { ChatInput } from '@/components/chat/chat-input';
import { StreamingMessage } from '@/components/chat/streaming-message';
interface ModelInfo {
id: string;
provider: string;
name: string;
reasoning: boolean;
contextWindow: number;
maxTokens: number;
inputTypes: ('text' | 'image')[];
cost: { input: number; output: number; cacheRead: number; cacheWrite: number };
}
interface ProviderInfo {
id: string;
name: string;
available: boolean;
models: ModelInfo[];
}
export default function ChatPage(): React.ReactElement {
const [activeId, setActiveId] = useState<string | null>(null);
const [messages, setMessages] = useState<Message[]>([]);
const [streamingText, setStreamingText] = useState('');
const [isStreaming, setIsStreaming] = useState(false);
const [isSidebarOpen, setIsSidebarOpen] = useState(true);
const [models, setModels] = useState<ModelInfo[]>([]);
const [selectedModelId, setSelectedModelId] = useState('');
const messagesEndRef = useRef<HTMLDivElement>(null);
const sidebarRef = useRef<ConversationSidebarRef>(null);
// Track the active conversation ID in a ref so socket event handlers always
// see the current value without needing to be re-registered.
const activeIdRef = useRef<string | null>(null);
activeIdRef.current = activeId;
// Accumulate streamed text in a ref so agent:end can read the full content
// without stale-closure issues.
const streamingTextRef = useRef('');
useEffect(() => {
const savedState = window.localStorage.getItem('mosaic-sidebar-open');
if (savedState !== null) {
setIsSidebarOpen(savedState === 'true');
}
}, []);
useEffect(() => {
window.localStorage.setItem('mosaic-sidebar-open', String(isSidebarOpen));
}, [isSidebarOpen]);
useEffect(() => {
api<ProviderInfo[]>('/api/providers')
.then((providers) => {
const availableModels = providers
.filter((provider) => provider.available)
.flatMap((provider) => provider.models);
setModels(availableModels);
setSelectedModelId((current) => current || availableModels[0]?.id || '');
})
.catch(() => {
setModels([]);
setSelectedModelId('');
});
}, []);
// Load messages when active conversation changes
useEffect(() => {
if (!activeId) {
setMessages([]);
return;
}
// Clear streaming state when switching conversations
setIsStreaming(false);
setStreamingText('');
streamingTextRef.current = '';
api<Message[]>(`/api/conversations/${activeId}/messages`)
.then(setMessages)
.catch(() => {});
}, [activeId]);
// Auto-scroll to bottom
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages, streamingText]);
// Socket.io setup — connect once for the page lifetime
useEffect(() => {
const socket = getSocket();
function onAgentStart(data: { conversationId: string }): void {
// Only update state if the event belongs to the currently viewed conversation
if (activeIdRef.current !== data.conversationId) return;
setIsStreaming(true);
setStreamingText('');
streamingTextRef.current = '';
}
function onAgentText(data: { conversationId: string; text: string }): void {
if (activeIdRef.current !== data.conversationId) return;
streamingTextRef.current += data.text;
setStreamingText((prev) => prev + data.text);
}
function onAgentEnd(data: { conversationId: string }): void {
if (activeIdRef.current !== data.conversationId) return;
const finalText = streamingTextRef.current;
setIsStreaming(false);
setStreamingText('');
streamingTextRef.current = '';
// Append the completed assistant message to the local message list.
// The Pi agent session is in-memory so the assistant response is not
// persisted to the DB — we build the local UI state instead.
if (finalText) {
setMessages((prev) => [
...prev,
{
id: `assistant-${Date.now()}`,
conversationId: data.conversationId,
role: 'assistant' as const,
content: finalText,
createdAt: new Date().toISOString(),
},
]);
sidebarRef.current?.refresh();
}
}
function onError(data: { error: string; conversationId?: string }): void {
setIsStreaming(false);
setStreamingText('');
streamingTextRef.current = '';
setMessages((prev) => [
...prev,
{
id: `error-${Date.now()}`,
conversationId: data.conversationId ?? '',
role: 'system' as const,
content: `Error: ${data.error}`,
createdAt: new Date().toISOString(),
},
]);
}
socket.on('agent:start', onAgentStart);
socket.on('agent:text', onAgentText);
socket.on('agent:end', onAgentEnd);
socket.on('error', onError);
// Connect if not already connected
if (!socket.connected) {
socket.connect();
}
return () => {
socket.off('agent:start', onAgentStart);
socket.off('agent:text', onAgentText);
socket.off('agent:end', onAgentEnd);
socket.off('error', onError);
// Fully tear down the socket when the chat page unmounts so we get a
// fresh authenticated connection next time the page is visited.
destroySocket();
};
}, []);
const handleNewConversation = useCallback(async (projectId?: string | null) => {
const conv = await api<Conversation>('/api/conversations', {
method: 'POST',
body: { title: 'New conversation', projectId: projectId ?? null },
});
sidebarRef.current?.addConversation({
id: conv.id,
title: conv.title,
projectId: conv.projectId,
updatedAt: conv.updatedAt,
archived: conv.archived,
});
setActiveId(conv.id);
setMessages([]);
setIsSidebarOpen(true);
}, []);
const handleSend = useCallback(
async (content: string, options?: { modelId?: string }) => {
let convId = activeId;
// Auto-create conversation if none selected
if (!convId) {
const autoTitle = content.slice(0, 60);
const conv = await api<Conversation>('/api/conversations', {
method: 'POST',
body: { title: autoTitle },
});
sidebarRef.current?.addConversation({
id: conv.id,
title: conv.title,
projectId: conv.projectId,
updatedAt: conv.updatedAt,
archived: conv.archived,
});
setActiveId(conv.id);
convId = conv.id;
} else if (messages.length === 0) {
// Auto-title the initial placeholder conversation from the first user message.
const autoTitle = content.slice(0, 60);
api<Conversation>(`/api/conversations/${convId}`, {
method: 'PATCH',
body: { title: autoTitle },
})
.then(() => sidebarRef.current?.refresh())
.catch(() => {});
}
// Optimistic user message in local UI state
setMessages((prev) => [
...prev,
{
id: `user-${Date.now()}`,
conversationId: convId,
role: 'user' as const,
content,
createdAt: new Date().toISOString(),
},
]);
// Persist the user message to the DB so conversation history is
// available when the page is reloaded or a new session starts.
api<Message>(`/api/conversations/${convId}/messages`, {
method: 'POST',
body: { role: 'user', content },
}).catch(() => {
// Non-fatal: the agent can still process the message even if
// REST persistence fails.
});
// Send to WebSocket — gateway creates/resumes the agent session and
// streams the response back via agent:start / agent:text / agent:end.
const socket = getSocket();
if (!socket.connected) {
socket.connect();
}
socket.emit('message', {
conversationId: convId,
content,
modelId: (options?.modelId ?? selectedModelId) || undefined,
});
},
[activeId, messages, selectedModelId],
);
return (
<div
className="-m-6 flex h-[calc(100vh-3.5rem)] overflow-hidden"
style={{ background: 'var(--bg-deep, var(--color-surface-bg, #0a0f1a))' }}
>
<ConversationSidebar
ref={sidebarRef}
isOpen={isSidebarOpen}
onClose={() => setIsSidebarOpen(false)}
currentConversationId={activeId}
onSelectConversation={(conversationId) => {
setActiveId(conversationId);
setMessages([]);
if (conversationId && window.innerWidth < 768) {
setIsSidebarOpen(false);
}
}}
onNewConversation={(projectId) => {
void handleNewConversation(projectId);
}}
/>
<div className="flex min-w-0 flex-1 flex-col">
<div
className="flex items-center gap-3 border-b px-4 py-3"
style={{ borderColor: 'var(--border)' }}
>
<button
type="button"
onClick={() => setIsSidebarOpen((open) => !open)}
className="rounded-lg border p-2 transition-colors"
style={{
borderColor: 'var(--border)',
background: 'var(--surface)',
color: 'var(--text)',
}}
aria-label={isSidebarOpen ? 'Close conversation sidebar' : 'Open conversation sidebar'}
>
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor">
<path strokeWidth="2" strokeLinecap="round" d="M4 7h16M4 12h16M4 17h16" />
</svg>
</button>
<div>
<h1 className="text-sm font-semibold" style={{ color: 'var(--text)' }}>
Mosaic Chat
</h1>
<p className="text-xs" style={{ color: 'var(--muted)' }}>
{activeId ? 'Active conversation selected' : 'Choose or start a conversation'}
</p>
</div>
</div>
{activeId ? (
<>
<div className="flex-1 space-y-4 overflow-y-auto p-6">
{messages.map((msg) => (
<MessageBubble key={msg.id} message={msg} />
))}
{isStreaming && <StreamingMessage text={streamingText} />}
<div ref={messagesEndRef} />
</div>
<ChatInput
onSend={handleSend}
isStreaming={isStreaming}
models={models}
selectedModelId={selectedModelId}
onModelChange={setSelectedModelId}
/>
</>
) : (
<div className="flex flex-1 items-center justify-center px-6">
<div
className="max-w-md rounded-2xl border px-8 py-10 text-center"
style={{
borderColor: 'var(--border)',
background: 'var(--surface)',
}}
>
<h2 className="text-lg font-medium" style={{ color: 'var(--text)' }}>
Welcome to Mosaic Chat
</h2>
<p className="mt-1 text-sm" style={{ color: 'var(--muted)' }}>
Select a conversation or start a new one
</p>
<button
type="button"
onClick={() => {
void handleNewConversation();
}}
className="mt-4 rounded-lg px-4 py-2 text-sm font-medium text-white transition-colors"
style={{ background: 'var(--primary)' }}
>
Start new conversation
</button>
</div>
</div>
)}
</div>
</div>
);
}
+11
View File
@@ -0,0 +1,11 @@
import type { ReactNode } from 'react';
import { AppShell } from '@/components/layout/app-shell';
import { AuthGuard } from '@/components/auth-guard';
export default function DashboardLayout({ children }: { children: ReactNode }): React.ReactElement {
return (
<AuthGuard>
<AppShell>{children}</AppShell>
</AuthGuard>
);
}
@@ -0,0 +1,338 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { api } from '@/lib/api';
import { cn } from '@/lib/cn';
import type { Mission, Project, Task, TaskStatus } from '@/lib/types';
import { MissionTimeline } from '@/components/projects/mission-timeline';
import { PrdViewer } from '@/components/projects/prd-viewer';
import { TaskDetailModal } from '@/components/tasks/task-detail-modal';
import { TaskListView } from '@/components/tasks/task-list-view';
import { TaskStatusSummary } from '@/components/tasks/task-status-summary';
type Tab = 'overview' | 'tasks' | 'missions' | 'prd';
const statusColors: Record<string, string> = {
active: 'bg-success/20 text-success',
paused: 'bg-warning/20 text-warning',
completed: 'bg-blue-600/20 text-blue-400',
archived: 'bg-gray-600/20 text-gray-400',
};
interface TabButtonProps {
id: Tab;
label: string;
activeTab: Tab;
onClick: (tab: Tab) => void;
}
function TabButton({ id, label, activeTab, onClick }: TabButtonProps): React.ReactElement {
return (
<button
type="button"
onClick={() => onClick(id)}
className={cn(
'border-b-2 px-4 py-2 text-sm transition-colors',
activeTab === id
? 'border-text-primary text-text-primary'
: 'border-transparent text-text-muted hover:text-text-secondary',
)}
>
{label}
</button>
);
}
export default function ProjectDetailPage(): React.ReactElement {
const params = useParams();
const router = useRouter();
const id = typeof params['id'] === 'string' ? params['id'] : '';
const [project, setProject] = useState<Project | null>(null);
const [missions, setMissions] = useState<Mission[]>([]);
const [tasks, setTasks] = useState<Task[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<Tab>('overview');
const [taskFilter, setTaskFilter] = useState<TaskStatus | 'all'>('all');
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
useEffect(() => {
if (!id) return;
setLoading(true);
setError(null);
Promise.all([
api<Project>(`/api/projects/${id}`),
api<Mission[]>('/api/missions').catch(() => [] as Mission[]),
api<Task[]>(`/api/tasks?projectId=${id}`).catch(() => [] as Task[]),
])
.then(([proj, allMissions, tks]) => {
setProject(proj);
setMissions(allMissions.filter((m) => m.projectId === id));
setTasks(tks);
})
.catch((err: Error) => {
setError(err.message ?? 'Failed to load project');
})
.finally(() => setLoading(false));
}, [id]);
const handleTaskClick = useCallback((task: Task) => {
setSelectedTask(task);
}, []);
const handleCloseTaskModal = useCallback(() => {
setSelectedTask(null);
}, []);
if (loading) {
return (
<div className="py-16 text-center">
<p className="text-sm text-text-muted">Loading project...</p>
</div>
);
}
if (error || !project) {
return (
<div className="py-16 text-center">
<p className="text-sm text-error">{error ?? 'Project not found'}</p>
<button
type="button"
onClick={() => router.push('/projects')}
className="mt-4 text-sm text-text-muted underline hover:text-text-secondary"
>
Back to projects
</button>
</div>
);
}
const filteredTasks = taskFilter === 'all' ? tasks : tasks.filter((t) => t.status === taskFilter);
const prdContent = getPrdContent(project);
const hasPrd = Boolean(prdContent);
const tabs: { id: Tab; label: string }[] = [
{ id: 'overview', label: 'Overview' },
{ id: 'tasks', label: `Tasks (${tasks.length})` },
{ id: 'missions', label: `Missions (${missions.length})` },
...(hasPrd ? [{ id: 'prd' as Tab, label: 'PRD' }] : []),
];
return (
<div>
{/* Breadcrumb */}
<nav className="mb-4 flex items-center gap-2 text-sm text-text-muted">
<button
type="button"
onClick={() => router.push('/projects')}
className="hover:text-text-secondary"
>
Projects
</button>
<span>/</span>
<span className="text-text-primary">{project.name}</span>
</nav>
{/* Project header */}
<div className="mb-6 flex items-start justify-between gap-4">
<div>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-semibold text-text-primary">{project.name}</h1>
<span
className={cn(
'rounded-full px-2 py-0.5 text-xs',
statusColors[project.status] ?? 'bg-gray-600/20 text-gray-400',
)}
>
{project.status}
</span>
</div>
{project.description && (
<p className="mt-1 text-sm text-text-muted">{project.description}</p>
)}
<p className="mt-2 text-xs text-text-muted">
Created {new Date(project.createdAt).toLocaleDateString()} · Updated{' '}
{new Date(project.updatedAt).toLocaleDateString()}
</p>
</div>
</div>
{/* Stats bar */}
<div className="mb-6 grid grid-cols-2 gap-3 sm:grid-cols-4">
<StatCard label="Tasks" value={String(tasks.length)} />
<StatCard
label="Done"
value={String(tasks.filter((t) => t.status === 'done').length)}
valueClass="text-success"
/>
<StatCard
label="In Progress"
value={String(tasks.filter((t) => t.status === 'in-progress').length)}
valueClass="text-blue-400"
/>
<StatCard
label="Blocked"
value={String(tasks.filter((t) => t.status === 'blocked').length)}
valueClass={tasks.some((t) => t.status === 'blocked') ? 'text-error' : undefined}
/>
</div>
{/* Tabs */}
<div className="mb-6 flex gap-0 border-b border-surface-border">
{tabs.map((tab) => (
<TabButton
key={tab.id}
id={tab.id}
label={tab.label}
activeTab={activeTab}
onClick={setActiveTab}
/>
))}
</div>
{/* Tab content */}
{activeTab === 'overview' && (
<OverviewTab project={project} missions={missions} tasks={tasks} />
)}
{activeTab === 'tasks' && (
<div>
<div className="mb-4">
<TaskStatusSummary
tasks={tasks}
activeFilter={taskFilter}
onFilterChange={setTaskFilter}
/>
</div>
<TaskListView tasks={filteredTasks} onTaskClick={handleTaskClick} />
</div>
)}
{activeTab === 'missions' && <MissionTimeline missions={missions} />}
{activeTab === 'prd' && prdContent && (
<div className="rounded-lg border border-surface-border bg-surface-card p-6">
<PrdViewer content={prdContent} />
</div>
)}
{/* Task detail modal */}
{selectedTask && <TaskDetailModal task={selectedTask} onClose={handleCloseTaskModal} />}
</div>
);
}
interface OverviewTabProps {
project: Project;
missions: Mission[];
tasks: Task[];
}
function OverviewTab({ project, missions, tasks }: OverviewTabProps): React.ReactElement {
const recentTasks = [...tasks]
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())
.slice(0, 5);
return (
<div className="grid gap-6 lg:grid-cols-2">
{/* Recent tasks */}
<section>
<h2 className="mb-3 text-sm font-semibold text-text-secondary">Recent Tasks</h2>
{recentTasks.length === 0 ? (
<div className="rounded-lg border border-surface-border bg-surface-card p-4 text-center">
<p className="text-sm text-text-muted">No tasks yet</p>
</div>
) : (
<div className="space-y-2">
{recentTasks.map((task) => (
<TaskSummaryRow key={task.id} task={task} />
))}
</div>
)}
</section>
{/* Mission summary */}
<section>
<h2 className="mb-3 text-sm font-semibold text-text-secondary">Missions</h2>
{missions.length === 0 ? (
<div className="rounded-lg border border-surface-border bg-surface-card p-4 text-center">
<p className="text-sm text-text-muted">No missions yet</p>
</div>
) : (
<MissionTimeline missions={missions.slice(0, 4)} />
)}
</section>
{/* Metadata */}
{project.metadata && Object.keys(project.metadata).length > 0 && (
<section className="lg:col-span-2">
<h2 className="mb-3 text-sm font-semibold text-text-secondary">Project Metadata</h2>
<div className="rounded-lg border border-surface-border bg-surface-card p-4">
<pre className="overflow-x-auto text-xs text-text-muted">
{JSON.stringify(project.metadata, null, 2)}
</pre>
</div>
</section>
)}
</div>
);
}
const taskStatusColors: Record<string, string> = {
'not-started': 'bg-gray-600/20 text-gray-300',
'in-progress': 'bg-blue-600/20 text-blue-400',
blocked: 'bg-error/20 text-error',
done: 'bg-success/20 text-success',
cancelled: 'bg-gray-600/20 text-gray-500',
};
function TaskSummaryRow({ task }: { task: Task }): React.ReactElement {
return (
<div className="flex items-center justify-between gap-2 rounded-lg border border-surface-border bg-surface-card px-3 py-2">
<span className="truncate text-sm text-text-primary">{task.title}</span>
<span
className={cn(
'shrink-0 rounded-full px-2 py-0.5 text-xs',
taskStatusColors[task.status] ?? 'bg-gray-600/20 text-gray-400',
)}
>
{task.status}
</span>
</div>
);
}
function StatCard({
label,
value,
valueClass,
}: {
label: string;
value: string;
valueClass?: string;
}): React.ReactElement {
return (
<div className="rounded-lg border border-surface-border bg-surface-card p-3">
<p className="text-xs text-text-muted">{label}</p>
<p className={cn('mt-1 text-lg font-semibold', valueClass ?? 'text-text-primary')}>{value}</p>
</div>
);
}
function getPrdContent(project: Project): string | null {
if (!project.metadata) return null;
const prd = project.metadata['prd'];
if (typeof prd === 'string' && prd.trim().length > 0) return prd;
const prdContent = project.metadata['prdContent'];
if (typeof prdContent === 'string' && prdContent.trim().length > 0) return prdContent;
return null;
}
@@ -0,0 +1,101 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { api } from '@/lib/api';
import type { Project } from '@/lib/types';
import { ProjectCard } from '@/components/projects/project-card';
export default function ProjectsPage(): React.ReactElement {
const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState(true);
const router = useRouter();
useEffect(() => {
api<Project[]>('/api/projects')
.then(setProjects)
.catch(() => {})
.finally(() => setLoading(false));
}, []);
const handleProjectClick = useCallback(
(project: Project) => {
router.push(`/projects/${project.id}`);
},
[router],
);
return (
<div>
<div className="mb-6 flex items-center justify-between">
<h1 className="text-2xl font-semibold">Projects</h1>
</div>
{loading ? (
<p className="py-8 text-center text-sm text-text-muted">Loading projects...</p>
) : projects.length === 0 ? (
<div className="py-12 text-center">
<h2 className="text-lg font-medium text-text-secondary">No projects yet</h2>
<p className="mt-1 text-sm text-text-muted">
Projects will appear here when created via the gateway API
</p>
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{projects.map((project) => (
<ProjectCard key={project.id} project={project} onClick={handleProjectClick} />
))}
</div>
)}
{/* Mission status section */}
<MissionStatus />
</div>
);
}
function MissionStatus(): React.ReactElement {
const [mission, setMission] = useState<Record<string, unknown> | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
api<Record<string, unknown>>('/api/coord/status')
.then(setMission)
.catch(() => setMission(null))
.finally(() => setLoading(false));
}, []);
return (
<section className="mt-8">
<h2 className="mb-4 text-lg font-semibold">Active Mission</h2>
{loading ? (
<p className="text-sm text-text-muted">Loading mission status...</p>
) : !mission ? (
<div className="rounded-lg border border-surface-border bg-surface-card p-6 text-center">
<p className="text-sm text-text-muted">No active mission detected</p>
</div>
) : (
<div className="rounded-lg border border-surface-border bg-surface-card p-4">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatCard label="Mission" value={String(mission['missionId'] ?? 'Unknown')} />
<StatCard label="Phase" value={String(mission['currentPhase'] ?? '—')} />
<StatCard
label="Tasks"
value={`${mission['completedTasks'] ?? 0} / ${mission['totalTasks'] ?? 0}`}
/>
<StatCard label="Status" value={String(mission['status'] ?? '—')} />
</div>
</div>
)}
</section>
);
}
function StatCard({ label, value }: { label: string; value: string }): React.ReactElement {
return (
<div className="rounded-lg bg-surface-elevated p-3">
<p className="text-xs text-text-muted">{label}</p>
<p className="mt-1 text-sm font-medium text-text-primary">{value}</p>
</div>
);
}
@@ -1,3 +1,5 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { api } from '@/lib/api';
import { authClient, useSession } from '@/lib/auth-client';
@@ -57,19 +59,9 @@ function prefValue<T>(prefs: Preference[], key: string, fallback: T): T {
return p.value as T;
}
// The reset must not outlive the tab: an uncleared setTimeout fires into a
// torn-down environment (unmount, or jsdom teardown under vitest).
function useSavedBadgeReset(saveState: SaveState, setSaveState: (s: SaveState) => void): void {
useEffect(() => {
if (saveState !== 'saved') return undefined;
const timer = setTimeout(() => setSaveState('idle'), 2000);
return () => clearTimeout(timer);
}, [saveState, setSaveState]);
}
// ─── Main Page ────────────────────────────────────────────────────────────────
export function SettingsPage(): React.ReactElement {
export default function SettingsPage(): React.ReactElement {
const { data: session } = useSession();
const [activeTab, setActiveTab] = useState<Tab>('profile');
@@ -121,7 +113,6 @@ function ProfileTab({
const [image, setImage] = useState(session?.user.image ?? '');
const [saveState, setSaveState] = useState<SaveState>('idle');
const [errorMsg, setErrorMsg] = useState('');
useSavedBadgeReset(saveState, setSaveState);
// Sync from session when it loads
useEffect(() => {
@@ -142,6 +133,7 @@ function ProfileTab({
return;
}
setSaveState('saved');
setTimeout(() => setSaveState('idle'), 2000);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Failed to update profile';
setErrorMsg(message);
@@ -204,7 +196,6 @@ function AppearanceTab(): React.ReactElement {
const [defaultModel, setDefaultModel] = useState('');
const [saveState, setSaveState] = useState<SaveState>('idle');
const [errorMsg, setErrorMsg] = useState('');
useSavedBadgeReset(saveState, setSaveState);
useEffect(() => {
api<Preference[]>('/api/memory/preferences?category=appearance')
@@ -250,6 +241,7 @@ function AppearanceTab(): React.ReactElement {
: []),
]);
setSaveState('saved');
setTimeout(() => setSaveState('idle'), 2000);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Failed to save preferences';
setErrorMsg(message);
@@ -333,7 +325,6 @@ function NotificationsTab(): React.ReactElement {
const [emailDigest, setEmailDigest] = useState(false);
const [saveState, setSaveState] = useState<SaveState>('idle');
const [errorMsg, setErrorMsg] = useState('');
useSavedBadgeReset(saveState, setSaveState);
useEffect(() => {
api<Preference[]>('/api/memory/preferences?category=communication')
@@ -380,6 +371,7 @@ function NotificationsTab(): React.ReactElement {
}),
]);
setSaveState('saved');
setTimeout(() => setSaveState('idle'), 2000);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Failed to save preferences';
setErrorMsg(message);
@@ -0,0 +1,72 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { api } from '@/lib/api';
import { cn } from '@/lib/cn';
import type { Task } from '@/lib/types';
import { KanbanBoard } from '@/components/tasks/kanban-board';
import { TaskListView } from '@/components/tasks/task-list-view';
type ViewMode = 'list' | 'kanban';
export default function TasksPage(): React.ReactElement {
const [tasks, setTasks] = useState<Task[]>([]);
const [view, setView] = useState<ViewMode>('kanban');
const [loading, setLoading] = useState(true);
useEffect(() => {
api<Task[]>('/api/tasks')
.then(setTasks)
.catch(() => {})
.finally(() => setLoading(false));
}, []);
const handleTaskClick = useCallback((task: Task) => {
// Task detail view will be added in future iteration
console.log('Task clicked:', task.id);
}, []);
return (
<div>
<div className="mb-6 flex items-center justify-between">
<h1 className="text-2xl font-semibold">Tasks</h1>
<div className="flex items-center gap-2">
<div className="flex rounded-lg border border-surface-border">
<button
type="button"
onClick={() => setView('list')}
className={cn(
'px-3 py-1.5 text-xs transition-colors',
view === 'list'
? 'bg-surface-elevated text-text-primary'
: 'text-text-muted hover:text-text-secondary',
)}
>
List
</button>
<button
type="button"
onClick={() => setView('kanban')}
className={cn(
'px-3 py-1.5 text-xs transition-colors',
view === 'kanban'
? 'bg-surface-elevated text-text-primary'
: 'text-text-muted hover:text-text-secondary',
)}
>
Kanban
</button>
</div>
</div>
</div>
{loading ? (
<p className="py-8 text-center text-sm text-text-muted">Loading tasks...</p>
) : view === 'kanban' ? (
<KanbanBoard tasks={tasks} onTaskClick={handleTaskClick} />
) : (
<TaskListView tasks={tasks} onTaskClick={handleTaskClick} />
)}
</div>
);
}
@@ -0,0 +1,95 @@
'use client';
import Link from 'next/link';
import { useEffect, useState } from 'react';
import { useParams, useSearchParams } from 'next/navigation';
import { api } from '@/lib/api';
import { resolveAuthCallbackURL } from '@/lib/auth-redirect';
import { signIn } from '@/lib/auth-client';
import type { SsoProviderDiscovery } from '@/lib/sso';
export default function AuthProviderRedirectPage(): React.ReactElement {
const params = useParams<{ provider: string }>();
const searchParams = useSearchParams();
const providerId = typeof params.provider === 'string' ? params.provider : '';
const requestedCallbackURL = searchParams.get('callbackURL');
const [providerName, setProviderName] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
async function redirectToProvider(): Promise<void> {
try {
const callbackURL = resolveAuthCallbackURL(requestedCallbackURL, window.location.origin);
const providers = await api<SsoProviderDiscovery[]>('/api/sso/providers');
if (cancelled) return;
const provider = providers.find((candidate) => candidate.id === providerId);
if (!provider) {
setError('Unknown SSO provider.');
return;
}
setProviderName(provider.name);
if (!provider.configured) {
setError(`${provider.name} is not enabled in this deployment.`);
return;
}
if (provider.loginMode !== 'oidc') {
setError(`${provider.name} is not available for OIDC sign in.`);
return;
}
const result = await signIn.oauth2({
providerId: provider.id,
callbackURL,
});
if (!cancelled && result?.error) {
setError(result.error.message ?? `${provider.name} sign in failed.`);
}
} catch (caught: unknown) {
if (!cancelled) {
setError(caught instanceof Error ? caught.message : 'Unable to start single sign-on.');
}
}
}
void redirectToProvider();
return () => {
cancelled = true;
};
}, [providerId, requestedCallbackURL]);
return (
<div className="mx-auto flex min-h-[50vh] max-w-md flex-col justify-center">
<h1 className="text-2xl font-semibold text-text-primary">Single sign-on</h1>
<p className="mt-2 text-sm text-text-secondary">
{providerName
? `Redirecting you to ${providerName}...`
: 'Preparing your sign-in request...'}
</p>
{error ? (
<div
role="alert"
className="mt-6 rounded-lg border border-error/30 bg-error/10 px-4 py-3 text-sm text-error"
>
<p>{error}</p>
<Link
href="/login"
className="mt-3 inline-block font-medium text-blue-400 hover:text-blue-300"
>
Return to login
</Link>
</div>
) : (
<div className="mt-6 rounded-lg border border-surface-border bg-surface-elevated px-4 py-3 text-sm text-text-secondary">
If the redirect does not start automatically, return to the login page and try again.
</div>
)}
</div>
);
}
+41
View File
@@ -0,0 +1,41 @@
import type { Metadata } from 'next';
import type { ReactNode } from 'react';
import { ThemeProvider } from '@/providers/theme-provider';
import './globals.css';
export const metadata: Metadata = {
title: 'Mosaic',
description: 'Mosaic Stack Dashboard',
};
function themeScript(): string {
return `
(function () {
try {
var theme = window.localStorage.getItem('mosaic-theme') || 'dark';
document.documentElement.setAttribute('data-theme', theme === 'light' ? 'light' : 'dark');
} catch (error) {
document.documentElement.setAttribute('data-theme', 'dark');
}
})();
`;
}
export default function RootLayout({ children }: { children: ReactNode }): React.ReactElement {
return (
<html lang="en" suppressHydrationWarning>
<head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=Fira+Code:wght@400;500&display=swap"
/>
<script dangerouslySetInnerHTML={{ __html: themeScript() }} />
</head>
<body>
<ThemeProvider>{children}</ThemeProvider>
</body>
</html>
);
}

Some files were not shown because too many files have changed in this diff Show More