fix: break the TechLog CSRF bootstrap cycle and close the review's fix-round-1 items
C1 (Critical): getStudioSession was stamped with the same TECH_LOG_STUDIO_SESSION auth profile as every other Studio operation, and that profile requires the CSRF header it is getStudioSession's own job to issue -- an unconditional cycle that recursed without bound in HTTP mode. Fixed with a credential-free TECH_LOG_STUDIO_BOOTSTRAP auth profile for getStudioSession alone, a synchronous re-entrancy guard in createCsrfTokenProvider as defense in depth, and a throwing stub in place of the prior `let x!: T` assertion. Added a composition-level regression test that wires the real executor, CSRF provider, and credential-attach function together and proves getStudioSession dispatches exactly once while its token reaches both a JSON operation and the multipart upload. Also: invalidate the cached CSRF token on a 401/403 from the upload path (I2), a throwing useStudioAssetGateway() accessor so Task 11 cannot silently compile a null-gateway UI (I3), and the M1-M5 minors from the review (guard a malformed success body, cover the untested error fallbacks, align aborted uploads with the JSON path's non-retryable CANCELLED mapping, derive the credential header name from one source instead of two, and correct the adapter review doc's operation count). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
c9c832c365
commit
2cab4974b7
@@ -28,6 +28,7 @@ import { createBrowserStorageAdapter } from "../adapters/storage/browser-storage
|
||||
import { createBrowserMutationIntentFactory } from "../adapters/platform/browser-mutation-intent-factory.ts";
|
||||
import { createTelemetryAdapter } from "../adapters/telemetry/best-effort-telemetry.ts";
|
||||
import { createCsrfTokenProvider } from "../features/tech-log/adapters/http/studio-session-csrf.ts";
|
||||
import { attachStudioSessionCredentials } from "../features/tech-log/adapters/http/studio-session-credentials.ts";
|
||||
import type { StudioOperationExecutor as TechLogStudioOperationExecutor } from "../features/tech-log/adapters/http/http-studio-gateway.ts";
|
||||
import type { AuthSessionPort } from "../application/ports/auth-session-port.ts";
|
||||
import type { ReleaseInfo } from "../application/ports/release-info-port.ts";
|
||||
@@ -426,7 +427,8 @@ export async function createRuntimeAdapters(
|
||||
* §7.7 / Task 7. There is exactly one CSRF provider per Studio session, and
|
||||
* it is owned by the composition root — not by the TechLog feature input —
|
||||
* because two collaborators share it: `attachCredentials` below (the only
|
||||
* path by which `x-csrf-token` reaches the 18 JSON operations) and the
|
||||
* path by which the CSRF header reaches the 17 JSON operations that
|
||||
* require it — every operation except `getStudioSession` itself) and the
|
||||
* multipart upload transport, which bypasses the platform executor
|
||||
* entirely and must set the header itself. If each built its own provider,
|
||||
* one Studio session would hold two different tokens.
|
||||
@@ -434,14 +436,37 @@ export async function createRuntimeAdapters(
|
||||
* `execute` calls `getStudioSession` through `contractOperations`, which is
|
||||
* declared further below — a real ordering hazard, since `attachCredentials`
|
||||
* (needed to build `contractHttp`, needed to build `contractOperations`)
|
||||
* needs this provider first. `getStudioSession` is a SAFE operation and
|
||||
* needs no CSRF itself, so there is no true cycle: the callback below only
|
||||
* *runs* once the whole runtime is composed and a Studio request is made,
|
||||
* by which point `contractOperations` is assigned. `let` plus a forward
|
||||
* reference inside this closure defers the read to call time instead of
|
||||
* declaration time.
|
||||
* needs this provider first.
|
||||
*
|
||||
* Fix round 1 (C1). The original comment here claimed "`getStudioSession`
|
||||
* is a SAFE operation and needs no CSRF, so there is no true cycle" — that
|
||||
* was wrong. `retrySemantics: "SAFE"` and the auth profile's
|
||||
* `requiredCredentialHeaders` are orthogonal; every operation stamped with
|
||||
* `TECH_LOG_STUDIO_SESSION` (originally including `getStudioSession`
|
||||
* itself) required the CSRF header, so fetching the token required already
|
||||
* having it — an unconditional, deterministic cycle, reproduced against the
|
||||
* real composition root as unbounded recursion
|
||||
* (`RangeError: Maximum call stack size exceeded`), not a first-request
|
||||
* race. The real fix is `getStudioSession` now using the credential-free
|
||||
* `TECH_LOG_STUDIO_BOOTSTRAP` profile (`rest-profiles.ts`,
|
||||
* `tech-log-studio-contract-contribution.ts`), so its own
|
||||
* `attachCredentials` call never reaches this provider. `let` plus a
|
||||
* forward reference inside this closure still defers the read of
|
||||
* `contractOperations` to call time instead of declaration time — that part
|
||||
* of the original design was fine — but the binding below is a throwing
|
||||
* stub rather than a bare `let x!: T` definite-assignment assertion: `!`
|
||||
* silently accepts `undefined` forever if a future refactor inserts an
|
||||
* `await` between this declaration and the real assignment, where a stub
|
||||
* fails loudly instead.
|
||||
*/
|
||||
let contractOperations!: TechLogStudioOperationExecutor;
|
||||
let contractOperations: TechLogStudioOperationExecutor = Object.freeze({
|
||||
async execute() {
|
||||
throw new Error(
|
||||
"contractOperations used before assignment in createRuntimeAdapters — " +
|
||||
"construction order regressed.",
|
||||
);
|
||||
},
|
||||
});
|
||||
const techLogCsrf = createCsrfTokenProvider({
|
||||
async execute(options) {
|
||||
const outcome = await contractOperations.execute(
|
||||
@@ -475,25 +500,16 @@ export async function createRuntimeAdapters(
|
||||
if (serverStateScope.getPhase() !== "READY") {
|
||||
return Object.freeze({ kind: "SCOPE_FENCED" as const });
|
||||
}
|
||||
if (operation.authProfileId === "TECH_LOG_STUDIO_SESSION") {
|
||||
// Studio authenticates with a session cookie and carries only the
|
||||
// CSRF token as a proof header. Read operations use this profile too
|
||||
// — the server does not require the header for them — so a failure
|
||||
// to fetch the token fails only this one request (`UNAVAILABLE`) and
|
||||
// is not promoted to a session-level `UNAUTHENTICATED`, which would
|
||||
// trigger a global re-authentication flow the session itself did not
|
||||
// warrant.
|
||||
try {
|
||||
return Object.freeze({
|
||||
kind: "READY" as const,
|
||||
headers: Object.freeze({
|
||||
"x-csrf-token": await techLogCsrf.token({ signal: authContext.signal }),
|
||||
}),
|
||||
});
|
||||
} catch {
|
||||
return Object.freeze({ kind: "UNAVAILABLE" as const });
|
||||
}
|
||||
}
|
||||
// Fix round 1 (C1, M4). Extracted to `studio-session-credentials.ts` so
|
||||
// this exact code — not a re-implementation of it — is what
|
||||
// `tests/features/tech-log/studio-csrf-composition.test.ts` exercises
|
||||
// against the real executor and provider.
|
||||
const studioOutcome = await attachStudioSessionCredentials(
|
||||
operation.authProfileId,
|
||||
authContext,
|
||||
techLogCsrf,
|
||||
);
|
||||
if (studioOutcome) return studioOutcome;
|
||||
const state = authSession.getState();
|
||||
if (state === "integration-failed") {
|
||||
return Object.freeze({ kind: "UNAVAILABLE" as const });
|
||||
|
||||
Reference in New Issue
Block a user