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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user