Skip to content

[upstream/deferred] Five URL-handling defects in upstream code surfaced via strict-mode csapi() probe #187

@Sam-Bolling

Description

@Sam-Bolling

Summary

Five URL-handling issues in pre-existing camptocamp/ogc-client upstream code, surfaced during the Phase 8 root-cause investigation of the live demo's "Library strict-mode validation skipped" warning. None are in our CSAPI contribution diff — verified with git diff upstream/main.

Each finding is classified below against an evidence ladder (platform spec → MDN → library contract → library tests) so reviewers can audit the reasoning rather than take the classification on faith.

Scope and severity (read this first)

This issue is not a claim that the library is broadly broken. It is a claim that (a) one specific deployment pattern is unsupported, (b) the implicit "absolute URL only" contract is undocumented, and (c) several call sites silently rely on that contract in ways that will eventually surface.

Plain-language framing

To state the situation as plainly as possible, so reviewers can decide whether this is worth their time:

  • The library's implicit input contract appears to be "absolute URL only" for the baseUrl passed to OgcApiEndpoint. That contract is undocumented in the README, type signatures, or JSDoc, but it is consistent with every example shown upstream and with the de-facto contract enforced by the test suite (relative input is never exercised — see L4).
  • Most users pass absolute URLs, so the library works correctly for them. We are not claiming otherwise.
  • Our deployment uses a same-origin reverse proxy and passes /api/csapi-go as the baseUrl. This is a legitimate, real-world pattern (CORS avoidance, cookie-domain alignment, dev/prod URL portability) but it is not the typical upstream usage. It is an edge case the library has not committed to supporting.
  • That edge case does break today — concretely, finding Phase 1.1: Create Type System (csapi/model.ts) #1 throws a TypeError four call levels deep — which is why our explorer ships a thin bridge component to construct an absolute URL before handing off to OgcApiEndpoint. The bridge is a workaround for our deployment pattern, not a workaround for a universal library defect.
  • Findings Phase 1.2: Create Helper Utilities (csapi/helpers.ts) #2Phase 1.4: Integrate with OgcApiEndpoint #4 are separate from our blocker. They are spec-level issues (OGC API Common permits relative href per RFC 8288; the library does not resolve those) that have not surfaced for current users only because most servers in the wild emit absolute hrefs. They are worth reporting because they will surface eventually for someone, but they are not what is breaking us today.
  • Finding Phase 2.1: Systems Methods #5 is included for completeness and may simply be working-as-designed.

The ask is therefore narrow: document the absolute-URL contract, or relax it for finding #1, so deployments behind same-origin proxies do not need a bridge. Findings #2#4 are flagged for the maintainers' awareness and prioritization at their discretion.

Per governance (AI_OPERATIONAL_CONSTRAINTS.md) and the precedent of docs/code-review/upstream-findings-report.md, we will not fix any of these inside PR #136.

Full investigation report

docs/implementation/phase-8-strict-mode-validation-warning-root-cause.md (commit a794387 on phase-8).

Symptom

Yellow card on https://ogc-csapi-explorer.pages.dev against the CSAPI-Go preset (which uses a same-origin /api/csapi-go proxy):

Library strict-mode validation skipped — Failed to construct 'URL': Invalid URL

The error is a TypeError thrown four call levels deep inside upstream URL handling, not a server response.

Authoritative sources cited

  • L1 (platform standard) — WHATWG URL Standard §6.1, "URL class"https://url.spec.whatwg.org/#url-class

    "This throws an exception if the input is a relative-URL string: new URL("/🍣🍺")"
    Constructor steps: "If parsedURL is failure, then throw a TypeError."

  • L2 (platform docs) — MDN, URL() constructorhttps://developer.mozilla.org/en-US/docs/Web/API/URL/URL

    "new URL("/en-US/docs"); // Raises a TypeError exception as '/en-US/docs' is not a valid URL"

  • L3 (link semantics) — OGC API – Common Part 1 §7.4 + RFC 8288 §3.1 — link href values may be relative URI references; clients are expected to resolve against a base.
  • L4 (library tested contract) — src/shared/url-utils.spec.tsgetBaseUrl is tested with no-arg and absolute input only; the relative-input case is not covered.

new URL(relative) throwing is correct platform behavior, not a bug. The defects below are about how upstream code uses that primitive, not the primitive itself.

The five sites (all upstream-owned)

# Site (phase-8 line) Classification Why it's classified that way
1 src/shared/url-utils.ts:9getBaseUrl(url) → new URL(url) Internal contract inconsistency (active for us). getParentPath and getChildPath in the same file accept relative input and resolve via new URL(url, getBaseUrl()). They imply the helper layer supports relative input. But getBaseUrl(url) itself does not. The internal contradiction is the defect; the new URL throw is just how it manifests. L4 silent (not tested), L3 silent (not documented either way). Hits us because we pass /api/csapi-go.
2 src/ogc-api/info.ts:161parseBaseCollectionInfo, new URL(link.href) no base Latent spec-vs-code defect. OGC API Common (L3) explicitly permits relative href per RFC 8288. WHATWG URL (L1) requires a base for relative input. The library claims to consume OGC API responses, so a server returning a spec-permitted relative href will throw here. Not currently breaking us because csapi-go returns absolute hrefs.
3 src/ogc-api/edr/url_builder.ts:103,163,223,300,371,446,514 — 7× new URL(href) no base Same as #2. OGC API – EDR (OGC 19-086) inherits Common's link semantics. Latent until an EDR server returns relative hrefs.
4 src/ogc-api/endpoint.ts:675,678,680new URL(href, this.baseUrl || '') Real defect, internally inconsistent. Empty-string base is a parsing failure per WHATWG URL §4.4 ("missing-scheme-non-relative-URL validation error, return failure"). The || '' fallback is only reached when this.baseUrl is falsy — i.e., precisely when a usable base is most needed. Latent (requires a falsy baseUrl plus a relative href).
5 src/ogc-api/link-utils.ts:75fetchCollectionRoot, new URL(parentUrl) no base Low confidence — possibly working as designed. All current call sites pass an already-absolute URL. No spec/test/internal contradiction. Listed for completeness; close at maintainer discretion.

