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') }