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: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",
|
||||
"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: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",
|
||||
|
||||
@@ -30,7 +30,7 @@ const SKILL_API_LOAD_POLICY: JsonLoadPolicy = {
|
||||
maxResponseBytes: SKILL_API_LIMITS.maxResponseBytes,
|
||||
};
|
||||
|
||||
class ResponseByteLimitError extends Error {}
|
||||
export class ResponseByteLimitError extends Error {}
|
||||
|
||||
function normalizePath(path: string): string {
|
||||
if (!path || path === "/") return "";
|
||||
@@ -155,6 +155,13 @@ export async function readBoundedJson(
|
||||
response: Response,
|
||||
maxBytes: number,
|
||||
): 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");
|
||||
if (contentLength !== null) {
|
||||
const parsedLength = Number(contentLength);
|
||||
@@ -191,8 +198,7 @@ export async function readBoundedJson(
|
||||
bytes.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
||||
return JSON.parse(text) as unknown;
|
||||
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
||||
}
|
||||
|
||||
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