feat(08-09): complete the fail-closed Phase 8 final unchanged-MCP gate
Fills in every scripts/check-phase8.sh stage skeleton with real logic: disposable Postgres (docker run + pg_isready), the assembled Go app built and served against it with a throwaway onboarding-seeded gate account, the real unchanged fonoteka-mcp process started with all three required environment variables, and the full scripted SDK lifecycle -- discovery (MCP's own RFC 9728 401 hint, verified separately from authorization server metadata), DCR, PKCE authorize, JWT login/consent, token, an MCP tool call, refresh, replay of the spent refresh token, revoke, and a post-revoke refresh failure -- delegated to the new scripts/check-phase8-mcp-client.mjs driver, which resolves the MCP SDK's auth helpers from fonoteka-mcp's own node_modules (no new dependency, same pattern as parity/capture_clients.mjs). Both repositories' vet/test/race, the full parity/corpus/secret-scan gate, the existing check-phase8-ui.mjs --final-gate UI harness, an unchanged-client git-diff check for both MCP_ROOT and NUXT_ROOT, and a 08-SECURITY-REVIEW.md status:verified gate close out the stage list. --contract-self-test validates structure only (stage names/order, cleanup trap, loopback-only binding, the three MCP env vars, the redaction helper, no pre-final full-run flag, read-only unchanged-client references) in well under 30 seconds -- it boots no services. The --red-contract self-test from Task 1 is preserved unchanged. run_full_gate (the no-flag invocation) is 08-10 Task 3's sole execution site; 08-09 never invokes it.
This commit is contained in:
317
scripts/check-phase8-mcp-client.mjs
Executable file
317
scripts/check-phase8-mcp-client.mjs
Executable file
@@ -0,0 +1,317 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* check-phase8-mcp-client.mjs -- the scripted MCP client D-14 requires:
|
||||||
|
* discovery, DCR, PKCE authorize, JWT login/consent, token, an MCP tool
|
||||||
|
* call, refresh, replay, revoke, and post-revoke failure, driven against
|
||||||
|
* the REAL unchanged fonoteka-mcp process and the assembled Go backend.
|
||||||
|
* Never modifies fonoteka-mcp or Nuxt source; resolves the MCP SDK's auth
|
||||||
|
* helpers from fonoteka-mcp's own node_modules exactly like
|
||||||
|
* parity/capture_clients.mjs does (no new dependency in either repo).
|
||||||
|
*
|
||||||
|
* Invoked once per named stage by scripts/check-phase8.sh's stage_*
|
||||||
|
* functions, each stage reading/writing a small JSON state file so later
|
||||||
|
* stages (refresh, revoke) can reuse earlier captures (tokens, request
|
||||||
|
* ids) without re-running the whole sequence. Only 08-10 Task 3 invokes
|
||||||
|
* this end to end; 08-09 never runs it.
|
||||||
|
*
|
||||||
|
* Required env:
|
||||||
|
* FONOTEKA_API_URL Go app origin (personal-token API, and the
|
||||||
|
* same origin's JWT-group user/session API).
|
||||||
|
* FONOTEKA_MCP_PUBLIC_URL The MCP resource server's own base URL.
|
||||||
|
* FONOTEKA_MCP_AUTH_SERVER The Go authorization server's base URL
|
||||||
|
* (normally identical to FONOTEKA_API_URL).
|
||||||
|
* PHASE8_GATE_STATE Path to the JSON state file.
|
||||||
|
* PHASE8_GATE_EMAIL/PASSWORD Credentials for the JWT login/consent
|
||||||
|
* steps, seeded by the app-boot stage.
|
||||||
|
*
|
||||||
|
* Every raw secret/token/code/verifier this script would otherwise print
|
||||||
|
* is redacted before it reaches stdout/stderr (T-08-REQUEST-LEAK).
|
||||||
|
*/
|
||||||
|
import { createRequire } from 'node:module'
|
||||||
|
import { pathToFileURL } from 'node:url'
|
||||||
|
import { readFileSync, writeFileSync, existsSync } from 'node:fs'
|
||||||
|
|
||||||
|
const MCP_PKG = '/media/nvme/dev/golem15/fonoteka/fonoteka-mcp/package.json'
|
||||||
|
|
||||||
|
function redact(s) {
|
||||||
|
return String(s)
|
||||||
|
.replace(/inv_[A-Za-z0-9_-]{8,}/g, '<redacted-inv>')
|
||||||
|
.replace(/eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g, '<redacted-jwt>')
|
||||||
|
.replace(/("access_token"|"refresh_token"|"code_verifier"|"client_secret")\s*:\s*"[^"]*"/g, '$1:"<redacted>"')
|
||||||
|
}
|
||||||
|
|
||||||
|
function log(msg) {
|
||||||
|
process.stderr.write(`[check-phase8-mcp-client] ${redact(msg)}\n`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function fail(msg) {
|
||||||
|
log(`FAIL: ${msg}`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function env(name, required = true) {
|
||||||
|
const v = process.env[name]
|
||||||
|
if (required && (!v || !v.trim())) fail(`missing required env ${name}`)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadState(path) {
|
||||||
|
if (!existsSync(path)) return {}
|
||||||
|
return JSON.parse(readFileSync(path, 'utf8'))
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveState(path, state) {
|
||||||
|
writeFileSync(path, JSON.stringify(state, null, 2), { mode: 0o600 })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAuthHelpers() {
|
||||||
|
const req = createRequire(MCP_PKG)
|
||||||
|
const mod = await import(pathToFileURL(req.resolve('@modelcontextprotocol/sdk/client/auth.js')).href)
|
||||||
|
return mod
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadClientTransport() {
|
||||||
|
const req = createRequire(MCP_PKG)
|
||||||
|
const clientMod = await import(pathToFileURL(req.resolve('@modelcontextprotocol/sdk/client/index.js')).href)
|
||||||
|
const httpMod = await import(pathToFileURL(req.resolve('@modelcontextprotocol/sdk/client/streamableHttp.js')).href)
|
||||||
|
return { Client: clientMod.Client, StreamableHTTPClientTransport: httpMod.StreamableHTTPClientTransport }
|
||||||
|
}
|
||||||
|
|
||||||
|
const REDIRECT_URI = 'http://127.0.0.1:8424/oauth/callback'
|
||||||
|
|
||||||
|
async function stageDiscovery(state) {
|
||||||
|
const mcpPublic = env('FONOTEKA_MCP_PUBLIC_URL')
|
||||||
|
const authServer = env('FONOTEKA_MCP_AUTH_SERVER')
|
||||||
|
const auth = await loadAuthHelpers()
|
||||||
|
|
||||||
|
// The resource server's own 401 hint (RFC 9728), owned by fonoteka-mcp,
|
||||||
|
// not the backend (D-12) -- verified separately from the backend's own
|
||||||
|
// exact Basic/no-challenge responses (stage_token below).
|
||||||
|
const probe = await fetch(new URL('/mcp', mcpPublic), { method: 'GET' })
|
||||||
|
if (probe.status !== 401) fail(`expected 401 from unauthenticated MCP endpoint, got ${probe.status}`)
|
||||||
|
const params = auth.extractWWWAuthenticateParams
|
||||||
|
? auth.extractWWWAuthenticateParams(probe)
|
||||||
|
: null
|
||||||
|
if (!params || !params.resourceMetadataUrl) {
|
||||||
|
fail('MCP 401 response is missing a resource_metadata WWW-Authenticate hint')
|
||||||
|
}
|
||||||
|
|
||||||
|
const resourceMetadata = await auth.discoverOAuthProtectedResourceMetadata(mcpPublic)
|
||||||
|
const metadata = await auth.discoverAuthorizationServerMetadata(authServer)
|
||||||
|
if (!metadata) fail('authorization server metadata discovery failed')
|
||||||
|
|
||||||
|
state.resourceMetadata = resourceMetadata
|
||||||
|
state.metadata = metadata
|
||||||
|
log('discovery OK')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stageDCR(state) {
|
||||||
|
const authServer = env('FONOTEKA_MCP_AUTH_SERVER')
|
||||||
|
const auth = await loadAuthHelpers()
|
||||||
|
const clientInformation = await auth.registerClient(authServer, {
|
||||||
|
metadata: state.metadata,
|
||||||
|
clientMetadata: {
|
||||||
|
client_name: 'Phase 8 final gate client',
|
||||||
|
redirect_uris: [REDIRECT_URI],
|
||||||
|
grant_types: ['authorization_code', 'refresh_token'],
|
||||||
|
response_types: ['code'],
|
||||||
|
token_endpoint_auth_method: 'none',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
state.clientInformation = clientInformation
|
||||||
|
log('dcr OK')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stagePKCEAuthorize(state) {
|
||||||
|
const authServer = env('FONOTEKA_MCP_AUTH_SERVER')
|
||||||
|
const auth = await loadAuthHelpers()
|
||||||
|
const { authorizationUrl, codeVerifier } = await auth.startAuthorization(authServer, {
|
||||||
|
metadata: state.metadata,
|
||||||
|
clientInformation: state.clientInformation,
|
||||||
|
redirectUrl: REDIRECT_URI,
|
||||||
|
scope: 'read write',
|
||||||
|
state: 'phase8-gate',
|
||||||
|
})
|
||||||
|
const res = await fetch(authorizationUrl, { redirect: 'manual' })
|
||||||
|
if (res.status !== 302 && res.status !== 303 && res.status !== 307) {
|
||||||
|
fail(`authorize did not redirect (status ${res.status})`)
|
||||||
|
}
|
||||||
|
const location = res.headers.get('location')
|
||||||
|
if (!location) fail('authorize redirect has no Location header')
|
||||||
|
const requestId = new URL(location).searchParams.get('request')
|
||||||
|
if (!requestId) fail('authorize redirect is missing ?request=')
|
||||||
|
state.codeVerifier = codeVerifier
|
||||||
|
state.requestId = requestId
|
||||||
|
log('pkce-authorize OK')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stageJWTLoginConsent(state) {
|
||||||
|
const apiURL = env('FONOTEKA_API_URL')
|
||||||
|
const email = env('PHASE8_GATE_EMAIL')
|
||||||
|
const password = env('PHASE8_GATE_PASSWORD')
|
||||||
|
|
||||||
|
const loginRes = await fetch(new URL('/_user/api/v1/login', apiURL), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||||
|
body: JSON.stringify({ email, password }),
|
||||||
|
})
|
||||||
|
if (loginRes.status !== 200) fail(`JWT login failed (status ${loginRes.status})`)
|
||||||
|
const { token } = await loginRes.json()
|
||||||
|
if (!token) fail('JWT login response has no token')
|
||||||
|
|
||||||
|
const showRes = await fetch(new URL(`/_fonoteka/api/v1/oauth/request/${state.requestId}`, apiURL), {
|
||||||
|
headers: { Accept: 'application/json', Authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
if (showRes.status !== 200) fail(`consent request lookup failed (status ${showRes.status})`)
|
||||||
|
|
||||||
|
const consentRes = await fetch(new URL('/_fonoteka/api/v1/oauth/consent', apiURL), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json', Authorization: `Bearer ${token}` },
|
||||||
|
body: JSON.stringify({ request_id: state.requestId, scopes: ['read', 'write'] }),
|
||||||
|
})
|
||||||
|
if (consentRes.status !== 200) fail(`consent failed (status ${consentRes.status})`)
|
||||||
|
const consentBody = await consentRes.json()
|
||||||
|
const redirectTo = consentBody?.data?.redirect_to
|
||||||
|
if (!redirectTo) fail('consent response has no redirect_to')
|
||||||
|
const code = new URL(redirectTo).searchParams.get('code')
|
||||||
|
if (!code) fail('consent redirect_to has no code')
|
||||||
|
|
||||||
|
state.jwt = token
|
||||||
|
state.code = code
|
||||||
|
log('jwt-login-consent OK')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stageToken(state) {
|
||||||
|
const authServer = env('FONOTEKA_MCP_AUTH_SERVER')
|
||||||
|
const auth = await loadAuthHelpers()
|
||||||
|
const tokens = await auth.exchangeAuthorization(authServer, {
|
||||||
|
metadata: state.metadata,
|
||||||
|
clientInformation: state.clientInformation,
|
||||||
|
authorizationCode: state.code,
|
||||||
|
codeVerifier: state.codeVerifier,
|
||||||
|
redirectUri: REDIRECT_URI,
|
||||||
|
})
|
||||||
|
if (!tokens.access_token || !tokens.refresh_token) fail('token exchange did not return both tokens')
|
||||||
|
state.accessToken = tokens.access_token
|
||||||
|
state.refreshToken = tokens.refresh_token
|
||||||
|
state.spentRefreshToken = tokens.refresh_token
|
||||||
|
log('token OK')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stageToolCall(state) {
|
||||||
|
const mcpPublic = env('FONOTEKA_MCP_PUBLIC_URL')
|
||||||
|
const { Client, StreamableHTTPClientTransport } = await loadClientTransport()
|
||||||
|
const transport = new StreamableHTTPClientTransport(new URL('/mcp', mcpPublic), {
|
||||||
|
requestInit: { headers: { Authorization: `Bearer ${state.accessToken}` } },
|
||||||
|
})
|
||||||
|
const client = new Client({ name: 'phase8-gate', version: '0.0.1' })
|
||||||
|
await client.connect(transport)
|
||||||
|
try {
|
||||||
|
const tools = await client.listTools()
|
||||||
|
if (!Array.isArray(tools.tools) || tools.tools.length === 0) fail('MCP tool list is empty')
|
||||||
|
const listGenres = tools.tools.find((t) => t.name === 'list_genres')
|
||||||
|
if (!listGenres) fail('list_genres tool not found')
|
||||||
|
const result = await client.callTool({ name: 'list_genres', arguments: {} })
|
||||||
|
if (result.isError) fail(`list_genres tool call reported an error: ${JSON.stringify(result)}`)
|
||||||
|
} finally {
|
||||||
|
await client.close().catch(() => {})
|
||||||
|
}
|
||||||
|
log('tool-call OK')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stageRefresh(state) {
|
||||||
|
const authServer = env('FONOTEKA_MCP_AUTH_SERVER')
|
||||||
|
const auth = await loadAuthHelpers()
|
||||||
|
const tokens = await auth.refreshAuthorization(authServer, {
|
||||||
|
metadata: state.metadata,
|
||||||
|
clientInformation: state.clientInformation,
|
||||||
|
refreshToken: state.refreshToken,
|
||||||
|
})
|
||||||
|
if (!tokens.access_token) fail('refresh did not return a new access token')
|
||||||
|
state.spentRefreshToken = state.refreshToken
|
||||||
|
state.accessToken = tokens.access_token
|
||||||
|
state.refreshToken = tokens.refresh_token || state.refreshToken
|
||||||
|
log('refresh OK')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stageReplay(state) {
|
||||||
|
const authServer = env('FONOTEKA_MCP_AUTH_SERVER')
|
||||||
|
const auth = await loadAuthHelpers()
|
||||||
|
let threw = false
|
||||||
|
try {
|
||||||
|
await auth.refreshAuthorization(authServer, {
|
||||||
|
metadata: state.metadata,
|
||||||
|
clientInformation: state.clientInformation,
|
||||||
|
refreshToken: state.spentRefreshToken,
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
threw = true
|
||||||
|
}
|
||||||
|
if (!threw) fail('replaying the spent refresh token unexpectedly succeeded')
|
||||||
|
log('replay OK (spent refresh token correctly rejected)')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stageRevoke(state) {
|
||||||
|
const apiURL = env('FONOTEKA_API_URL')
|
||||||
|
const listRes = await fetch(new URL('/_fonoteka/api/v1/oauth/connected-apps', apiURL), {
|
||||||
|
headers: { Accept: 'application/json', Authorization: `Bearer ${state.jwt}` },
|
||||||
|
})
|
||||||
|
if (listRes.status !== 200) fail(`connected-apps list failed (status ${listRes.status})`)
|
||||||
|
const listBody = await listRes.json()
|
||||||
|
const app = (listBody.data || []).find((a) => a.name === 'Phase 8 final gate client')
|
||||||
|
if (!app) fail('connected-apps list does not show this gate client')
|
||||||
|
const delRes = await fetch(new URL(`/_fonoteka/api/v1/oauth/connected-apps/${app.id}`, apiURL), {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { Accept: 'application/json', Authorization: `Bearer ${state.jwt}` },
|
||||||
|
})
|
||||||
|
if (delRes.status !== 200) fail(`connected-apps revoke failed (status ${delRes.status})`)
|
||||||
|
state.revokedAppId = app.id
|
||||||
|
log('revoke OK')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stagePostRevokeFailure(state) {
|
||||||
|
const authServer = env('FONOTEKA_MCP_AUTH_SERVER')
|
||||||
|
const auth = await loadAuthHelpers()
|
||||||
|
let threw = false
|
||||||
|
try {
|
||||||
|
await auth.refreshAuthorization(authServer, {
|
||||||
|
metadata: state.metadata,
|
||||||
|
clientInformation: state.clientInformation,
|
||||||
|
refreshToken: state.refreshToken,
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
threw = true
|
||||||
|
}
|
||||||
|
if (!threw) fail('refreshing after revoke unexpectedly succeeded')
|
||||||
|
log('post-revoke-failure OK')
|
||||||
|
}
|
||||||
|
|
||||||
|
const STAGES = {
|
||||||
|
discovery: stageDiscovery,
|
||||||
|
dcr: stageDCR,
|
||||||
|
'pkce-authorize': stagePKCEAuthorize,
|
||||||
|
'jwt-login-consent': stageJWTLoginConsent,
|
||||||
|
token: stageToken,
|
||||||
|
'tool-call': stageToolCall,
|
||||||
|
refresh: stageRefresh,
|
||||||
|
replay: stageReplay,
|
||||||
|
revoke: stageRevoke,
|
||||||
|
'post-revoke-failure': stagePostRevokeFailure,
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const stageArgIdx = process.argv.indexOf('--stage')
|
||||||
|
if (stageArgIdx === -1 || !process.argv[stageArgIdx + 1]) {
|
||||||
|
fail('usage: check-phase8-mcp-client.mjs --stage <name>')
|
||||||
|
}
|
||||||
|
const stageName = process.argv[stageArgIdx + 1]
|
||||||
|
const fn = STAGES[stageName]
|
||||||
|
if (!fn) fail(`unknown stage ${stageName}`)
|
||||||
|
|
||||||
|
const statePath = env('PHASE8_GATE_STATE')
|
||||||
|
const state = loadState(statePath)
|
||||||
|
await fn(state)
|
||||||
|
saveState(statePath, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => fail(err?.stack || String(err)))
|
||||||
@@ -144,7 +144,7 @@ run_contract_self_test() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
echo "==> no pre-final full-run mode is offered"
|
echo "==> no pre-final full-run mode is offered"
|
||||||
if grep -qE -- '--pre-security|--pre-final' "$self"; then
|
if grep -qE -- '^\s*--pre-security\)|^\s*--pre-final\)' "$self"; then
|
||||||
echo "refuse: a pre-final full-run mode is offered" >&2
|
echo "refuse: a pre-final full-run mode is offered" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
@@ -180,6 +180,7 @@ redact_phase8() {
|
|||||||
|
|
||||||
PHASE8_CLEANUP_PIDS=()
|
PHASE8_CLEANUP_PIDS=()
|
||||||
PHASE8_CLEANUP_DIRS=()
|
PHASE8_CLEANUP_DIRS=()
|
||||||
|
PHASE8_CLEANUP_CONTAINERS=()
|
||||||
|
|
||||||
cleanup_phase8() {
|
cleanup_phase8() {
|
||||||
local pid
|
local pid
|
||||||
@@ -188,6 +189,11 @@ cleanup_phase8() {
|
|||||||
kill "$pid" 2>/dev/null || true
|
kill "$pid" 2>/dev/null || true
|
||||||
wait "$pid" 2>/dev/null || true
|
wait "$pid" 2>/dev/null || true
|
||||||
done
|
done
|
||||||
|
local c
|
||||||
|
for c in "${PHASE8_CLEANUP_CONTAINERS[@]:-}"; do
|
||||||
|
[[ -n "$c" ]] || continue
|
||||||
|
docker stop "$c" >/dev/null 2>&1 || true
|
||||||
|
done
|
||||||
local dir
|
local dir
|
||||||
for dir in "${PHASE8_CLEANUP_DIRS[@]:-}"; do
|
for dir in "${PHASE8_CLEANUP_DIRS[@]:-}"; do
|
||||||
[[ -n "$dir" ]] || continue
|
[[ -n "$dir" ]] || continue
|
||||||
@@ -215,14 +221,94 @@ stage_docker_preflight() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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"
|
||||||
|
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() {
|
stage_postgres() {
|
||||||
echo "not yet implemented outside 08-10 Task 3" >&2
|
local dir
|
||||||
exit 1
|
dir="$(phase8_workdir)"
|
||||||
|
PHASE8_PG_CONTAINER="phase8-pg-$$"
|
||||||
|
docker run -d --rm --name "$PHASE8_PG_CONTAINER" \
|
||||||
|
-e POSTGRES_PASSWORD=phase8 -e POSTGRES_DB=fonoteka_phase8 \
|
||||||
|
-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() {
|
stage_app_boot() {
|
||||||
echo "not yet implemented outside 08-10 Task 3" >&2
|
local dir
|
||||||
exit 1
|
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 via the real onboarding endpoint
|
||||||
|
# (matching TestOAuthFlows' seeding, but through HTTP since this stage
|
||||||
|
# drives the real listening app, not an in-process handler).
|
||||||
|
curl -s -X POST "$PHASE8_APP_URL/_fonoteka/api/v1/onboarding/bootstrap" \
|
||||||
|
-H "Content-Type: application/json" -H "Accept: application/json" \
|
||||||
|
-d "{\"org_name\":\"Phase 8 Gate\",\"email\":\"${PHASE8_GATE_EMAIL}\",\"password\":\"${PHASE8_GATE_PASSWORD}\"}" \
|
||||||
|
>/dev/null
|
||||||
}
|
}
|
||||||
|
|
||||||
stage_real_mcp() {
|
stage_real_mcp() {
|
||||||
@@ -230,77 +316,86 @@ stage_real_mcp() {
|
|||||||
echo "refuse: MCP_ROOT not found: $MCP_ROOT" >&2
|
echo "refuse: MCP_ROOT not found: $MCP_ROOT" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
# FONOTEKA_API_URL, FONOTEKA_MCP_PUBLIC_URL, FONOTEKA_MCP_AUTH_SERVER are
|
local dir
|
||||||
# exported here (only into the fonoteka-mcp child process, never into the
|
dir="$(phase8_workdir)"
|
||||||
# gate's own persistent environment) once the app/Postgres stages above
|
(
|
||||||
# are live; 127.0.0.1-only.
|
cd "$MCP_ROOT"
|
||||||
echo "not yet implemented outside 08-10 Task 3" >&2
|
export FONOTEKA_API_URL="$PHASE8_APP_URL"
|
||||||
exit 1
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
stage_discovery() {
|
phase8_mcp_stage() {
|
||||||
echo "not yet implemented outside 08-10 Task 3" >&2
|
local stage="$1"
|
||||||
exit 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_dcr() {
|
stage_discovery() { phase8_mcp_stage discovery; }
|
||||||
echo "not yet implemented outside 08-10 Task 3" >&2
|
stage_dcr() { phase8_mcp_stage dcr; }
|
||||||
exit 1
|
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_pkce_authorize() {
|
stage_tool_call() { phase8_mcp_stage tool-call; }
|
||||||
echo "not yet implemented outside 08-10 Task 3" >&2
|
stage_refresh() { phase8_mcp_stage refresh; }
|
||||||
exit 1
|
stage_replay() { phase8_mcp_stage replay; }
|
||||||
}
|
stage_revoke() { phase8_mcp_stage revoke; }
|
||||||
|
stage_post_revoke_failure() { phase8_mcp_stage post-revoke-failure; }
|
||||||
stage_jwt_login_consent() {
|
|
||||||
echo "not yet implemented outside 08-10 Task 3" >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
stage_token() {
|
|
||||||
echo "not yet implemented outside 08-10 Task 3" >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
stage_tool_call() {
|
|
||||||
echo "not yet implemented outside 08-10 Task 3" >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
stage_refresh() {
|
|
||||||
echo "not yet implemented outside 08-10 Task 3" >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
stage_replay() {
|
|
||||||
echo "not yet implemented outside 08-10 Task 3" >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
stage_revoke() {
|
|
||||||
echo "not yet implemented outside 08-10 Task 3" >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
stage_post_revoke_failure() {
|
|
||||||
echo "not yet implemented outside 08-10 Task 3" >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
stage_vet_test_race() {
|
stage_vet_test_race() {
|
||||||
echo "not yet implemented outside 08-10 Task 3" >&2
|
local name dir
|
||||||
exit 1
|
for name in "$ROOT" "$APP"; do
|
||||||
|
(
|
||||||
|
cd "$name"
|
||||||
|
go vet ./...
|
||||||
|
go test ./...
|
||||||
|
go test -race ./...
|
||||||
|
)
|
||||||
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
stage_parity_corpus() {
|
stage_parity_corpus() {
|
||||||
echo "not yet implemented outside 08-10 Task 3" >&2
|
(
|
||||||
exit 1
|
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() {
|
stage_secret_scan() {
|
||||||
echo "not yet implemented outside 08-10 Task 3" >&2
|
# check_corpus.go --check-secrets above already scans every fixture;
|
||||||
exit 1
|
# 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() {
|
stage_ui_harness() {
|
||||||
@@ -308,25 +403,59 @@ stage_ui_harness() {
|
|||||||
echo "refuse: NUXT_ROOT not found: $NUXT_ROOT" >&2
|
echo "refuse: NUXT_ROOT not found: $NUXT_ROOT" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo "not yet implemented outside 08-10 Task 3" >&2
|
PHASE8_UI_ALLOW_FINAL_GATE=1 node "$ROOT/scripts/check-phase8-ui.mjs" --final-gate
|
||||||
exit 1
|
|
||||||
}
|
}
|
||||||
|
|
||||||
stage_unchanged_client_diff() {
|
stage_unchanged_client_diff() {
|
||||||
# Fails the gate if either unchanged client worktree (MCP_ROOT/NUXT_ROOT)
|
# Fails the gate if either unchanged client worktree gains a Phase 8
|
||||||
# gains a Phase 8 source diff -- this repo never edits them.
|
# source diff -- this repo never edits them.
|
||||||
echo "not yet implemented outside 08-10 Task 3" >&2
|
local name
|
||||||
exit 1
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
stage_security_review() {
|
stage_security_review() {
|
||||||
echo "not yet implemented outside 08-10 Task 3" >&2
|
local review="$ROOT/.planning/phases/08-oauth2-1-authorization-server/08-SECURITY-REVIEW.md"
|
||||||
exit 1
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
run_full_gate() {
|
run_full_gate() {
|
||||||
echo "refuse: the complete gate runs only from 08-10 Task 3" >&2
|
stage_docker_preflight
|
||||||
exit 1
|
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
|
||||||
|
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() {
|
main() {
|
||||||
|
|||||||
Reference in New Issue
Block a user