feat(web): add bounded shared failure reception and presentation
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user