feat(web): add bounded shared failure reception and presentation

This commit is contained in:
2026-11-06 07:27:21 +09:00
parent 4de62650da
commit 8c71ec719a
7 changed files with 675 additions and 4 deletions
+1 -1
View File
@@ -6,7 +6,7 @@
"dev": "deno run -A npm:vite@8.3.2 dev", "dev": "deno run -A npm:vite@8.3.2 dev",
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787", "dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787",
"check": "deno run -A npm:@sveltejs/kit@3.0.0 sync && deno run -A npm:svelte-check@4.7.6 --tsconfig ./tsconfig.json", "check": "deno run -A npm:@sveltejs/kit@3.0.0 sync && deno run -A npm:svelte-check@4.7.6 --tsconfig ./tsconfig.json",
"test": "deno test --allow-read=src,test,tests,../../docs,../../resources/config-schema --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV src/lib/workspace/feature-permissions.test.ts tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/navigation/deferred.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/auth/login.test.ts src/lib/workspace/auth/routes.test.ts src/lib/workspace/auth/hosting.test.ts src/lib/workspace/auth/hosting-enrollment.test.ts src/lib/workspace/auth/activation.test.ts src/lib/workspace/auth/hosting-accounts.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/api/ticket-case.test.ts src/lib/workspace/api/ticket-operations.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/subjektiv/routes.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/composer-invocation.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/alert-order.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/settings/workdir-checkout.test.ts tests/workdir-removal.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/workers/worker-launch.test.ts tests/runtime-workers.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts src/lib/workspace/tickets/ticket-search.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts test/config-source/adoption-api.test.ts tests/memory-api.test.ts src/lib/workspace/memory/permissions.test.ts src/lib/workspace/memory/subject-body.test.ts src/lib/workspace/memory/conversation-attempt.test.ts src/lib/workspace/memory/conversation-api.test.ts tests/subject-delete.test.ts tests/journal-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts src/lib/workspace/drive/api.test.ts src/lib/workspace/drive/controller.test.ts src/lib/workspace/drive-ui/policy.test.ts src/lib/workspace/api/ticket-parent.test.ts src/lib/workspace/tickets/ticket-parent-controller.test.ts", "test": "deno test --allow-read=src,test,tests,../../docs,../../resources/config-schema,../../crates/error-contract/fixtures --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV src/lib/workspace/feature-permissions.test.ts tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/navigation/deferred.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/auth/login.test.ts src/lib/workspace/auth/routes.test.ts src/lib/workspace/auth/hosting.test.ts src/lib/workspace/auth/hosting-enrollment.test.ts src/lib/workspace/auth/activation.test.ts src/lib/workspace/auth/hosting-accounts.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/failure/receive.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/api/ticket-case.test.ts src/lib/workspace/api/ticket-operations.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/subjektiv/routes.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/composer-invocation.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/alert-order.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/settings/workdir-checkout.test.ts tests/workdir-removal.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/workers/worker-launch.test.ts tests/runtime-workers.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts src/lib/workspace/tickets/ticket-search.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts test/config-source/adoption-api.test.ts tests/memory-api.test.ts src/lib/workspace/memory/permissions.test.ts src/lib/workspace/memory/subject-body.test.ts src/lib/workspace/memory/conversation-attempt.test.ts src/lib/workspace/memory/conversation-api.test.ts tests/subject-delete.test.ts tests/journal-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts src/lib/workspace/drive/api.test.ts src/lib/workspace/drive/controller.test.ts src/lib/workspace/drive-ui/policy.test.ts src/lib/workspace/api/ticket-parent.test.ts src/lib/workspace/tickets/ticket-parent-controller.test.ts",
"test:journal:backend-contract": "cd ../.. && mkdir -p target && cargo test -p yoi-workspace-server --lib journal_browser_web_contract -- --nocapture > target/journal-browser-contract.log && cd web/workspace && deno run --allow-read=../../target/journal-browser-contract.log test/journal-backend-contract.ts ../../target/journal-browser-contract.log", "test:journal:backend-contract": "cd ../.. && mkdir -p target && cargo test -p yoi-workspace-server --lib journal_browser_web_contract -- --nocapture > target/journal-browser-contract.log && cd web/workspace && deno run --allow-read=../../target/journal-browser-contract.log test/journal-backend-contract.ts ../../target/journal-browser-contract.log",
"test:component": "deno run -A npm:vitest@5.0.3 run --config vitest.config.ts", "test:component": "deno run -A npm:vitest@5.0.3 run --config vitest.config.ts",
"test:browser:activation": "mkdir -p ../../target/web-ux/tmp && TMPDIR=../../target/web-ux/tmp deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/activation-browser/activation-shell.browser.test.ts", "test:browser:activation": "mkdir -p ../../target/web-ux/tmp && TMPDIR=../../target/web-ux/tmp deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/activation-browser/activation-shell.browser.test.ts",
+9 -3
View File
@@ -30,7 +30,7 @@ const SKILL_API_LOAD_POLICY: JsonLoadPolicy = {
maxResponseBytes: SKILL_API_LIMITS.maxResponseBytes, maxResponseBytes: SKILL_API_LIMITS.maxResponseBytes,
}; };
class ResponseByteLimitError extends Error {} export class ResponseByteLimitError extends Error {}
function normalizePath(path: string): string { function normalizePath(path: string): string {
if (!path || path === "/") return ""; if (!path || path === "/") return "";
@@ -155,6 +155,13 @@ export async function readBoundedJson(
response: Response, response: Response,
maxBytes: number, maxBytes: number,
): Promise<unknown> { ): Promise<unknown> {
return JSON.parse(await readBoundedText(response, maxBytes)) as unknown;
}
export async function readBoundedText(
response: Response,
maxBytes: number,
): Promise<string> {
const contentLength = response.headers.get("content-length"); const contentLength = response.headers.get("content-length");
if (contentLength !== null) { if (contentLength !== null) {
const parsedLength = Number(contentLength); const parsedLength = Number(contentLength);
@@ -191,8 +198,7 @@ export async function readBoundedJson(
bytes.set(chunk, offset); bytes.set(chunk, offset);
offset += chunk.byteLength; offset += chunk.byteLength;
} }
const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
return JSON.parse(text) as unknown;
} }
async function requireJson<T>( async function requireJson<T>(
@@ -0,0 +1,28 @@
// @vitest-environment happy-dom
import { cleanup, render, screen } from "@testing-library/svelte";
import { afterEach, expect, test } from "vitest";
import FailureNotice from "./FailureNotice.svelte";
afterEach(cleanup);
test("failure cause is an alert before optional details; server text cannot create markup or network links", () => {
const explanation =
"Subject session.record is denied; Session registration and Worker creation could not proceed. <img src='/secret' onerror='alert(1)'>";
render(FailureNotice, {
failure: {
explanation,
supplements: ["Other operations remain available."],
compensation: ["Cleanup could not be confirmed."],
details: [{ label: "Support correlation", value: "correlation-1" }],
},
});
const alert = screen.getByRole("alert");
expect(alert.textContent).toContain(explanation);
expect(alert.querySelector("img")).toBeNull();
expect(alert.querySelector("a")).toBeNull();
const details = alert.querySelector("details")!;
expect(details.open).toBe(false);
expect(details.textContent).not.toContain("session.record");
expect(screen.getByText("Cleanup needs attention")).toBeTruthy();
expect(screen.getByText("Additional information")).toBeTruthy();
expect(alert.getAttribute("aria-atomic")).toBe("true");
});
@@ -0,0 +1,50 @@
<script lang="ts">
import type { FailureNoticeModel } from './model.ts';
let { failure }: { failure: FailureNoticeModel } = $props();
</script>
<div class="failure-notice" role="alert" aria-atomic="true">
<p class="failure-notice__cause"><strong>Operation failed</strong> — {failure.explanation}</p>
{#if failure.compensation.length > 0}
<div class="failure-notice__compensation">
<strong>Cleanup needs attention</strong>
<ul>{#each failure.compensation as explanation}<li>{explanation}</li>{/each}</ul>
</div>
{/if}
{#if failure.supplements.length > 0}
<div class="failure-notice__supplements">
<strong>Additional information</strong>
<ul>{#each failure.supplements as explanation}<li>{explanation}</li>{/each}</ul>
</div>
{/if}
{#if failure.details.length > 0}
<details>
<summary>Failure details</summary>
<dl>
{#each failure.details as detail}
<dt>{detail.label}</dt><dd>{detail.value}</dd>
{/each}
</dl>
</details>
{/if}
</div>
<style>
.failure-notice {
border-inline-start: 3px solid var(--danger);
padding: 0.75rem 1rem;
color: var(--text);
min-width: 0;
overflow-wrap: anywhere;
}
.failure-notice__cause { margin: 0; }
.failure-notice__cause strong { color: var(--danger); }
.failure-notice__compensation, .failure-notice__supplements, details { margin-top: 0.75rem; }
ul { padding-inline-start: 1.25rem; margin-block: 0.25rem 0; }
summary { cursor: pointer; width: fit-content; }
summary:focus-visible { outline: 2px solid currentColor; outline-offset: 3px; }
dl { margin-bottom: 0; }
dt { font-weight: 600; }
dd { margin-inline-start: 1rem; margin-bottom: 0.5rem; }
</style>
@@ -0,0 +1,56 @@
import type { FailureReceipt } from "./receive.ts";
export type FailureNoticeModel = {
explanation: string;
supplements: string[];
compensation: string[];
details: { label: string; value: string }[];
};
const FALLBACKS = {
unsupported:
"The server returned an unsupported failure contract. The operation result has not been confirmed.",
invalid:
"The server response could not be interpreted safely. The operation result has not been confirmed.",
too_large:
"The server response exceeded the safe size limit. The operation result has not been confirmed.",
transport:
"The response could not be received. The operation result has not been confirmed.",
http:
"The request did not return a supported failure response. The operation result has not been confirmed.",
} as const;
// 原因の説明/秘匿はServerの公開projectionが所有する。UIでreasonを翻訳したり
// details/sourceを走査しない。相関参照は問い合わせ用で、再送keyではない。
export function failureNotice<D>(
receipt: FailureReceipt<D>,
): FailureNoticeModel {
if (receipt.kind !== "problem") {
return {
explanation: FALLBACKS[receipt.kind],
supplements: [],
compensation: [],
details: [],
};
}
const failure = receipt.problem.yoi_failure;
const details = [
{ label: "Cause", value: `${failure.domain} / ${failure.reason}` },
...failure.context.operations.map((operation) => ({
label: "Operation / stage",
value: `${operation.operation} / ${operation.stage}`,
})),
...(failure.context.correlation
? [{ label: "Support correlation", value: failure.context.correlation }]
: []),
];
return {
explanation: failure.explanation,
// 同義wrapperをtitle/detail/explanationの三重表示にしない。
supplements: [
...new Set(failure.context.diagnostics.map((item) => item.explanation)),
].filter((text) => text !== failure.explanation),
compensation: [],
details,
};
}
@@ -0,0 +1,256 @@
declare const Deno: {
test(name: string, fn: () => void | Promise<void>): void;
readTextFileSync(path: URL): string;
};
import {
decodeFailure,
decodeProblem,
failureIdentifier,
failureRecord,
receiveProblem,
} from "./receive.ts";
import type { DetailsDecoder } from "./receive.ts";
import { failureNotice } from "./model.ts";
import { FAILURE_LIMITS } from "#lib/generated/error-contract.ts";
function equal(actual: unknown, expected: unknown) {
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
throw new Error(
`Expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`,
);
}
}
// T810の二domain例はcoreの意味検証用。実Server認可/HTTP保証の代替ではない。
const fixtureDecoder: DetailsDecoder<{ operation: string }> = (envelope) => {
if (envelope.details_version !== 1) return { kind: "unsupported" };
const details = failureRecord(envelope.details);
if (envelope.domain === "subject" && envelope.reason === "operation_denied") {
if (envelope.category !== "authorization") return { kind: "invalid" };
failureIdentifier(details.subject_ref);
return {
kind: "known",
details: { operation: failureIdentifier(details.required_operation) },
};
}
if (envelope.domain === "workdir" && envelope.reason === "in_use") {
if (envelope.category !== "conflict") return { kind: "invalid" };
failureIdentifier(details.workdir_ref);
return {
kind: "known",
details: { operation: failureIdentifier(details.operation) },
};
}
return { kind: "unsupported" };
};
const subject = JSON.parse(
Deno.readTextFileSync(
new URL(
"../../../../../../crates/error-contract/fixtures/subject-denied.json",
import.meta.url,
),
),
);
const workdir = JSON.parse(
Deno.readTextFileSync(
new URL(
"../../../../../../crates/error-contract/fixtures/workdir-in-use.json",
import.meta.url,
),
),
);
function problem(failure: unknown = subject) {
return {
type: "urn:yoi:problem:example",
title: "Example",
status: 409,
detail: (failure as typeof subject).explanation,
instance: "",
yoi_failure: failure,
};
}
function decode(value: unknown) {
return decodeProblem(
JSON.stringify(value),
409,
"application/problem+json; charset=utf-8",
fixtureDecoder,
);
}
Deno.test("T810 Rust/TS shared fixtures preserve cause, operation context and correlation", () => {
for (const fixture of [subject, workdir]) {
const received = decodeFailure(JSON.stringify(fixture), fixtureDecoder);
if (received.kind !== "known") throw new Error(received.kind);
equal(received.failure.domain, fixture.domain);
equal(received.failure.reason, fixture.reason);
equal(received.failure.context, fixture.context);
equal(
received.failure.details.operation,
fixture.details.required_operation ?? fixture.details.operation,
);
}
});
Deno.test("explanatory wording, language, RFC type and unknown extensions do not select operation results", () => {
for (
const explanation of [
"拒否されたため登録できません。",
"OutcomeUnknown is only text",
"A new explanation",
]
) {
const value = problem({ ...subject, explanation });
value.title = "Unbekannter Fehler";
value.type = "urn:future:problem";
const received = decode({
...value,
future_extension: { source: "/internal/secret", token: "secret" },
});
if (received.kind !== "problem") throw new Error(received.kind);
equal(received.problem.yoi_failure.details.operation, "session.record");
const displayed = failureNotice(received);
equal(displayed.explanation, explanation);
equal(JSON.stringify(displayed).includes("secret"), false);
equal("retryable" in displayed, false);
}
});
Deno.test("unsupported required contracts never become known outcomes or display opaque payloads", () => {
for (
const failure of [
{ ...subject, version: 2 },
{ ...subject, required_extensions: ["future"] },
{ ...subject, details_version: 2 },
{ ...subject, reason: "future" },
]
) {
const received = decode(problem(failure));
equal(received.kind, "unsupported");
equal(
JSON.stringify(failureNotice(received)).includes("session.record"),
false,
);
}
});
Deno.test("invalid identity, status mismatch, missing/malformed fields, duplicate fields and depth reject safely", () => {
for (
const value of [
{ ...problem(), status: 403 },
{ ...problem(), detail: "other" },
{ ...problem(), yoi_failure: null },
{ ...problem(), title: null },
problem({ ...subject, category: "internal" }),
problem({
...subject,
context: {
...subject.context,
operations: Array(9).fill(subject.context.operations[0]),
},
}),
problem({ ...subject, reason: "bad\nreason" }),
problem({ ...subject, explanation: "あ".repeat(700) }),
]
) equal(decode(value).kind, "invalid");
equal(
decodeProblem(
'{"status":403,' + JSON.stringify(problem()).slice(1),
409,
"application/problem+json",
fixtureDecoder,
).kind,
"invalid",
);
equal(
decode({
...problem(),
unknown: JSON.parse("[".repeat(17) + "0" + "]".repeat(17)),
}).kind,
"invalid",
);
equal(
decodeProblem(
"raw /private/path token=secret",
500,
"application/problem+json",
fixtureDecoder,
).kind,
"invalid",
);
});
Deno.test("display uses server cause once, supplementary diagnostics separately, never raw details or source chain", () => {
const received = decode(
problem({
...subject,
details: { ...subject.details, source: "secret" },
context: {
...subject.context,
diagnostics: [
{ code: "wrapper", explanation: subject.explanation },
{
code: "additional",
explanation: "Saved results remain available.",
},
],
},
}),
);
const display = failureNotice(received);
equal(display.supplements, ["Saved results remain available."]);
equal(display.explanation, subject.explanation);
equal(JSON.stringify(display).includes("secret"), false);
});
Deno.test("stream byte budgets cancel before JSON parse with and without content-length", async () => {
for (
const headers of [
{ "content-length": String(FAILURE_LIMITS.bytes + 1) },
{},
] as Record<string, string>[]
) {
let cancelled = false;
const stream = new ReadableStream<Uint8Array>({
pull(controller) {
controller.enqueue(new Uint8Array(1024));
},
cancel() {
cancelled = true;
},
});
const response = new Response(stream, {
status: 500,
headers: { ...headers, "content-type": "application/problem+json" },
});
equal((await receiveProblem(response, fixtureDecoder)).kind, "too_large");
equal(cancelled, true);
}
});
Deno.test("bounded response text, unknown MIME, broken transport and UTF8 never expose raw bodies", async () => {
equal(
(await receiveProblem(
new Response(JSON.stringify(problem()), {
status: 409,
headers: { "content-type": "application/problem+json" },
}),
fixtureDecoder,
)).kind,
"problem",
);
const cases = [
new Response("<html>secret</html>", { status: 502 }),
new Response(new Uint8Array([255]), {
status: 502,
headers: { "content-type": "application/problem+json" },
}),
new Response(
new ReadableStream({
start(controller) {
controller.error(new Error("secret transport path"));
},
}),
{ status: 502, headers: { "content-type": "application/problem+json" } },
),
];
for (const response of cases) {
const received = await receiveProblem(response, fixtureDecoder);
if (received.kind === "problem") throw new Error("unexpected problem");
equal(JSON.stringify(failureNotice(received)).includes("secret"), false);
}
});
@@ -0,0 +1,275 @@
import { FAILURE_LIMITS } from "#lib/generated/error-contract.ts";
import type {
FailureContext,
FailureEnvelope,
ProblemDetails,
} from "#lib/generated/error-contract.ts";
import {
readBoundedText,
ResponseByteLimitError,
} from "#lib/workspace/api/http.ts";
// domain固有detailsの検証は公開schemaを所有するadapterへ渡す。
// この受信結果は操作結果でも、別audienceへの再公開の許可でもない。
export type DetailsDecode<D> =
| { kind: "known"; details: D }
| { kind: "unsupported" }
| { kind: "invalid" };
export type DetailsDecoder<D> = (
envelope: FailureEnvelope<unknown>,
) => DetailsDecode<D>;
export type FailureReceipt<D> =
| { kind: "problem"; problem: ProblemDetails<D> }
| { kind: "unsupported" }
| { kind: "invalid" }
| { kind: "too_large" }
| { kind: "transport" }
| { kind: "http"; status: number };
class InvalidFailure extends Error {}
const invalid = (): never => {
throw new InvalidFailure();
};
const utf8 = new TextEncoder();
export function failureRecord(value: unknown): Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
return invalid();
}
return value as Record<string, unknown>;
}
export function failureString(
value: unknown,
max: number = FAILURE_LIMITS.explanationBytes,
): string {
if (
typeof value !== "string" || value.length === 0 || value.includes("\0") ||
utf8.encode(value).length > max
) {
return invalid();
}
return value;
}
export function failureIdentifier(value: unknown): string {
const text = failureString(value, FAILURE_LIMITS.identifierBytes);
if (/[\x00-\x1f\x7f-\x9f]/u.test(text)) return invalid();
return text;
}
function integer(value: unknown): number {
if (
typeof value !== "number" || !Number.isInteger(value) || value < 1 ||
value > 65535
) return invalid();
return value;
}
function list<T>(value: unknown, max: number, parse: (v: unknown) => T): T[] {
if (!Array.isArray(value) || value.length > max) return invalid();
return value.map(parse);
}
function optionalIdentifier(value: unknown): string | null {
return value == null ? null : failureIdentifier(value);
}
function context(value: unknown): FailureContext {
const raw = failureRecord(value);
if (typeof raw.supplementary_omitted !== "boolean") return invalid();
return {
operations: list(raw.operations, FAILURE_LIMITS.contexts, (v) => {
const op = failureRecord(v);
return {
operation: failureIdentifier(op.operation),
stage: failureIdentifier(op.stage),
operation_ref: optionalIdentifier(op.operation_ref),
};
}),
causes: list(raw.causes, FAILURE_LIMITS.causes, (v) => {
const cause = failureRecord(v);
return {
domain: failureIdentifier(cause.domain),
reason: failureIdentifier(cause.reason),
correlation: failureIdentifier(cause.correlation),
};
}),
diagnostics: list(raw.diagnostics, FAILURE_LIMITS.diagnostics, (v) => {
const diagnostic = failureRecord(v);
return {
code: failureIdentifier(diagnostic.code),
explanation: failureString(diagnostic.explanation),
};
}),
correlation: optionalIdentifier(raw.correlation),
supplementary_omitted: raw.supplementary_omitted,
};
}
// JSON.parse前に深さを制限する。重複keyはlast-winsで必須契約を上書きさせない。
function parseBounded(text: string): unknown {
if (utf8.encode(text).length > FAILURE_LIMITS.bytes) {
throw new ResponseByteLimitError();
}
const stack: (Set<string> | null)[] = [];
for (let i = 0; i < text.length; i++) {
const char = text[i];
if (char === '"') {
const start = i;
for (i++; i < text.length; i++) {
if (text[i] === "\\") i++;
else if (text[i] === '"') break;
}
let next = i + 1;
while (/\s/u.test(text[next] ?? "") && next < text.length) next++;
if (text[next] === ":") {
const key: unknown = JSON.parse(text.slice(start, i + 1));
const keys = stack.at(-1);
if (!keys || typeof key !== "string" || keys.has(key)) return invalid();
keys.add(key);
}
} else if (char === "{" || char === "[") {
stack.push(char === "{" ? new Set<string>() : null);
if (stack.length > FAILURE_LIMITS.depth) return invalid();
} else if (char === "}" || char === "]") {
if (!stack.length) return invalid();
stack.pop();
}
}
return JSON.parse(text) as unknown;
}
export function decodeFailure<D>(text: string, decode: DetailsDecoder<D>):
| { kind: "known"; failure: FailureEnvelope<D> }
| { kind: "unsupported" }
| { kind: "invalid" }
| { kind: "too_large" } {
try {
return decodeEnvelope(parseBounded(text), decode);
} catch (error) {
return {
kind: error instanceof ResponseByteLimitError ? "too_large" : "invalid",
};
}
}
function decodeEnvelope<D>(value: unknown, decode: DetailsDecoder<D>):
| { kind: "known"; failure: FailureEnvelope<D> }
| { kind: "unsupported" }
| { kind: "invalid" } {
const raw = failureRecord(value);
const version = integer(raw.version);
const details_version = integer(raw.details_version);
const required_extensions = list(
raw.required_extensions,
FAILURE_LIMITS.diagnostics,
failureIdentifier,
);
const category = failureIdentifier(raw.category);
const domain = failureIdentifier(raw.domain);
const reason = failureIdentifier(raw.reason);
const explanation = failureString(raw.explanation);
const metadata = context(raw.context);
if (version !== 1 || required_extensions.length !== 0) {
return { kind: "unsupported" };
}
if (
![
"authorization",
"invalid_input",
"conflict",
"dependency",
"internal",
"unknown",
].includes(category)
) return { kind: "unsupported" };
const envelope: FailureEnvelope<unknown> = {
version,
required_extensions,
domain,
reason,
category: category as FailureEnvelope<unknown>["category"],
details_version,
details: raw.details,
explanation,
context: metadata,
};
const result = decode(envelope);
return result.kind === "known"
? { kind: "known", failure: { ...envelope, details: result.details } }
: result;
}
function uriReference(value: unknown): string {
// URIは表示/取得先として使わない。WHATWG URLの補正も行わない。
if (
typeof value !== "string" ||
utf8.encode(value).length > FAILURE_LIMITS.explanationBytes ||
/[^\x21-\x7e]/u.test(value) || /%(?![0-9a-f]{2})/iu.test(value)
) return invalid();
return value;
}
export function decodeProblem<D>(
text: string,
status: number,
contentType: string,
decode: DetailsDecoder<D>,
): FailureReceipt<D> {
try {
if (
contentType.split(";")[0].trim().toLowerCase() !==
"application/problem+json"
) return { kind: "http", status };
const raw = failureRecord(parseBounded(text));
if (
!Number.isInteger(status) || status < 400 || status > 599 ||
raw.status !== status
) return invalid();
const type = uriReference(raw.type);
const instance = uriReference(raw.instance);
const title = failureString(raw.title);
const detail = failureString(raw.detail);
const failure = decodeEnvelope(raw.yoi_failure, decode);
if (detail !== failureRecord(raw.yoi_failure).explanation) return invalid();
if (failure.kind !== "known") return failure;
return {
kind: "problem",
problem: {
type,
instance,
status,
title,
detail,
yoi_failure: failure.failure,
},
};
} catch (error) {
return {
kind: error instanceof ResponseByteLimitError ? "too_large" : "invalid",
};
}
}
export async function receiveProblem<D>(
response: Response,
decode: DetailsDecoder<D>,
): Promise<FailureReceipt<D>> {
const contentType = response.headers.get("content-type") ?? "";
if (
contentType.split(";")[0].trim().toLowerCase() !==
"application/problem+json"
) {
await response.body?.cancel().catch(() => {});
return { kind: "http", status: response.status };
}
let text: string;
try {
text = await readBoundedText(response, FAILURE_LIMITS.bytes);
} catch (error) {
return {
kind: error instanceof ResponseByteLimitError
? "too_large"
: error instanceof TypeError
? "invalid"
: "transport",
};
}
return decodeProblem(text, response.status, contentType, decode);
}