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) {
|
async function stageRefresh(state) {
|
||||||
const authServer = env('FONOTEKA_MCP_AUTH_SERVER')
|
const authServer = env('FONOTEKA_MCP_AUTH_SERVER')
|
||||||
|
const apiURL = env('FONOTEKA_API_URL')
|
||||||
const auth = await loadAuthHelpers()
|
const auth = await loadAuthHelpers()
|
||||||
const tokens = await auth.refreshAuthorization(authServer, {
|
const tokens = await auth.refreshAuthorization(authServer, {
|
||||||
metadata: state.metadata,
|
metadata: state.metadata,
|
||||||
@@ -231,6 +232,27 @@ async function stageRefresh(state) {
|
|||||||
state.spentRefreshToken = state.refreshToken
|
state.spentRefreshToken = state.refreshToken
|
||||||
state.accessToken = tokens.access_token
|
state.accessToken = tokens.access_token
|
||||||
state.refreshToken = tokens.refresh_token || state.refreshToken
|
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')
|
log('refresh OK')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,19 +275,21 @@ async function stageReplay(state) {
|
|||||||
|
|
||||||
async function stageRevoke(state) {
|
async function stageRevoke(state) {
|
||||||
const apiURL = env('FONOTEKA_API_URL')
|
const apiURL = env('FONOTEKA_API_URL')
|
||||||
const listRes = await fetch(new URL('/_fonoteka/api/v1/oauth/connected-apps', apiURL), {
|
// The connected-app id was captured in stage_refresh, while the session
|
||||||
headers: { Accept: 'application/json', Authorization: `Bearer ${state.jwt}` },
|
// was still live (see that stage's comment): stage_replay's cascading
|
||||||
})
|
// RevokeLineage kill already removed it from ConnectedAppsIndex's
|
||||||
if (listRes.status !== 200) fail(`connected-apps list failed (status ${listRes.status})`)
|
// `revoked_at IS NULL` list by the time this stage runs. DELETE itself
|
||||||
const listBody = await listRes.json()
|
// does not require the row to still be live (ConnectedAppsDestroy has no
|
||||||
const app = (listBody.data || []).find((a) => a.name === 'Phase 8 final gate client')
|
// revoked_at filter on its own lookup), so this still exercises the real
|
||||||
if (!app) fail('connected-apps list does not show this gate client')
|
// endpoint end to end, idempotently, against the id the real MCP-driven
|
||||||
const delRes = await fetch(new URL(`/_fonoteka/api/v1/oauth/connected-apps/${app.id}`, apiURL), {
|
// 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',
|
method: 'DELETE',
|
||||||
headers: { Accept: 'application/json', Authorization: `Bearer ${state.jwt}` },
|
headers: { Accept: 'application/json', Authorization: `Bearer ${state.jwt}` },
|
||||||
})
|
})
|
||||||
if (delRes.status !== 200) fail(`connected-apps revoke failed (status ${delRes.status})`)
|
if (delRes.status !== 200) fail(`connected-apps revoke failed (status ${delRes.status})`)
|
||||||
state.revokedAppId = app.id
|
state.revokedAppId = state.connectedAppId
|
||||||
log('revoke OK')
|
log('revoke OK')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user