fix: give each API surface its own error-code enum

The public site answered every screen with the terminal error surface. Three
defects stacked, and each one hid the next.

The first refused the request outright: `attachCredentials` asks the Studio
helper, which returns null for a profile it does not own, and the fallback
below read the session and rejected anything not authenticated. Public reads
declare the ANONYMOUS profile, so a signed-out visitor — the public site's
entire audience — never got a request out of the browser. An anonymous profile
carries no credentials by definition and must never consult the session.

With requests flowing, the second surfaced: `envelopeError()` pinned
`ApiError.code` to the Studio enum and all three surfaces shared it. Public and
Management each declare their own enum in their own contract, so every error
they returned failed validation and arrived as a CONTRACT_VIOLATION — an
unclassifiable transport fault — rather than the domain error it was. A strict
enum checked against the wrong surface's contract still looks strict, which is
why no gate caught it. Each surface now passes its own contract's codes.

The third was the not-found path: it read `status` and `code` off the problem
body, but the envelope has no `status` and names the code for its surface
(PUBLIC_RESOURCE_NOT_FOUND, not NOT_FOUND). The HTTP status from the transport
is the authoritative signal and the only one that holds across both shapes.

The regression test composes the real runtime adapters against the deployed
backend's actual 404 body. Neither the gateway tests (which stub the executor)
nor the screen tests (which stub the gateway) cover this seam, and the whole
outage lived in it.

Two page-level fixes came out of the same investigation: the profile page asked
for two project slugs that only ever existed in the static fixture, and the
index pages held their fixed header copy behind a request that had nothing to
do with it. Headers now paint immediately; only the sections that are actually
waiting show a fallback, and an empty list says so instead of rendering blank.
This commit is contained in:
DongHyeonka
2026-08-21 01:14:20 +09:00
parent 03986da3d6
commit 760071156d
11 changed files with 270 additions and 43 deletions
@@ -38,8 +38,19 @@ export function createHttpManagementGateway(
const outcome = await deps.operations.execute(operationId, input, { routeId: ROUTE_ID }); const outcome = await deps.operations.execute(operationId, input, { routeId: ROUTE_ID });
if (outcome.kind === "SUCCESS") return outcome.value as T; if (outcome.kind === "SUCCESS") return outcome.value as T;
if (outcome.kind === "PROBLEM") { if (outcome.kind === "PROBLEM") {
const problem = outcome.problem as Readonly<{ code?: string }> | null; // The management surface answers with the ADR-006 envelope, which nests
throw new ManagementGatewayError(operationId, problem?.code ?? "PROBLEM"); // the code under `error` — reading `problem.code` found nothing and every
// failure surfaced as the literal "PROBLEM", matching no i18n key.
const body = outcome.problem as
| Readonly<{ code?: unknown; error?: Readonly<{ code?: unknown }> }>
| null;
const code =
typeof body?.code === "string"
? body.code
: typeof body?.error?.code === "string"
? body.error.code
: "PROBLEM";
throw new ManagementGatewayError(operationId, code);
} }
throw new ManagementGatewayError(operationId, outcome.kind); throw new ManagementGatewayError(operationId, outcome.kind);
} }
@@ -46,6 +46,20 @@ function gatewayError(operationId: string, detail: string): PublicContentGateway
return error; return error;
} }
/**
* Reads the backend's error code out of either response shape — RFC7807 puts it
* at the top level, the ADR-006 envelope nests it under `error`. Without this
* every failure was reported as the literal "PROBLEM", which told a reader
* nothing and matched no i18n key.
*/
function problemCode(problem: unknown): string {
if (!problem || typeof problem !== "object") return "PROBLEM";
const body = problem as Readonly<{ code?: unknown; error?: Readonly<{ code?: unknown }> }>;
if (typeof body.code === "string") return body.code;
if (typeof body.error?.code === "string") return body.error.code;
return "PROBLEM";
}
export function createHttpPublicContentGateway( export function createHttpPublicContentGateway(
deps: Readonly<{ operations: StudioOperationExecutor }>, deps: Readonly<{ operations: StudioOperationExecutor }>,
): PublicContentQueries { ): PublicContentQueries {
@@ -53,9 +67,15 @@ export function createHttpPublicContentGateway(
const outcome = await deps.operations.execute(operationId, input, { routeId: ROUTE_ID }); const outcome = await deps.operations.execute(operationId, input, { routeId: ROUTE_ID });
if (outcome.kind === "SUCCESS") return outcome.value as T; if (outcome.kind === "SUCCESS") return outcome.value as T;
if (outcome.kind === "PROBLEM") { if (outcome.kind === "PROBLEM") {
const problem = outcome.problem as Readonly<{ status?: number; code?: string }> | null; // The HTTP status is the authoritative signal, and the only one that
if (problem?.status === 404 || problem?.code === "NOT_FOUND") return NOT_FOUND; // holds across both shapes this surface answers with. RFC7807 carries
throw gatewayError(operationId, problem?.code ?? "PROBLEM"); // `status` in the body; the ADR-006 envelope does not — it puts the
// reason in `error.category` and a backend-specific string in
// `error.code` (PUBLIC_RESOURCE_NOT_FOUND, not NOT_FOUND). The old
// body-only check matched neither, so every 404 raised the terminal
// error surface on a page whose real state was "this does not exist".
if (outcome.metadata.status === 404) return NOT_FOUND;
throw gatewayError(operationId, problemCode(outcome.problem));
} }
throw gatewayError(operationId, outcome.kind); throw gatewayError(operationId, outcome.kind);
} }
@@ -18,7 +18,23 @@ type QueryEntries = readonly (readonly [string, string])[];
const NO_PATH: PathValues = Object.freeze({}); const NO_PATH: PathValues = Object.freeze({});
const NO_QUERY = Object.freeze([]) as QueryEntries; const NO_QUERY = Object.freeze([]) as QueryEntries;
const PROBLEM = envelopeError(); /** studio-management-v1.yaml `ApiError.code` enum과 1:1이다. */
const MANAGEMENT_ERROR_CODES = Object.freeze([
"AUTHENTICATION_REQUIRED",
"STUDIO_ACCESS_DENIED",
"REQUEST_VALIDATION_FAILED",
"VERSION_CONFLICT",
"TOPIC_NOT_FOUND",
"TOPIC_NAME_TAKEN",
"TOPIC_SLUG_TAKEN",
"TOPIC_IN_USE",
"PROJECT_NOT_FOUND",
"PROJECT_SLUG_TAKEN",
"PROJECT_IN_USE",
"INTERNAL_ERROR",
]);
const PROBLEM = envelopeError(MANAGEMENT_ERROR_CODES, "ManagementErrorEnvelope");
/** Studio 쪽과 같은 판정이다: 4xx 도메인 거절은 적용되지 않았음이 확정, 5xx·네트워크는 불확정. */ /** Studio 쪽과 같은 판정이다: 4xx 도메인 거절은 적용되지 않았음이 확정, 5xx·네트워크는 불확정. */
const COMMAND_EFFECT: CommandEffectDescriptor<ProblemDetails> = Object.freeze({ const COMMAND_EFFECT: CommandEffectDescriptor<ProblemDetails> = Object.freeze({
@@ -11,7 +11,18 @@ type QueryEntries = readonly (readonly [string, string])[];
const NO_PATH: PathValues = Object.freeze({}); const NO_PATH: PathValues = Object.freeze({});
const NO_QUERY = Object.freeze([]) as QueryEntries; const NO_QUERY = Object.freeze([]) as QueryEntries;
const PROBLEM = envelopeError(); /**
* public-v1.yaml `ApiError.code` enum과 1:1이다. `INTERNAL_ERROR` 는 이 기능이
* 아니라 스켈레톤 공통 처리기가 내는 코드이고, 계약이 그것까지 열거하므로 여기도
* 열거한다 — 빠지면 500 응답이 계약 위반으로 분류된다.
*/
const PUBLIC_ERROR_CODES = Object.freeze([
"PUBLIC_REQUEST_INVALID",
"PUBLIC_RESOURCE_NOT_FOUND",
"INTERNAL_ERROR",
]);
const PROBLEM = envelopeError(PUBLIC_ERROR_CODES, "PublicErrorEnvelope");
function queryOf(input: Readonly<Record<string, unknown>>): QueryEntries { function queryOf(input: Readonly<Record<string, unknown>>): QueryEntries {
const entries: (readonly [string, string])[] = []; const entries: (readonly [string, string])[] = [];
@@ -77,9 +77,10 @@ export const envelopeData = <T>(schemaId: string): RuntimeValidator<T> =>
.transform((envelope) => envelope.data as T) as unknown as z.ZodType<T>, .transform((envelope) => envelope.data as T) as unknown as z.ZodType<T>,
); );
const apiErrorSchema = z const apiErrorSchema = (codes: readonly string[]) =>
z
.object({ .object({
code: z.enum(STUDIO_ERROR_CODES as unknown as [string, ...string[]]), code: z.enum(codes as unknown as [string, ...string[]]),
category: z.string().min(1), category: z.string().min(1),
message: z.string().min(1).max(5000), message: z.string().min(1).max(5000),
retryable: z.boolean(), retryable: z.boolean(),
@@ -100,11 +101,27 @@ const apiErrorSchema = z
* 않는다. 지금 그 필드들을 평평하게 읽는 소비자가 없고, 분해는 실제 소비자가 * 않는다. 지금 그 필드들을 평평하게 읽는 소비자가 없고, 분해는 실제 소비자가
* 생겼을 때 추가할 투기적 작업이다. * 생겼을 때 추가할 투기적 작업이다.
*/ */
export const envelopeError = (): RuntimeValidator<ProblemDetails> => /**
* The code enum is per-surface, and getting that wrong took the public site
* down. Public, Studio and Management each declare their own `ApiError.code`
* enum in their own contract; this validator was pinned to the Studio list and
* shared by all three, so every public error — `PUBLIC_RESOURCE_NOT_FOUND`
* first among them — failed the enum, became a CONTRACT_VIOLATION rather than a
* PROBLEM, and reached the screens as an unclassifiable failure. A visitor
* following a link to a project that no longer exists got the terminal error
* surface instead of a not-found page, and no gate noticed, because a strict
* enum checked against the wrong surface's contract still looks strict.
*
* Each caller now passes the enum from its own contract.
*/
export const envelopeError = (
codes: readonly string[] = STUDIO_ERROR_CODES as readonly string[],
schemaId = "StudioErrorEnvelope",
): RuntimeValidator<ProblemDetails> =>
zodValidator<ProblemDetails>( zodValidator<ProblemDetails>(
"StudioErrorEnvelope", schemaId,
z z
.object({ success: z.literal(false), error: apiErrorSchema, meta: metaSchema }) .object({ success: z.literal(false), error: apiErrorSchema(codes), meta: metaSchema })
.loose() .loose()
.transform((envelope) => ({ .transform((envelope) => ({
type: `https://techlog.local/problems/${envelope.error.code.toLowerCase().replaceAll("_", "-")}`, type: `https://techlog.local/problems/${envelope.error.code.toLowerCase().replaceAll("_", "-")}`,
@@ -23,19 +23,29 @@ const principles = [
}, },
] as const; ] as const;
const currentProjectSlugs = ["backend-skeleton", "auth-lab"] as const;
const topics = ["Backend Architecture", "JPA", "Authentication", "Redis"] as const; const topics = ["Backend Architecture", "JPA", "Authentication", "Redis"] as const;
export function ProfilePage() { export function ProfilePage() {
// The two project slugs this named were the static fixture's, and they exist
// in no real deployment — the page asked the backend for them, took two 404s,
// and rendered nothing but an error. "Current projects" means the published
// ones, so read them from the catalogue the projects index already reads.
const view = usePublicContent(["tech-log", "profile"], async (queries) => { const view = usePublicContent(["tech-log", "profile"], async (queries) => {
const entries = (await queries.searchPublicContent("")).filter(
(item) => item.contentType === "PROJECT",
);
const resolved = await Promise.all( const resolved = await Promise.all(
currentProjectSlugs.map((slug) => queries.getProject(slug)), entries.map((item) => queries.getProject(item.path.replace("/projects/", ""))),
); );
return { currentProjects: resolved.filter((project) => project !== undefined) }; return { currentProjects: resolved.filter((project) => project !== undefined) };
}); });
if (!view.ready) return view.fallback;
const { currentProjects } = view.data; // Only the project list comes from the network. Returning the page-wide
// fallback here — as every public screen did — held the operator's name, the
// principles, and the topics behind a request that has nothing to do with
// them, so a visitor saw a skeleton, then possibly an error, where the page
// could have been readable the whole time. The markup below is unchanged;
// the fallback now sits in the one section that is actually waiting.
return ( return (
<main id="main-content" className="shell profile-page"> <main id="main-content" className="shell profile-page">
<header className="profile-header"> <header className="profile-header">
@@ -65,8 +75,13 @@ export function ProfilePage() {
<p className="section-kicker">Current</p> <p className="section-kicker">Current</p>
<h2 id="profile-projects-title"> </h2> <h2 id="profile-projects-title"> </h2>
</div> </div>
{!view.ready ? (
view.fallback
) : view.data.currentProjects.length === 0 ? (
<p className="public-empty-note"> .</p>
) : (
<ul> <ul>
{currentProjects.map((project) => ( {view.data.currentProjects.map((project) => (
<li key={project.slug}> <li key={project.slug}>
<Link to={`/projects/${project.slug}`}> <Link to={`/projects/${project.slug}`}>
<div> <div>
@@ -79,6 +94,7 @@ export function ProfilePage() {
</li> </li>
))} ))}
</ul> </ul>
)}
</section> </section>
<section className="profile-topics" aria-labelledby="profile-topics-title"> <section className="profile-topics" aria-labelledby="profile-topics-title">
<p className="section-kicker">Topics</p> <p className="section-kicker">Topics</p>
@@ -12,9 +12,8 @@ export function ProjectsPage() {
); );
return { projects: resolved.filter((project) => project !== undefined) }; return { projects: resolved.filter((project) => project !== undefined) };
}); });
if (!view.ready) return view.fallback;
const { projects } = view.data; // Header first: it is fixed copy and owes the network nothing.
return ( return (
<main <main
id="main-content" id="main-content"
@@ -28,8 +27,13 @@ export function ProjectsPage() {
. .
</p> </p>
</header> </header>
{!view.ready ? (
view.fallback
) : view.data.projects.length === 0 ? (
<p className="public-empty-note"> .</p>
) : (
<ol className="project-index-list"> <ol className="project-index-list">
{projects.map((project, index) => ( {view.data.projects.map((project, index) => (
<li key={project.slug}> <li key={project.slug}>
<Link to={`/projects/${project.slug}`}> <Link to={`/projects/${project.slug}`}>
<span>{String(index + 1).padStart(2, "0")}</span> <span>{String(index + 1).padStart(2, "0")}</span>
@@ -55,6 +59,7 @@ export function ProjectsPage() {
</li> </li>
))} ))}
</ol> </ol>
)}
</main> </main>
); );
} }
@@ -12,9 +12,10 @@ export function ReleasesPage() {
); );
return { releases: resolved.filter((release) => release !== undefined) }; return { releases: resolved.filter((release) => release !== undefined) };
}); });
if (!view.ready) return view.fallback;
const { releases } = view.data; // The header is fixed copy; only the list is a request. Returning the
// page-wide fallback here left a visitor with a skeleton — or an error —
// where the page's own explanation of itself could already be on screen.
return ( return (
<main <main
id="main-content" id="main-content"
@@ -28,8 +29,13 @@ export function ReleasesPage() {
. .
</p> </p>
</header> </header>
{!view.ready ? (
view.fallback
) : view.data.releases.length === 0 ? (
<p className="public-empty-note"> .</p>
) : (
<ol className="release-index-list"> <ol className="release-index-list">
{releases.map((release) => ( {view.data.releases.map((release) => (
<li key={release.version}> <li key={release.version}>
<Link to={release.path}> <Link to={release.path}>
<div> <div>
@@ -47,6 +53,7 @@ export function ReleasesPage() {
</li> </li>
))} ))}
</ol> </ol>
)}
</main> </main>
); );
} }
@@ -1958,6 +1958,11 @@ dialog::backdrop {
} }
} }
/* Shown where a public list would be, when the site has nothing published yet.
Takes the same top border and muted tone the lists it replaces already use,
so an empty page reads as a page rather than as a failure. */
.public-empty-note { margin: 70px 0 0; padding: 44px 0; border-top: 1px solid var(--line-strong); color: var(--muted); font-size: 14px; }
.release-index-list { margin: 70px 0 0; padding: 0; border-top: 1px solid var(--line-strong); list-style: none; } .release-index-list { margin: 70px 0 0; padding: 0; border-top: 1px solid var(--line-strong); list-style: none; }
.release-index-list > li { border-bottom: 1px solid var(--line); } .release-index-list > li { border-bottom: 1px solid var(--line); }
.release-index-list a { display: grid; min-height: 150px; grid-template-columns: 150px minmax(0, 1fr) 24px; gap: 28px; padding: 30px 0 33px; } .release-index-list a { display: grid; min-height: 150px; grid-template-columns: 150px minmax(0, 1fr) 24px; gap: 28px; padding: 30px 0 33px; }
@@ -1,6 +1,6 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import { render, screen, within } from "@testing-library/react"; import { render, screen, waitFor, within } from "@testing-library/react";
import { type ComponentType } from "react"; import { type ComponentType } from "react";
import { import {
createMemoryRouter, createMemoryRouter,
@@ -97,7 +97,14 @@ async function renderPublicRoute(routeId: PublicIndexRouteId, initialEntry: stri
)); ));
// 포트가 async 가 되면서 첫 페인트에는 데이터가 없다. 화면이 정착한 뒤 // 포트가 async 가 되면서 첫 페인트에는 데이터가 없다. 화면이 정착한 뒤
// 단언하도록 여기서 한 번 기다린다 — 각 테스트에 흩어 놓으면 빠뜨린 곳이 생긴다. // 단언하도록 여기서 한 번 기다린다 — 각 테스트에 흩어 놓으면 빠뜨린 곳이 생긴다.
//
// `main` 이 있다는 것만으로는 더 이상 정착이 아니다. index 화면들은 고정 카피인
// 헤더를 네트워크와 무관하게 즉시 그리므로 (그게 목적이다), `main` 은 데이터가
// 오기 전에 존재한다. 기다려야 하는 것은 대기 중이던 구역이 대기를 멈추는 것이다.
await screen.findByRole("main"); await screen.findByRole("main");
await waitFor(() => {
expect(document.querySelector('[aria-busy="true"]')).toBeNull();
});
return { ...view, router }; return { ...view, router };
} }
+112
View File
@@ -0,0 +1,112 @@
import { describe, expect, it, vi } from "vitest";
import { createRuntimeAdapters } from "../../src/bootstrap/runtime-adapters.ts";
import { TECH_LOG_FEATURE_ID } from "../../src/features/tech-log/application/tech-log-feature-input.ts";
type Runtime = Parameters<typeof createRuntimeAdapters>[0]["runtime"];
type Release = Parameters<typeof createRuntimeAdapters>[0]["release"];
/**
* The public site is read by people who are not signed in — that is the whole
* point of it — and the composition root is the only place that decides whether
* an operation is allowed to leave the browser.
*
* Two defects met there and took the entire public surface down in production.
* `attachCredentials` asked the Studio credential helper first, which returns
* null for a profile it does not own; the fallback below it read the session
* and refused anything not authenticated, so every ANONYMOUS operation resolved
* to UNAUTHENTICATED with no request ever dispatched. And the public gateway
* detected "not found" by reading `status`/`code` off the problem body, fields
* the ADR-006 envelope does not have — so a 404 raised the terminal-error
* surface instead of the page's own not-found state.
*
* Neither was caught by the gateway tests (which stub the executor) or the
* screen tests (which stub the gateway). This is the seam between them.
*/
const runtime = {
config: {
APP_ENV: "local",
API_BASE_URL: "http://api.test",
TELEMETRY_ENABLED: false,
AUTH_MODE: "demo",
REQUEST_TIMEOUT_MS: 5000,
MAX_RETRY_ATTEMPTS: 0,
RELEASE_MANIFEST_URL: "/release-manifest.json",
CONFIG_SCHEMA_VERSION: "2.0",
CAPABILITY_OVERRIDES: {
REALTIME: "DEFAULT",
WEB_WORKER: "DEFAULT",
SERVICE_WORKER: "DEFAULT",
OFFLINE_COMMANDS: "DEFAULT",
},
FEATURE_OVERRIDES: {},
TECH_LOG_STUDIO_SOURCE: "HTTP",
TECH_LOG_PUBLIC_SOURCE: "HTTP",
},
configSchema: "V2",
build: {
buildId: "build-a",
commitSha: "abc123",
routerBasePath: "/",
runtimeConfigUrl: "/config.json",
},
validationDurationMs: 0,
} as unknown as Runtime;
const release = {
releaseId: "release-a",
manifestUrl: "/release-manifest.json",
} as unknown as Release;
// Copied verbatim from the deployed backend's answer for a slug that does not
// exist. The code is PUBLIC_RESOURCE_NOT_FOUND, not NOT_FOUND, and there is no
// top-level `status` — the two facts the old detection assumed away.
const NOT_FOUND_BODY = JSON.stringify({
success: false,
data: null,
error: {
code: "PUBLIC_RESOURCE_NOT_FOUND",
category: "NOT_FOUND",
message: "요청한 자료를 찾을 수 없습니다",
retryable: false,
details: null,
},
meta: {
requestId: "dd674218-a0c0-4a1b-a3d3-d0b8563783a2",
traceId: "78b4fdd3dba6436281e9d4ac884ecd72",
correlationId: "26c37920-7df6-4a06-afe1-61b6f1b094ae",
page: null,
},
});
describe("public reads by a signed-out visitor", () => {
it("dispatches the request and reads a 404 envelope as absent", async () => {
const fetcher = vi.fn(
async () =>
new Response(NOT_FOUND_BODY, {
status: 404,
headers: { "content-type": "application/json" },
}),
);
const adapters = await createRuntimeAdapters({
runtime,
release,
host: {},
fetcher: fetcher as unknown as typeof fetch,
});
// The precondition that made the bug: nobody is signed in.
expect(adapters.outputPorts.session.getState()).toBe("unauthenticated");
const { publicContent } = adapters.featureInputs[TECH_LOG_FEATURE_ID];
await expect(publicContent.getRecord("CASE", "missing-slug")).resolves.toBeUndefined();
// The load-bearing assertion. Before the fix this was 0: the operation was
// refused as UNAUTHENTICATED inside the composition root.
expect(fetcher).toHaveBeenCalled();
adapters.infrastructure.dispose();
});
});