feat: add TechLog Studio error mapping and CSRF token provider

This commit is contained in:
DongHyeonka
2026-08-18 00:44:37 +09:00
parent a0e0be6522
commit 7381be1477
3 changed files with 233 additions and 0 deletions
@@ -0,0 +1,87 @@
import type { HttpExecutionOutcome } from "../../../../adapters/http/http-execution-v3.ts";
import { StudioGatewayError } from "../../application/ports/studio-gateway-error.ts";
import type { ProblemDetails } from "../../contracts/studio/contract.ts";
/** canonical studio-v1.yaml `ProblemDetails.code` enum과 1:1이다. */
export const STUDIO_ERROR_CODES = Object.freeze([
"AUTHENTICATION_REQUIRED",
"STUDIO_ACCESS_DENIED",
"DOCUMENT_NOT_FOUND",
"VERSION_CONFLICT",
"REQUEST_VALIDATION_FAILED",
"VALIDATION_FAILED",
"VALIDATION_STALE",
"PREVIEW_NOT_FOUND",
"PREVIEW_STALE",
"PREVIEW_EXPIRED",
"PUBLICATION_NOT_FOUND",
"PUBLICATION_CONFLICT",
"PUBLICATION_EVENT_NOT_FOUND",
"PUBLICATION_SNAPSHOT_NOT_FOUND",
"WARNING_ACKNOWLEDGEMENT_REQUIRED",
"IDEMPOTENCY_KEY_REUSED",
"ASSET_NOT_FOUND",
"ASSET_NOT_READY",
"ASSET_IN_USE",
"ASSET_QUARANTINED",
"PAYLOAD_TOO_LARGE",
"UNSUPPORTED_MEDIA_TYPE",
"STUDIO_UNAVAILABLE",
]) as readonly ProblemDetails["code"][];
const CODES = new Set<string>(STUDIO_ERROR_CODES);
function synthetic(
code: ProblemDetails["code"],
status: number,
detail: string,
retryable: boolean,
): StudioGatewayError {
return new StudioGatewayError({
type: `https://techlog.local/problems/${code.toLowerCase().replaceAll("_", "-")}`,
title: code,
status,
detail,
code,
retryable,
});
}
/**
* 서버가 계약 밖 코드를 보내면 도메인 코드를 지어내지 않는다. 전송 계층
* 실패와 마찬가지로 `STUDIO_UNAVAILABLE`로 접는다.
*/
export function toStudioGatewayError(
outcome: HttpExecutionOutcome<unknown, unknown>,
operationId: string,
): StudioGatewayError {
switch (outcome.kind) {
case "PROBLEM": {
const problem = outcome.problem as ProblemDetails;
if (problem && typeof problem.code === "string" && CODES.has(problem.code)) {
return new StudioGatewayError(problem);
}
return synthetic(
"STUDIO_UNAVAILABLE",
outcome.metadata.status,
`${operationId} returned an uncontracted problem code.`,
false,
);
}
case "UNAUTHENTICATED":
return synthetic("AUTHENTICATION_REQUIRED", 401, `${operationId} requires authentication.`, false);
case "FORBIDDEN":
return synthetic("STUDIO_ACCESS_DENIED", 403, `${operationId} was denied.`, false);
case "CANCELLED":
return synthetic("STUDIO_UNAVAILABLE", 499, `${operationId} was cancelled.`, false);
case "RATE_LIMITED":
return synthetic("STUDIO_UNAVAILABLE", 429, `${operationId} was rate limited.`, true);
case "TRANSPORT_FAILURE":
return synthetic("STUDIO_UNAVAILABLE", 503, `${operationId} transport failed.`, true);
case "AUTH_INTEGRATION_FAILURE":
case "CONTRACT_VIOLATION":
return synthetic("STUDIO_UNAVAILABLE", 502, `${operationId} broke its contract.`, false);
case "SUCCESS":
throw new Error(`${operationId}: success outcome is not an error`);
}
}
@@ -0,0 +1,55 @@
export type StudioSessionSnapshot = Readonly<{
csrfToken: string;
csrfHeaderName: string;
}>;
export type CsrfSessionExecutor = (
options?: Readonly<{ signal?: AbortSignal }>,
) => Promise<StudioSessionSnapshot>;
export type CsrfTokenProvider = Readonly<{
token(options?: Readonly<{ signal?: AbortSignal }>): Promise<string>;
headerName(options?: Readonly<{ signal?: AbortSignal }>): Promise<string>;
invalidate(): void;
}>;
/**
* CSRF는 전송 관심사다. UI는 토큰을 보지 않으므로 포트로 노출하지 않고
* 어댑터 내부에서 캐시한다. 동시 요청은 하나의 in-flight 조회를 공유한다.
*/
export function createCsrfTokenProvider(
deps: Readonly<{ execute: CsrfSessionExecutor }>,
): CsrfTokenProvider {
let cached: StudioSessionSnapshot | null = null;
let inFlight: Promise<StudioSessionSnapshot> | null = null;
async function resolve(
options?: Readonly<{ signal?: AbortSignal }>,
): Promise<StudioSessionSnapshot> {
if (cached) return cached;
inFlight ??= deps.execute(options).then(
(snapshot) => {
cached = snapshot;
inFlight = null;
return snapshot;
},
(error: unknown) => {
inFlight = null;
throw error;
},
);
return inFlight;
}
return Object.freeze({
async token(options) {
return (await resolve(options)).csrfToken;
},
async headerName(options) {
return (await resolve(options)).csrfHeaderName;
},
invalidate() {
cached = null;
},
});
}