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
@@ -15,6 +15,7 @@ import {
} from "../../../src/features/tech-log/adapters/http/studio-session-credentials.ts";
import type { StudioOperationExecutor } from "../../../src/features/tech-log/adapters/http/http-studio-gateway.ts";
import { TECH_LOG_STUDIO_CONTRIBUTION } from "../../../src/features/tech-log/contracts/tech-log-studio-contract-contribution.ts";
import { REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION } from "../../../src/features/reference-feature/contracts/reference-feature-contract-contribution.ts";
/**
* I1 (Task 7 fix round 1). C1 was a self-referential CSRF bootstrap cycle
@@ -61,7 +62,14 @@ function scopeSnapshot() {
* afterward.
*/
function composeStudioRuntime() {
const composed = composeContractContributions([TECH_LOG_STUDIO_CONTRIBUTION]);
// Both contributions, exactly as `installed-contract-contributions.ts`
// composes them: `contractOperations.execute` is one executor shared by
// every installed feature, so a reference-feature operation's outcome
// travels through the same code path a Studio operation's does.
const composed = composeContractContributions([
TECH_LOG_STUDIO_CONTRIBUTION,
REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION,
]);
let contractOperations: StudioOperationExecutor = Object.freeze({
async execute() {
@@ -97,7 +105,16 @@ function composeStudioRuntime() {
authContext,
techLogCsrf,
);
return outcome ?? Object.freeze({ kind: "UNAVAILABLE" as const });
// `attachStudioSessionCredentials` returns null for a non-Studio
// profile so the caller falls through to its own credential logic --
// the bearer path in the composition root, this stand-in here.
return (
outcome ??
Object.freeze({
kind: "READY" as const,
headers: Object.freeze({ authorization: "Bearer reference-token" }),
})
);
},
});
@@ -113,8 +130,14 @@ function composeStudioRuntime() {
});
// Fix round 2, item 1. Same production call as
// `bootstrap/runtime-adapters.ts`'s `contractOperations.execute` — not
// a reimplementation of it.
invalidateTechLogCsrfOnOutcome(outcome.kind, techLogCsrf);
// a reimplementation of it. Final fix wave item 8 added the auth
// profile: this executor serves every installed feature, so the call
// has to be told which one produced the outcome.
invalidateTechLogCsrfOnOutcome(
outcome.kind,
operation.frontend.authProfileId,
techLogCsrf,
);
return outcome;
},
});
@@ -254,3 +277,70 @@ test(
assert.equal(sessionCalls, 2);
},
);
/**
* Final fix wave, item 8. `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 discarded the 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.
*/
test(
"a 403 on a non-Studio operation leaves the TechLog CSRF token cached",
async () => {
let sessionCalls = 0;
server.use(
http.get(`${BASE}/api/v1/studio/session`, () => {
sessionCalls += 1;
return HttpResponse.json({
authenticated: true,
displayName: "테스터",
roles: ["editor"],
csrfToken: `csrf-token-${sessionCalls}`,
csrfHeaderName: "X-CSRF-TOKEN",
});
}),
);
const dashboardHeaders: (string | null)[] = [];
server.use(
http.get(`${BASE}/api/v1/studio/dashboard`, ({ request }) => {
dashboardHeaders.push(request.headers.get("x-csrf-token"));
return HttpResponse.json({ documentTotals: {}, workflowSections: [] });
}),
);
server.use(
http.get(`${BASE}/api/reference-resources`, () => new HttpResponse(null, { status: 403 })),
);
const { contractOperations } = composeStudioRuntime();
const first = await contractOperations.execute(
"getStudioDashboard",
{},
{ routeId: "TECH_LOG_STUDIO" },
);
assert.equal(first.kind, "SUCCESS");
assert.equal(dashboardHeaders[0], "csrf-token-1");
// Someone else's 403, on a feature that has nothing to do with the Studio
// session.
const rejected = await contractOperations.execute(
"LIST_REFERENCE_RESOURCES",
{ limit: 10 },
{ routeId: "REFERENCE_FEATURE" },
);
assert.equal(rejected.kind, "FORBIDDEN");
const second = await contractOperations.execute(
"getStudioDashboard",
{},
{ routeId: "TECH_LOG_STUDIO" },
);
assert.equal(second.kind, "SUCCESS");
assert.equal(dashboardHeaders[1], "csrf-token-1");
assert.equal(sessionCalls, 1);
},
);