From 8c71ec719a7b906dd2d8742c64cf7b802c71ec02 Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 6 Nov 2026 07:27:21 +0900 Subject: [PATCH] feat(web): add bounded shared failure reception and presentation --- web/workspace/deno.json | 2 +- web/workspace/src/lib/workspace/api/http.ts | 12 +- .../failure/FailureNotice.browser.spec.ts | 28 ++ .../workspace/failure/FailureNotice.svelte | 50 ++++ .../src/lib/workspace/failure/model.ts | 56 ++++ .../src/lib/workspace/failure/receive.test.ts | 256 ++++++++++++++++ .../src/lib/workspace/failure/receive.ts | 275 ++++++++++++++++++ 7 files changed, 675 insertions(+), 4 deletions(-) create mode 100644 web/workspace/src/lib/workspace/failure/FailureNotice.browser.spec.ts create mode 100644 web/workspace/src/lib/workspace/failure/FailureNotice.svelte create mode 100644 web/workspace/src/lib/workspace/failure/model.ts create mode 100644 web/workspace/src/lib/workspace/failure/receive.test.ts create mode 100644 web/workspace/src/lib/workspace/failure/receive.ts diff --git a/web/workspace/deno.json b/web/workspace/deno.json index caade0d29..d0d81930a 100644 --- a/web/workspace/deno.json +++ b/web/workspace/deno.json @@ -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", diff --git a/web/workspace/src/lib/workspace/api/http.ts b/web/workspace/src/lib/workspace/api/http.ts index 27773b1bc..c97221022 100644 --- a/web/workspace/src/lib/workspace/api/http.ts +++ b/web/workspace/src/lib/workspace/api/http.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 { + return JSON.parse(await readBoundedText(response, maxBytes)) as unknown; +} + +export async function readBoundedText( + response: Response, + maxBytes: number, +): Promise { 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( diff --git a/web/workspace/src/lib/workspace/failure/FailureNotice.browser.spec.ts b/web/workspace/src/lib/workspace/failure/FailureNotice.browser.spec.ts new file mode 100644 index 000000000..a279cebd4 --- /dev/null +++ b/web/workspace/src/lib/workspace/failure/FailureNotice.browser.spec.ts @@ -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. "; + 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"); +}); diff --git a/web/workspace/src/lib/workspace/failure/FailureNotice.svelte b/web/workspace/src/lib/workspace/failure/FailureNotice.svelte new file mode 100644 index 000000000..548f74e69 --- /dev/null +++ b/web/workspace/src/lib/workspace/failure/FailureNotice.svelte @@ -0,0 +1,50 @@ + + + + + diff --git a/web/workspace/src/lib/workspace/failure/model.ts b/web/workspace/src/lib/workspace/failure/model.ts new file mode 100644 index 000000000..6165e4ff4 --- /dev/null +++ b/web/workspace/src/lib/workspace/failure/model.ts @@ -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( + receipt: FailureReceipt, +): 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, + }; +} diff --git a/web/workspace/src/lib/workspace/failure/receive.test.ts b/web/workspace/src/lib/workspace/failure/receive.test.ts new file mode 100644 index 000000000..36f3204d6 --- /dev/null +++ b/web/workspace/src/lib/workspace/failure/receive.test.ts @@ -0,0 +1,256 @@ +declare const Deno: { + test(name: string, fn: () => void | Promise): 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[] + ) { + let cancelled = false; + const stream = new ReadableStream({ + 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("secret", { 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); + } +}); diff --git a/web/workspace/src/lib/workspace/failure/receive.ts b/web/workspace/src/lib/workspace/failure/receive.ts new file mode 100644 index 000000000..58c4d4e30 --- /dev/null +++ b/web/workspace/src/lib/workspace/failure/receive.ts @@ -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 = + | { kind: "known"; details: D } + | { kind: "unsupported" } + | { kind: "invalid" }; +export type DetailsDecoder = ( + envelope: FailureEnvelope, +) => DetailsDecode; + +export type FailureReceipt = + | { kind: "problem"; problem: ProblemDetails } + | { 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 { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return invalid(); + } + return value as Record; +} + +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(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 | 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() : 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(text: string, decode: DetailsDecoder): + | { kind: "known"; failure: FailureEnvelope } + | { 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(value: unknown, decode: DetailsDecoder): + | { kind: "known"; failure: FailureEnvelope } + | { 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 = { + version, + required_extensions, + domain, + reason, + category: category as FailureEnvelope["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( + text: string, + status: number, + contentType: string, + decode: DetailsDecoder, +): FailureReceipt { + 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( + response: Response, + decode: DetailsDecoder, +): Promise> { + 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); +}