#!/usr/bin/env bash # Phase 8 final unchanged-MCP acceptance gate (08-CONTEXT.md D-14; 08-09-PLAN.md # Task 1/Task 3; 08-10-PLAN.md Task 3 is the sole execution site for the full # suite). Boots disposable Postgres and the assembled Go app, starts the real # unchanged Node fonoteka-mcp against the three required environment # variables, and drives the full scripted SDK lifecycle: discovery, DCR, # PKCE authorize, JWT login/consent, token, an MCP tool call, refresh, # replay, and revoke. Both repositories' vet/test/race, parity/corpus, # secret-scan, full UI harness, and unchanged Nuxt/MCP diffs are also gated # here. fonoteka-mcp and the Nuxt app are never modified. # # Modes: # (no flags) run the complete gate -- 08-10 Task 3 only. # --contract-self-test syntax/source assertions only, no services booted. # Designed for well under 30 seconds (08-09 Task 3). # --red-contract deliberately fail the named stage with the fixed # PHASE8_RED sentinel and exit 86 (08-09 Task 1 RED # harness self-test; never used outside that proof). set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" APP="$(cd "$ROOT/../fonoteka.go" && pwd)" MCP_ROOT="${MCP_ROOT:-/media/nvme/dev/golem15/fonoteka/fonoteka-mcp}" NUXT_ROOT="${NUXT_ROOT:-/media/nvme/dev/golem15/fonoteka/vue-fonoteka-app}" # Ordered, fail-closed stage names. --contract-self-test asserts every one of # these appears, in this order, in the script source (08-09-PLAN.md Task 3 # acceptance: "required real-MCP lifecycle, replay/revoke, two-repository # vet/test/race, parity, UI, secret-scan, security-review, and # unchanged-client stages in fail-closed order"). PHASE8_STAGES=( docker-preflight postgres app-boot real-mcp discovery dcr pkce-authorize jwt-login-consent token tool-call refresh replay revoke post-revoke-failure vet-test-race parity-corpus secret-scan ui-harness unchanged-client-diff security-review ) usage() { cat >&2 <<'EOF' usage: check-phase8.sh run the complete gate (08-10 Task 3 only) check-phase8.sh --contract-self-test syntax/source assertions only check-phase8.sh --security-review-only fail-closed 08-SECURITY-REVIEW.md checks only (08-10 Task 2) check-phase8.sh --red-contract deliberate RED self-test (08-09 Task 1) EOF exit 2 } # --------------------------------------------------------------------------- # --red-contract: a permanent, deliberate self-test hook proving the RED # harness (scripts/check-phase8-red.sh) correctly rejects anything other than # the exact one-stage, one-sentinel, exit-86 shape. It never calls a real # stage function -- it exists purely to anchor 08-09 Task 1's fail-closed # proof and is not part of the executable gate's stage sequence above. # --------------------------------------------------------------------------- run_red_contract() { local stage="$1" local found=false for s in "${PHASE8_STAGES[@]}"; do if [[ "$s" == "$stage" ]]; then found=true break fi done if [[ "$found" != true ]]; then echo "refuse: --red-contract stage %q is not a declared stage: $stage" >&2 exit 2 fi echo "PHASE8_STAGE:${stage}:FAIL:PHASE8_RED:real-mcp-stage" exit 86 } # --------------------------------------------------------------------------- # --contract-self-test: source/structure assertions only. No Docker, no # Postgres, no app boot, no Node process, no network beyond loopback binding # checks against the script's own source. Must stay well under 30 seconds # (08-09-PLAN.md Task 3 acceptance). # --------------------------------------------------------------------------- run_contract_self_test() { local self="${BASH_SOURCE[0]}" echo "==> bash -n" bash -n "$self" echo "==> required stage names present, in declared order" local last_line=0 for stage in "${PHASE8_STAGES[@]}"; do local line line="$(grep -n "stage_${stage//-/_}" "$self" | head -1 | cut -d: -f1 || true)" if [[ -z "$line" ]]; then echo "refuse: stage function for '${stage}' not found in source" >&2 exit 1 fi if (( line < last_line )); then echo "refuse: stage '${stage}' is declared out of order" >&2 exit 1 fi last_line="$line" done echo "==> cleanup trap present" grep -q "^trap cleanup_phase8 EXIT" "$self" || { echo "refuse: missing cleanup trap" >&2 exit 1 } echo "==> loopback-only service binding" if grep -qE "0\.0\.0\.0|--host[= ]0\.0\.0\.0" "$self"; then echo "refuse: non-loopback bind address found in source" >&2 exit 1 fi grep -q "127.0.0.1" "$self" || { echo "refuse: expected loopback address in source" >&2 exit 1 } echo "==> three MCP environment variables are exported" for var in FONOTEKA_API_URL FONOTEKA_MCP_PUBLIC_URL FONOTEKA_MCP_AUTH_SERVER; do grep -q "$var" "$self" || { echo "refuse: missing $var reference" >&2 exit 1 } done echo "==> redaction helper present (no raw secret/token/code/verifier echoed)" grep -q "redact_phase8" "$self" || { echo "refuse: missing redact_phase8 helper" >&2 exit 1 } echo "==> no pre-final full-run mode is offered" if grep -qE -- '^\s*--pre-security\)|^\s*--pre-final\)' "$self"; then echo "refuse: a pre-final full-run mode is offered" >&2 exit 1 fi echo "==> unchanged-client worktrees are only read, never written" grep -q "MCP_ROOT" "$self" || { echo "refuse: missing MCP_ROOT reference" >&2 exit 1 } grep -q "NUXT_ROOT" "$self" || { echo "refuse: missing NUXT_ROOT reference" >&2 exit 1 } echo "phase8 contract-self-test passed" } # --------------------------------------------------------------------------- # redact_phase8: strips anything credential-shaped before it reaches stdout. # Every stage function must pipe its own diagnostic output through this # before printing (T-08-REQUEST-LEAK). # --------------------------------------------------------------------------- redact_phase8() { sed -E \ -e 's/(client_secret=)[^&[:space:]]+/\1/g' \ -e 's/(code_verifier=)[^&[:space:]]+/\1/g' \ -e 's/(refresh_token=)[^&[:space:]]+/\1/g' \ -e 's/(access_token"?[:=]"?)[A-Za-z0-9_.\-]+/\1/g' \ -e 's/(Authorization: Bearer )[A-Za-z0-9_.\-]+/\1/g' \ -e 's/(Authorization: Basic )[A-Za-z0-9+\/=]+/\1/g' \ -e 's/inv_[A-Za-z0-9_-]{8,}//g' } PHASE8_CLEANUP_PIDS=() PHASE8_CLEANUP_DIRS=() PHASE8_CLEANUP_CONTAINERS=() cleanup_phase8() { local pid for pid in "${PHASE8_CLEANUP_PIDS[@]:-}"; do [[ -n "$pid" ]] || continue kill "$pid" 2>/dev/null || true wait "$pid" 2>/dev/null || true done local c for c in "${PHASE8_CLEANUP_CONTAINERS[@]:-}"; do [[ -n "$c" ]] || continue docker stop "$c" >/dev/null 2>&1 || true done local dir for dir in "${PHASE8_CLEANUP_DIRS[@]:-}"; do [[ -n "$dir" ]] || continue rm -rf "$dir" done } trap cleanup_phase8 EXIT # --------------------------------------------------------------------------- # Stage functions. Each is named stage_ so # --contract-self-test can locate it by source grep, in declared order. # Bodies are completed by 08-09-PLAN.md Task 3; execution is gated to # 08-10 Task 3 only (main() below never runs stages unless invoked with no # flags, which 08-09 never does). # --------------------------------------------------------------------------- stage_docker_preflight() { command -v docker >/dev/null 2>&1 || { echo "refuse: docker is required" >&2 exit 1 } docker info >/dev/null 2>&1 || { echo "refuse: docker daemon is not available" >&2 exit 1 } } PHASE8_WORKDIR="" PHASE8_PG_CONTAINER="" PHASE8_PG_PORT="" PHASE8_APP_PORT="18423" PHASE8_APP_URL="http://127.0.0.1:${PHASE8_APP_PORT}" PHASE8_MCP_PORT="18100" PHASE8_MCP_URL="http://127.0.0.1:${PHASE8_MCP_PORT}" PHASE8_GATE_EMAIL="phase8-gate@parity.test" PHASE8_GATE_PASSWORD="phase8-gate-pass" # bcrypt(cost=10) of PHASE8_GATE_PASSWORD, precomputed via # golang.org/x/crypto/bcrypt (bouncer.HashPassword's own algorithm) so the # app-boot seed step below needs no Go toolchain of its own. PHASE8_GATE_PASSWORD_HASH='$2a$10$cwulxI1xSv3pH0AzpT/AquBokra0uv73b41bkpy6jG.le2JOmJHJG' PHASE8_MCP_CLIENT="$ROOT/scripts/check-phase8-mcp-client.mjs" phase8_workdir() { if [[ -z "$PHASE8_WORKDIR" ]]; then PHASE8_WORKDIR="$(mktemp -d /tmp/summercms-phase8-XXXXXX)" PHASE8_CLEANUP_DIRS+=("$PHASE8_WORKDIR") fi echo "$PHASE8_WORKDIR" } stage_postgres() { local dir dir="$(phase8_workdir)" PHASE8_PG_CONTAINER="phase8-pg-$$" # The ICU pl-PL locale matches the application's recommended database; # lagoon itself no longer checks the database locale. docker run -d --rm --name "$PHASE8_PG_CONTAINER" \ -e POSTGRES_PASSWORD=phase8 -e POSTGRES_DB=fonoteka_phase8 \ -e POSTGRES_INITDB_ARGS="--locale-provider=icu --icu-locale=pl-PL --encoding=UTF8" \ -p 127.0.0.1::5432 postgres:16-alpine >/dev/null PHASE8_CLEANUP_CONTAINERS+=("$PHASE8_PG_CONTAINER") PHASE8_PG_PORT="$(docker port "$PHASE8_PG_CONTAINER" 5432/tcp | tail -1 | cut -d: -f2)" if [[ -z "$PHASE8_PG_PORT" ]]; then echo "refuse: could not determine disposable Postgres port" >&2 exit 1 fi local tries=0 until docker exec "$PHASE8_PG_CONTAINER" pg_isready -U postgres >/dev/null 2>&1; do tries=$((tries + 1)) if (( tries > 60 )); then echo "refuse: disposable Postgres did not become ready" >&2 exit 1 fi sleep 1 done echo "$PHASE8_PG_PORT" >"$dir/pg_port" } stage_app_boot() { local dir dir="$(phase8_workdir)" local dsn="postgres://postgres:phase8@127.0.0.1:${PHASE8_PG_PORT}/fonoteka_phase8?sslmode=disable" (cd "$APP" && go build -o "$dir/fonoteka" .) ( cd "$APP" export SUMMER_DATABASE__DSN="$dsn" export SUMMER_APP__URL="$PHASE8_APP_URL" export SUMMER_APP__KEY="$(head -c32 /dev/urandom | base64)" export SUMMER_GOLEM15__USER__JWT__SECRET="phase8-gate-jwt-secret-not-for-production" "$dir/fonoteka" migrate ) ( cd "$APP" export SUMMER_DATABASE__DSN="$dsn" export SUMMER_APP__URL="$PHASE8_APP_URL" export SUMMER_APP__KEY="$(head -c32 /dev/urandom | base64)" export SUMMER_GOLEM15__USER__JWT__SECRET="phase8-gate-jwt-secret-not-for-production" nohup "$dir/fonoteka" serve --addr "127.0.0.1:${PHASE8_APP_PORT}" >"$dir/app.log" 2>&1 & echo $! >"$dir/app.pid" ) PHASE8_CLEANUP_PIDS+=("$(cat "$dir/app.pid")") local tries=0 until curl -s -o /dev/null "$PHASE8_APP_URL/.well-known/oauth-authorization-server"; do tries=$((tries + 1)) if (( tries > 60 )); then echo "refuse: assembled app did not become ready ($(redact_phase8 <"$dir/app.log"))" >&2 exit 1 fi sleep 1 done # Seed the gate's own throwaway account and its default collection # directly in the disposable Postgres database, matching TestOAuthFlows' # real-Postgres seeding (../fonoteka.go/parity/oauth_flow_test.go). No # /_fonoteka/api/v1/onboarding/bootstrap route is mounted anywhere in # routes.go (routes.go's own comment marks that group's builder # deliberately empty, "pending its real handler in a later phase") -- # the account is seeded with SQL instead, exactly like every other # app-level OAuth test in this phase seeds its user/collection. # PHASE8_GATE_PASSWORD_HASH is the fixed bcrypt (cost 10) hash of # PHASE8_GATE_PASSWORD, matching bouncer.HashPassword's own algorithm # (golang.org/x/crypto/bcrypt), precomputed once so this stage needs no # Go toolchain of its own. docker exec -i "$PHASE8_PG_CONTAINER" psql -U postgres -d fonoteka_phase8 -v ON_ERROR_STOP=1 \ -v email="$PHASE8_GATE_EMAIL" -v hash="$PHASE8_GATE_PASSWORD_HASH" <<'SQL' >/dev/null WITH new_user AS ( INSERT INTO users (email, password, is_activated, activated_at) VALUES (:'email', :'hash', true, NOW()) RETURNING id ) INSERT INTO golem15_fonoteka_collections (owner_id, name) SELECT id, 'Phase 8 Gate Collection' FROM new_user; SQL } stage_real_mcp() { if [[ ! -d "$MCP_ROOT" ]]; then echo "refuse: MCP_ROOT not found: $MCP_ROOT" >&2 exit 1 fi local dir dir="$(phase8_workdir)" ( cd "$MCP_ROOT" export FONOTEKA_API_URL="$PHASE8_APP_URL" export FONOTEKA_MCP_PUBLIC_URL="$PHASE8_MCP_URL" export FONOTEKA_MCP_AUTH_SERVER="$PHASE8_APP_URL" export FONOTEKA_MCP_PORT="$PHASE8_MCP_PORT" nohup npx --no-install tsx src/http.ts >"$dir/mcp.log" 2>&1 & echo $! >"$dir/mcp.pid" ) PHASE8_CLEANUP_PIDS+=("$(cat "$dir/mcp.pid")") local tries=0 until curl -s -o /dev/null "$PHASE8_MCP_URL/mcp"; do tries=$((tries + 1)) if (( tries > 60 )); then echo "refuse: fonoteka-mcp did not become ready ($(redact_phase8 <"$dir/mcp.log"))" >&2 exit 1 fi sleep 1 done } phase8_mcp_stage() { local stage="$1" local dir dir="$(phase8_workdir)" ( export FONOTEKA_API_URL="$PHASE8_APP_URL" export FONOTEKA_MCP_PUBLIC_URL="$PHASE8_MCP_URL" export FONOTEKA_MCP_AUTH_SERVER="$PHASE8_APP_URL" export PHASE8_GATE_STATE="$dir/gate-state.json" export PHASE8_GATE_EMAIL PHASE8_GATE_PASSWORD node "$PHASE8_MCP_CLIENT" --stage "$stage" ) 2>&1 | redact_phase8 } stage_discovery() { phase8_mcp_stage discovery; } stage_dcr() { phase8_mcp_stage dcr; } stage_pkce_authorize() { phase8_mcp_stage pkce-authorize; } stage_jwt_login_consent() { phase8_mcp_stage jwt-login-consent; } stage_token() { phase8_mcp_stage token; } stage_tool_call() { phase8_mcp_stage tool-call; } stage_refresh() { phase8_mcp_stage refresh; } stage_replay() { phase8_mcp_stage replay; } stage_revoke() { phase8_mcp_stage revoke; } stage_post_revoke_failure() { phase8_mcp_stage post-revoke-failure; } stage_vet_test_race() { local name dir for name in "$ROOT" "$APP"; do ( cd "$name" go vet ./... go test ./... go test -race ./... ) done } stage_parity_corpus() { ( cd "$APP" go test ./parity -count=1 go run ./parity/check_corpus.go --manifest parity/manifest.yaml --fixtures parity/fixtures \ --require-recorded --require-clients --check-secrets ) } stage_secret_scan() { # check_corpus.go --check-secrets above already scans every fixture; # this stage additionally scans this gate's own working directory so a # captured log line never carries a live secret past cleanup. local dir dir="$(phase8_workdir)" if grep -RIlE 'inv_[A-Za-z0-9_-]{8,}|eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+' "$dir" >/dev/null 2>&1; then echo "refuse: a live credential-shaped value was found in the gate's own working directory" >&2 exit 1 fi } stage_ui_harness() { if [[ ! -d "$NUXT_ROOT" ]]; then echo "refuse: NUXT_ROOT not found: $NUXT_ROOT" >&2 exit 1 fi PHASE8_UI_ALLOW_FINAL_GATE=1 node "$ROOT/scripts/check-phase8-ui.mjs" --final-gate } stage_unchanged_client_diff() { # Fails the gate if either unchanged client worktree gains a Phase 8 # source diff -- this repo never edits them. local name for name in "$MCP_ROOT" "$NUXT_ROOT"; do if [[ -d "$name/.git" ]] || git -C "$name" rev-parse --git-dir >/dev/null 2>&1; then if [[ -n "$(git -C "$name" status --porcelain)" ]]; then echo "refuse: unchanged client worktree has a diff: $name" >&2 git -C "$name" status --short >&2 exit 1 fi fi done } PHASE8_REQUIRED_THREATS=( T-08-PKCE T-08-CODE-REPLAY T-08-REFRESH-REPLAY T-08-OPEN-REDIRECT T-08-SECRET-TIMING T-08-SCOPE-CEILING T-08-CROSS-USER T-08-REQUEST-LEAK T-08-DCR-FLOOD T-08-SURFACE T-08-SC ) stage_security_review() { local review="$ROOT/.planning/phases/08-oauth2-1-authorization-server/08-SECURITY-REVIEW.md" if [[ ! -f "$review" ]]; then echo "refuse: 08-SECURITY-REVIEW.md not found (08-10 Task adds it)" >&2 exit 1 fi grep -q "^status: verified" "$review" || { echo "refuse: 08-SECURITY-REVIEW.md is not status: verified" >&2 exit 1 } grep -qE "^threats_open: 0$" "$review" || { echo "refuse: 08-SECURITY-REVIEW.md does not declare threats_open: 0" >&2 exit 1 } local t for t in "${PHASE8_REQUIRED_THREATS[@]}"; do grep -q -- "$t" "$review" || { echo "refuse: 08-SECURITY-REVIEW.md is missing required threat row $t" >&2 exit 1 } done } run_full_gate() { # Every stage/helper that needs the shared workdir calls # dir="$(phase8_workdir)" -- command substitution, which always forks a # subshell. If PHASE8_WORKDIR were first assigned *inside* that # subshelled function body, the assignment would never escape back to # this (the real, top-level) shell: every separate caller would then # see PHASE8_WORKDIR still empty and mint its own fresh mktemp # directory, silently fragmenting one run's state (app.log, the MCP # client's gate-state.json, etc.) across many directories that never # see each other's writes. Setting it here, directly (not through a # command substitution), makes every later `phase8_workdir()` call see # it already populated and simply echo the same value back. PHASE8_WORKDIR="$(mktemp -d /tmp/summercms-phase8-XXXXXX)" PHASE8_CLEANUP_DIRS+=("$PHASE8_WORKDIR") stage_docker_preflight stage_postgres stage_app_boot stage_real_mcp stage_discovery stage_dcr stage_pkce_authorize stage_jwt_login_consent stage_token stage_tool_call stage_refresh stage_replay stage_revoke stage_post_revoke_failure # gate-state.json (the MCP client's shared cross-invocation state file) # has done its job: no later stage reads it, and it necessarily holds # raw live secrets (access/refresh tokens, the JWT) the MCP SDK's own # helpers returned during the stages above -- unlike every stage's own # stdout/stderr, it is never piped through redact_phase8. Removing it # now, before stage_secret_scan's directory-wide scan, is the fix (not # loosening that scan): nothing legitimate needs this file anymore. rm -f "$(phase8_workdir)/gate-state.json" stage_vet_test_race stage_parity_corpus stage_secret_scan stage_ui_harness stage_unchanged_client_diff stage_security_review echo "phase8 check passed" } main() { case "${1:-}" in "") run_full_gate ;; --contract-self-test) run_contract_self_test ;; --security-review-only) # 08-10-PLAN.md Task 2's own focused verify: the fail-closed # 08-SECURITY-REVIEW.md checks only, no services booted. Not part # of the complete gate's stage sequence; kept out of # PHASE8_STAGES/run_full_gate so --contract-self-test's "no # pre-final full-run mode" check does not need to special-case it. stage_security_review echo "phase8 security-review-only check passed" ;; --red-contract) [[ $# -ge 2 ]] || usage run_red_contract "$2" ;; *) usage ;; esac } main "$@"