Ownership verification

git diff upstream/main -- src/shared/url-utils.ts        # 0 lines
git diff upstream/main -- src/ogc-api/link-utils.ts      # 0 lines
git diff upstream/main -- src/ogc-api/edr/url_builder.ts # 0 lines

For info.ts and endpoint.ts, the defect lines fall outside our diff hunks — see report §5.2 for the hunk-by-hunk proof.

Failure chain for finding #1 (the one breaking us today)

demo: new OgcApiEndpoint('/api/csapi-go').csapi('systems')
  → endpoint.csapi()                      OURS  endpoint.ts:376
  → this.hasConnectedSystems              OURS  endpoint.ts:333
  → this.conformanceClasses               UPSTREAM
  → this.conformance                      UPSTREAM
  → fetchLink(root, ['conformance'], '/api/csapi-go')      UPSTREAM
  → getLinkUrl(doc, rel, '/api/csapi-go', …)               UPSTREAM
  → new URL(link.href, getBaseUrl('/api/csapi-go'))        UPSTREAM
  → getBaseUrl('/api/csapi-go')                            UPSTREAM
  → new URL('/api/csapi-go')   ← THROWS TypeError: Invalid URL

Note: JavaScript evaluates the second argument of new URL(href, base) before invoking the constructor, so even though link.href is absolute, getBaseUrl(baseUrl) throws first.

Suggested upstream patch (resolves finding #1 only)

// src/shared/url-utils.ts
export function getBaseUrl(url?: string): string | URL {
  if (url && typeof url === 'string') {
    // Resolve relative paths against globalThis.location when in a browser-like
    // environment, matching the implicit contract already honored by
    // getParentPath/getChildPath in this same file.
    if ('location' in globalThis && typeof globalThis.location === 'object') {
      return new URL(url, globalThis.location.toString());
    }
    return new URL(url);  // Node: caller must still pass absolute
  }
  if ('location' in globalThis && typeof globalThis.location === 'object') {
    return globalThis.location.toString();
  }
  return new URL('http://localhost');
}

Plus a regression test in src/shared/url-utils.spec.ts covering relative-path input (currently absent — see L4 above).

Findings #2#4 would each be addressed by passing an explicit base (likely the endpoint's own resolved root URL) at every new URL(href, …) call site.

Scope of this issue vs. our app's integration code

To prevent a misreading: our explorer ships an app-layer module (demo/src/csapi-bridge.ts, 614 lines) that sits between Vue components and OgcApiEndpoint/csapi(). The existence of that module is not evidence of upstream defects. It is standard application architecture — the same kind of integration layer most apps build over a generic library.

What the bridge actually contains, by category:

  • Discovery policy (3-tier): initializeBuilder runs strict (endpoint.csapi()) → permissive (link scan) → fallback (all 24 CSAPI types). This is opinionated UX policy ("never leave the user with an unusable UI"), not something the library should impose on every consumer.
  • Proxy-relative URL builders (10 of them): getListUrl, getDetailUrl, getCreateUrl, getUpdateUrl, getDeleteUrl, getNestedListUrl, getSchemaUrl, getCommandStatusUrl, getControlStreamSchemaUrl. These produce paths relative to a proxy mount that apiFetch() later prepends. The library correctly produces absolute URLs; the bridge is the seam where they get rewritten for our deployment.
  • CSAPI Part 2 payload helpers: parsePart2Resource, tryParseCommandStatus, getContentType, classifyResource. App-level format glue.
  • Vue reactivity: builder = shallowRef<CSAPIQueryBuilder>(null). Framework binding.
  • Failure-kind UX classification: strictModeFailureKind — pure presentation logic, added in explorer commit 72535f6 so the user sees an accurate message instead of "server conformance issue."

Only the failure-kind classifier is causally tied to the findings in this issue. It would become unnecessary if finding #1 is fixed upstream. Everything else in the bridge would still exist regardless of how this issue is resolved, regardless of whether PR #136 merges, and regardless of whether the upstream library is ever modified at all.

In other words: fixing finding #1 saves callers in our deployment pattern roughly one line of code (new URL(path, location.origin).href). It does not eliminate, and is not intended to eliminate, the need for app-side integration code.

Why we are not opening this on camptocamp/ogc-client directly (yet)

Escalation should originate from a maintainer/sponsor account once PR #136 has merged, with a full reproducer and the citations above. Filing prematurely from the PR context risks confusing the upstream review of #136.

Disposition

Related work

Labels

upstream, deferred, do-not-fix-in-pr-136, phase-8

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions