fix: scope the TechLog CSRF invalidation to Studio operations

`contractOperations.execute` is the single executor every installed feature
dispatches through, and it called `invalidateTechLogCsrfOnOutcome` for every
operation. A 403 on an unrelated reference-feature request therefore threw
away a perfectly good TechLog CSRF token, forcing an avoidable
`getStudioSession` round trip on the next Studio operation -- and, when the
session endpoint is itself unhealthy, turning someone else's authorization
failure into a Studio outage.

`invalidateTechLogCsrfOnOutcome` now takes the operation's auth profile and
acts only on the two TechLog Studio profiles. Required, not optional, so the
scoping cannot be dropped again by omission, and the predicate lives in the
feature file: `bootstrap/runtime-adapters.ts` is template-synced and its
change is the one added argument.

The composition test now installs both contributions the way
`installed-contract-contributions.ts` does, and asserts a reference-feature
403 leaves the cached token alone. Reverting the scope check fails exactly
that test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-08-18 09:14:42 +09:00
co-authored by Claude Opus 5
parent 65f8528ccc
commit a889cb5c00
3 changed files with 121 additions and 5 deletions
+8 -1
View File
@@ -585,7 +585,14 @@ export async function createRuntimeAdapters(
// operation invalidates a token shared across all of them. Same call
// the composition test drives
// (`tests/features/tech-log/studio-csrf-composition.test.ts`).
invalidateTechLogCsrfOnOutcome(outcome.kind, techLogCsrf);
// The auth profile scopes it to Studio operations: this executor serves
// every installed feature, so without it an unrelated 403 discarded the
// TechLog token.
invalidateTechLogCsrfOnOutcome(
outcome.kind,
operation.frontend.authProfileId,
techLogCsrf,
);
return outcome;
},
});
@@ -70,6 +70,14 @@ export function assertExactlyOneTechLogStudioBootstrapOperation(
}
}
/** Every operation whose rejection can say something about the Studio token. */
function isTechLogStudioAuthProfileId(value: string): value is TechLogStudioAuthProfileId {
return (
value === TECH_LOG_STUDIO_BOOTSTRAP_AUTH_PROFILE_ID ||
value === TECH_LOG_STUDIO_SESSION_AUTH_PROFILE_ID
);
}
/**
* Fix round 2, item 1. A CSRF rejection on a JSON operation can arrive as
* either `UNAUTHENTICATED` (the Studio session itself expired) or
@@ -81,11 +89,22 @@ export function assertExactlyOneTechLogStudioBootstrapOperation(
* (`http-studio-asset-gateway.ts`); this keeps the JSON path in agreement.
* Only `UNAUTHENTICATED` also tears down the auth session itself — that stays
* the caller's responsibility, not this function's.
*
* Final fix wave, item 8. `contractOperations.execute` is one executor shared
* by every installed feature, so it was calling this for reference-feature
* operations too: an unrelated 403 discarded a perfectly good Studio token
* and forced an avoidable `getStudioSession` round trip — or, when the
* session endpoint is itself unhealthy, turned someone else's authorization
* failure into a Studio outage. `authProfileId` is required rather than
* optional so the scoping cannot be dropped again by omission, and the
* decision lives here rather than at the (template-synced) call site.
*/
export function invalidateTechLogCsrfOnOutcome(
outcomeKind: string,
authProfileId: string,
csrf: CsrfTokenProvider,
): void {
if (!isTechLogStudioAuthProfileId(authProfileId)) return;
if (outcomeKind === "UNAUTHENTICATED" || outcomeKind === "FORBIDDEN") {
csrf.invalidate();
}