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:
DongHyeonka
2026-08-18 03:07:02 +09:00
co-authored by Claude Opus 5
parent c9c832c365
commit 2cab4974b7
16 changed files with 793 additions and 55 deletions
@@ -24,14 +24,17 @@ export function createAssetUploadTransport(
const doFetch = deps.fetch ?? globalThis.fetch.bind(globalThis);
const endpoint = new URL("api/v1/studio/assets", deps.baseUrl).href;
function unavailable(detail: string): StudioGatewayError {
function unavailable(
detail: string,
options?: Readonly<{ status?: number; retryable?: boolean }>,
): StudioGatewayError {
return new StudioGatewayError({
type: "https://techlog.local/problems/studio-unavailable",
title: "STUDIO_UNAVAILABLE",
status: 503,
status: options?.status ?? 503,
detail,
code: "STUDIO_UNAVAILABLE",
retryable: true,
retryable: options?.retryable ?? true,
});
}
@@ -61,13 +64,33 @@ export function createAssetUploadTransport(
credentials: "include",
});
} catch (error) {
if (signal.aborted) {
// M3 (fix round 1). Align with the JSON path's `CANCELLED` mapping
// (`toStudioGatewayError` maps it to `STUDIO_UNAVAILABLE` / 499 /
// `retryable: false`) — a caller-cancelled or deadline-exceeded
// upload is not a retryable outage, so it must not carry
// `retryable: true` the way a genuine transport failure does.
throw unavailable("Upload was aborted before it completed.", {
status: 499,
retryable: false,
});
}
throw unavailable(
error instanceof Error ? `Upload transport failed: ${error.message}` : "Upload transport failed.",
);
}
if (response.status === 201 || response.status === 200) {
return (await response.json()) as Asset;
try {
return (await response.json()) as Asset;
} catch {
// M1 (fix round 1). This port's contract is `StudioGatewayError`;
// a malformed success body must not throw a raw `SyntaxError` out
// of it.
throw unavailable(
`Upload returned status ${response.status} with a body that could not be parsed as JSON.`,
);
}
}
let problem: ProblemDetails | null;