fix(08-10): correct check-phase8-mcp-client.mjs's connected-apps field name and revoke ordering

Two real defects surfaced by the gate's first live run against the real
fonoteka-mcp SDK:

- stage_revoke looked up the connected app by a.name; ConnectedAppsIndex
  actually serializes client_name (confirmed against
  controllers/api/connected_app_controller.go serializeConnectedApp).
- Even with that fixed, stage_revoke ran after stage_replay, by which
  point RevokeLineage's forward walk (presenting the pre-refresh spent
  secret) had already cascade-revoked the live post-refresh access token
  too -- correct, intentional T-08-REFRESH-REPLAY behavior, and the exact
  same effect 08-09-PLAN.md's own mcp-lifecycle fixture ordering already
  documented ('connected-apps would already be empty if list ran after
  replay'). stage_refresh now captures the connected-app id while the
  session is still live; stage_revoke DELETEs that id directly instead of
  re-listing (ConnectedAppsDestroy has no revoked_at filter on its own
  lookup, so this still exercises the real endpoint, idempotently, against
  the id the real MCP-driven session actually owned).
This commit is contained in:
Jakub Zych
2026-09-24 00:51:28 +02:00
parent fef037efe8
commit e562bf6f5b

View File

@@ -221,6 +221,7 @@ async function stageToolCall(state) {
async function stageRefresh(state) {
const authServer = env('FONOTEKA_MCP_AUTH_SERVER')
const apiURL = env('FONOTEKA_API_URL')
const auth = await loadAuthHelpers()
const tokens = await auth.refreshAuthorization(authServer, {
metadata: state.metadata,
@@ -231,6 +232,27 @@ async function stageRefresh(state) {
state.spentRefreshToken = state.refreshToken
state.accessToken = tokens.access_token
state.refreshToken = tokens.refresh_token || state.refreshToken
// Capture the connected-app id now, while the session is still live.
// RevokeLineage walks forward through RotatedToID, so the *next* stage
// (replay, presenting the now-spent pre-refresh secret) will correctly
// cascade-kill this very token as part of proving the replay defense --
// by the time stage_revoke runs, ConnectedAppsIndex's `revoked_at IS
// NULL` filter will no longer show it (08-09-PLAN.md's own precedent:
// "connected-apps would already be empty if list ran after replay").
// Capturing the id here instead of re-listing post-replay lets
// stage_revoke still exercise the real DELETE endpoint (idempotent
// against an already-revoked row, matching auth.RevokeLineage's
// if-not-already-revoked guard) rather than skipping revoke entirely.
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.client_name === 'Phase 8 final gate client')
if (!app) fail('connected-apps list does not show this gate client while the session is still live')
state.connectedAppId = app.id
log('refresh OK')
}
@@ -253,19 +275,21 @@ async function stageReplay(state) {
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), {
// The connected-app id was captured in stage_refresh, while the session
// was still live (see that stage's comment): stage_replay's cascading
// RevokeLineage kill already removed it from ConnectedAppsIndex's
// `revoked_at IS NULL` list by the time this stage runs. DELETE itself
// does not require the row to still be live (ConnectedAppsDestroy has no
// revoked_at filter on its own lookup), so this still exercises the real
// endpoint end to end, idempotently, against the id the real MCP-driven
// session actually owned.
if (!state.connectedAppId) fail('no connectedAppId captured (stage_refresh must run first)')
const delRes = await fetch(new URL(`/_fonoteka/api/v1/oauth/connected-apps/${state.connectedAppId}`, 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
state.revokedAppId = state.connectedAppId
log('revoke OK')
}