From e562bf6f5b9cbc0ef0f2eea0ae19b9648b016e06 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Thu, 24 Sep 2026 00:51:28 +0200 Subject: [PATCH] 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). --- scripts/check-phase8-mcp-client.mjs | 42 ++++++++++++++++++++++------- 1 file changed, 33 insertions(+), 9 deletions(-) diff --git a/scripts/check-phase8-mcp-client.mjs b/scripts/check-phase8-mcp-client.mjs index d43c79d..1549a18 100755 --- a/scripts/check-phase8-mcp-client.mjs +++ b/scripts/check-phase8-mcp-client.mjs @@ -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') }