From b049a4ee80442417c9e4225df44ef6a3fd1b583d Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 1 Sep 2026 16:58:54 +0200 Subject: [PATCH 1/2] fix(domain): register the attribute-mapping forbidden error as anticipated `IngestAttributeMappingForbiddenError` was added with the admin gate but never regenerated into `ANTICIPATED_ERROR_IDENTIFIER_LIST`, so the drift test that compares the checked-in list against reflection has been failing on main since. Regenerated output only. Without it the error counts as a 5xx-style failure in the SDK's span status, so an ordinary 403 would show up as an error event. --- packages/domain/src/generated/anticipated-error-identifiers.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/domain/src/generated/anticipated-error-identifiers.ts b/packages/domain/src/generated/anticipated-error-identifiers.ts index 32592a5fa..add5dd953 100644 --- a/packages/domain/src/generated/anticipated-error-identifiers.ts +++ b/packages/domain/src/generated/anticipated-error-identifiers.ts @@ -47,6 +47,7 @@ export const ANTICIPATED_ERROR_IDENTIFIER_LIST: ReadonlyArray = [ "@maple/http/errors/ErrorIssuePullRequestNotFoundError", "@maple/http/errors/ErrorIssueTransitionError", "@maple/http/errors/ErrorValidationError", + "@maple/http/errors/IngestAttributeMappingForbiddenError", "@maple/http/errors/IngestAttributeMappingNotFoundError", "@maple/http/errors/IngestAttributeMappingValidationError", "@maple/http/errors/IntegrationsForbiddenError", From a6ae916b1757e1a7d046f828c4b043794d505b32 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 1 Sep 2026 16:59:08 +0200 Subject: [PATCH 2/2] refactor(url): parse URLs as values instead of catching TypeErrors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `new URL(...)` throws a `TypeError` the type system cannot see, and every `catch` answering it flattens "not a URL" together with whatever else the block could fail on. Eight sites were spelling that out by hand, each with its own copy of the recovery. `@maple/domain/url` holds the three shapes they wanted — `parseUrl` (Option), `parseUrlWithBase` for the relative form, and `urlPathname`, whose empty string is what the request predicates were expressing as `catch { return false }`. The existing local copies in `pull-request-ref` and `github-hosts` now use it too. Two behaviour changes fall out of the sweep: - `safeFetch` resolved a redirect's `Location` with a bare `new URL(location, validated)`. That header is whatever the far end sent, so a malformed one threw a raw `TypeError` out of the SSRF guard — neither a rejection callers can handle nor a response. It is a `UrlValidationError` now, with a regression test. - `validateExternalUrlSync` threw `UrlValidationError`, a `Schema.TaggedError`, which the Effect wrapper then had to re-recognise on the way back out. The check is `validateExternalUrlResult` returning a `Result`, `validateExternalUrl` is `Effect.fromResult` over it, and its two callers read the failure instead of catching it. All 69 existing guard cases are kept, as Result assertions. `safeFetch` itself stays a Promise: moving it onto `HttpClient` is a rewrite of the redirect and credential-stripping loop, not a parsing change. --- apps/api/src/http/url-validator.test.ts | 53 ++++++++--- apps/api/src/http/url-validator.ts | 87 +++++++++++-------- apps/api/src/platform/WorkersAiHttpClient.ts | 15 ++-- .../PlanetScaleDiscoveryService.ts | 6 +- .../vcs/vendor/github/GithubProvider.ts | 10 +-- .../vcs/vendor/github/github-hosts.ts | 13 +-- apps/api/src/worker.ts | 29 ++----- packages/domain/package.json | 1 + packages/domain/src/http/pull-request-ref.ts | 19 ++-- packages/domain/src/url.test.ts | 51 +++++++++++ packages/domain/src/url.ts | 45 ++++++++++ .../query-engine/src/execution/backend.ts | 12 +-- 12 files changed, 226 insertions(+), 115 deletions(-) create mode 100644 packages/domain/src/url.test.ts create mode 100644 packages/domain/src/url.ts diff --git a/apps/api/src/http/url-validator.test.ts b/apps/api/src/http/url-validator.test.ts index cab778997..f4353ea0b 100644 --- a/apps/api/src/http/url-validator.test.ts +++ b/apps/api/src/http/url-validator.test.ts @@ -1,22 +1,40 @@ import { assert, describe, expect, it } from "@effect/vitest" -import { Effect } from "effect" -import { safeFetch, UrlValidationError, validateExternalUrl, validateExternalUrlSync } from "./url-validator" +import { Effect, Result } from "effect" +import { + safeFetch, + UrlValidationError, + validateExternalUrl, + validateExternalUrlResult, +} from "./url-validator" -describe("validateExternalUrlSync", () => { +/** Rejected, and rejected as the tagged error rather than by throwing. */ +const rejects = (raw: string): void => { + const result = validateExternalUrlResult(raw) + assert.isTrue(Result.isFailure(result), `expected ${raw} to be rejected`) + if (Result.isFailure(result)) expect(result.failure).toBeInstanceOf(UrlValidationError) +} + +const accepts = (raw: string): URL => { + const result = validateExternalUrlResult(raw) + assert.isTrue(Result.isSuccess(result), `expected ${raw} to be accepted`) + return Result.isSuccess(result) ? result.success : new URL("https://unreachable.invalid") +} + +describe("validateExternalUrlResult", () => { it("accepts public https URLs", () => { - const url = validateExternalUrlSync("https://api.example.com/probe") + const url = accepts("https://api.example.com/probe") expect(url.hostname).toBe("api.example.com") }) it("accepts public http URLs", () => { - const url = validateExternalUrlSync("http://prom.public.dev:9090/metrics") + const url = accepts("http://prom.public.dev:9090/metrics") expect(url.hostname).toBe("prom.public.dev") }) it.each(["javascript:alert(1)", "file:///etc/passwd", "ftp://example.com", "data:text/html,