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:
@@ -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')
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user