Date: 2026-04-30
Investigator: GitHub Copilot (Claude Opus 4.7)
Trigger: User-observed yellow warning on the live demo at https://ogc-csapi-explorer.pages.dev when connecting to the CSAPI-Go preset:
⚠️ Library strict-mode validation skipped — "The canonicalOgcApiEndpoint.csapi()entry point could not be used against this server (Failed to construct 'URL': Invalid URL). The explorer fell back to permissive discovery so it can still connect, but this is a server-side conformance issue worth reporting upstream."
Status: Investigation complete. Root causes identified, ownership classified, fix scope determined.
The warning text shipped in the demo speculates that the failure is "a server-side conformance issue worth reporting upstream" — meaning a bug in the CSAPI-Go server. That speculation is wrong. The failure is a JavaScript TypeError thrown deep inside the camptocamp/ogc-client library code, not in any server response.
Five distinct latent defects in URL handling were identified during the audit. All five live entirely in pre-existing upstream code authored by camptocamp/ogc-client maintainers. Zero defects are in our CSAPI contribution surface (src/ogc-api/csapi/**, the csapi() factory method on OgcApiEndpoint, conformance helpers, etc.).
| # | Defect | File / line (phase-8) | Owner | Active today? | Fix policy |
|---|---|---|---|---|---|
| 1 | getBaseUrl(relativePath) throws TypeError: Invalid URL |
src/shared/url-utils.ts:9 |
Upstream (camptocamp) | Yes — fires for csapi-go | Forbidden to fix |
| 2 | parseBaseCollectionInfo items-link new URL(link.href) with no base |
src/ogc-api/info.ts:161 |
Upstream | Latent | Forbidden to fix |
| 3 | EDR builder new URL(...) with no base — 7 sites |
src/ogc-api/edr/url_builder.ts:103,163,223,300,371,446,514 |
Upstream | Latent (no EDR servers tested) | Forbidden to fix |
| 4 | getCollectionItemsUrl uses this.baseUrl || '' as URL base |
src/ogc-api/endpoint.ts:675,678,680 |
Upstream | Latent | Forbidden to fix |
| 5 | fetchCollectionRoot new URL(parentUrl) with no base |
src/ogc-api/link-utils.ts:75 |
Upstream | Latent (defensive only) | Forbidden to fix |
Net answer to the question "is this our faulty code?": No. It is not our code. Every single one of the URL-construction defects on the failure path is in code that existed in camptocamp/ogc-client before our fork was created. None of these lines appear in our diff against upstream/main. Per established governance precedent (see docs/code-review/upstream-findings-report.md), upstream code is out of scope for our PR — we may not modify it as part of the CSAPI contribution.
The user-visible warning is therefore a symptom that traces, through our (correctly-written) csapi() entry point, into upstream URL handling that has never tolerated a relative baseUrl. The mistake-in-shipping is in the demo's warning text, which mis-attributes the failure to the server. That part is ours and is fixable.
The explorer demo performs a two-tier discovery probe in demo/src/csapi-bridge.ts:
-
Strict mode — call the canonical library entry point
new OgcApiEndpoint(baseUrl).csapi(collectionId). This validates that:/conformanceadvertises a Connected Systems class (Part 1 or Part 2).- The collection id resolves to a real collection document.
- Root / collection links can be scanned for CSAPI resource relations.
-
Permissive mode — if strict validation fails for any reason, scan the landing page and collection links directly for CSAPI relations (
ogc-cs:*, plain resource names,itemswith resource paths). This is the "legacy" path that lets the demo still function against partially-conformant servers.
When strict succeeds: green card, "Library strict-mode validation passed". When strict fails: yellow card with the captured error message and the misleading "server-side conformance issue worth reporting upstream" text.
The explorer is deployed to Cloudflare Pages and uses CF Pages Functions as a reverse proxy:
browser → https://ogc-csapi-explorer.pages.dev/api/csapi-go/* (CF Pages Function proxy)
↓
https://129-80-248-53.sslip.io/csapi-go/* (upstream server)
For server presets the demo therefore configures connection.baseUrl = '/api/csapi-go' (proxy-relative). The proxy injects auth, strips problematic headers, and adds CORS — without it the browser can't reach OSH at all.
When the demo invokes the strict path it does:
const endpoint = new OgcApiEndpoint('/api/csapi-go')
await endpoint.csapi('systems')That second call is what throws.
cd ogc-client-CSAPI_2
node --input-type=module -e "
import { getBaseUrl } from './src/shared/url-utils.ts';
console.log('getBaseUrl(absolute) =', getBaseUrl('http://x.com').toString());
try {
console.log('getBaseUrl(/api/csapi-go) =', getBaseUrl('/api/csapi-go').toString());
} catch (e) {
console.log('THREW:', e.constructor.name + ':', e.message);
}
"Output:
getBaseUrl(absolute) = http://x.com/
getBaseUrl(/api/csapi-go) THREW: TypeError: Invalid URL
That is the exact TypeError surfaced in the demo's warning card. The browser variant has the prefix "Failed to construct 'URL'" instead of the plain Node form, but the underlying cause is identical: the single-argument URL constructor requires an absolute URL.
src/shared/url-utils.ts:7-17 (planning repo, phase-8):
export function getBaseUrl(url?: string): string | URL {
if (url && typeof url === 'string') {
return new URL(url); // ← line 9 — throws on relative path
}
if ('location' in globalThis && typeof globalThis.location === 'object') {
return globalThis.location.toString();
}
return new URL('http://localhost');
}Only the first branch is buggy. The other two branches (no-arg and Node fallback) work correctly. The bug is that whenever a caller passes a relative-path string, the function does not attempt to resolve it against globalThis.location — it goes straight to new URL(string) which mandates an absolute URL.
endpoint.csapi('systems') — src/ogc-api/endpoint.ts:376 (OURS — CSAPI contribution)
└─ await this.hasConnectedSystems — src/ogc-api/endpoint.ts:333 (OURS — CSAPI contribution)
└─ this.conformanceClasses — src/ogc-api/endpoint.ts:243 (UPSTREAM)
└─ this.conformance — src/ogc-api/endpoint.ts:82 (UPSTREAM)
└─ fetchLink(root, ['conformance', …], this.baseUrl)
— src/ogc-api/link-utils.ts:125 (UPSTREAM)
└─ getLinkUrl(doc, rel, baseUrl, …, true) — src/ogc-api/link-utils.ts:113 (UPSTREAM)
└─ new URL(link.href, getBaseUrl(baseUrl))
— src/ogc-api/link-utils.ts:122 (UPSTREAM)
└─ getBaseUrl('/api/csapi-go') — src/shared/url-utils.ts:9 (UPSTREAM — THROWS)
Critical observation about ownership: the only two frames that are CSAPI contribution code are the two top-most frames (csapi() and the hasConnectedSystems getter). The remainder — every link-utils, every URL constructor invocation, the getBaseUrl helper itself — is upstream code. The throw originates four call levels deep inside upstream code, in a function we did not write.
There is a subtle but important detail. The throwing call site is:
return new URL(link.href, getBaseUrl(baseUrl)).toString();For an absolute link.href (which csapi-go's conformance link is — https://129-80-248-53.sslip.io/csapi-go/conformance), the second argument is normally ignored by URL. But JavaScript evaluates arguments before calling the constructor — so getBaseUrl('/api/csapi-go') is invoked unconditionally and throws before new URL ever sees link.href. Even servers with perfectly-formed absolute hrefs cannot rescue this code path; the only requirement to trigger the bug is constructing the endpoint with a relative baseUrl.
The Connection Diagnostics panel shows several green checks (HTTPS, CORS, conformance class count, resource types discovered). Those checks all use raw fetch(baseUrl + '/conformance') etc. in demo/src/pages/ServerConnectPage.vue. They never feed '/api/csapi-go' into a URL constructor — they concatenate strings and let the browser's fetch resolve relative URLs against document.URL.
The library's csapi() path is the only code path that constructs URL objects from the relative baseUrl. That asymmetry is exactly why we see "8 CSAPI resource types discovered" succeeding next to "Library strict-mode validation skipped" — they don't share an implementation.
I audited every new URL( call site in src/ogc-api/** and src/shared/**, and traced each back to its commit. Findings, in priority order:
| Field | Value |
|---|---|
| File / line | src/shared/url-utils.ts:9 |
| Function | getBaseUrl(url?: string) |
| Owner | Upstream — camptocamp/ogc-client |
Diff vs upstream/main |
0 lines — file is identical |
| Active for csapi-go today | Yes |
| Fix policy | Forbidden in our PR. Upstream-only. |
This is the only defect actually firing today. Fixing it would require changing the first branch to attempt new URL(url, globalThis.location?.toString() ?? 'http://localhost') before falling through. That is a four-line change we are not permitted to make in our PR.
Test coverage gap (also upstream-owned): src/shared/url-utils.spec.ts:4-11 tests only getBaseUrl() and getBaseUrl('http://example.com'). There is no test exercising relative-path input. The upstream library has implicitly always been an "absolute-URL-only" API; the contract was never written down or asserted.
| Field | Value |
|---|---|
| File / line | src/ogc-api/info.ts:161 |
| Function | parseBaseCollectionInfo (items map step) |
| Code | const url = new URL(link.href); (no second argument) |
| Owner | Upstream |
Diff vs upstream/main |
The line is unchanged. Our diff to info.ts adds CSAPI conformance helpers and extends parseCollections with hasConnectedSystems — see hunks at lines 103, 269, 279, 310. The parseBaseCollectionInfo function and the throwing line are not in our diff. |
| Active for csapi-go today | No — csapi-go uses absolute item hrefs |
| Fix policy | Forbidden in our PR. |
Latent: any OGC API server that emits a relative items href (which is permitted by OGC API Common) crashes here.
| Field | Value |
|---|---|
| File | src/ogc-api/edr/url_builder.ts |
| Lines | 103, 163, 223, 300, 371, 446, 514 (seven sites) |
| Pattern | new URL(this.collection.data_queries?.X?.link.href) with no base |
| Owner | Upstream |
Diff vs upstream/main |
0 lines |
| Active for csapi-go today | No — csapi-go is not an EDR server |
| Fix policy | Forbidden in our PR. |
Latent: any EDR server using relative data_queries.*.link.href crashes.
| Field | Value |
|---|---|
| File / lines | src/ogc-api/endpoint.ts:675, 678, 680 |
| Function | getCollectionItemsUrl |
| Pattern | new URL(linkWithFormat.href, baseUrl) where baseUrl = this.baseUrl || '' |
| Owner | Upstream — function exists at upstream endpoint.ts:522 with identical logic |
Diff vs upstream/main |
The function is unchanged from upstream. |
| Active for csapi-go today | No — csapi-go item hrefs are absolute, so the second argument is ignored |
| Fix policy | Forbidden in our PR. |
Triggers if both this.baseUrl is relative AND link.href is relative. (Note: this same function also has a pre-existing P1 path-traversal vulnerability tracked as Finding 002 in docs/code-review/002-upstream-p1-query-param-injection.md — same out-of-scope rationale.)
| Field | Value |
|---|---|
| File / line | src/ogc-api/link-utils.ts:75 |
| Function | fetchCollectionRoot |
| Owner | Upstream |
Diff vs upstream/main |
0 lines |
| Active for csapi-go today | No — parentUrl is always already absolute (it comes from getParentPath which resolves through getBaseUrl() no-arg) |
| Fix policy | Forbidden in our PR. |
Defensive only; harmless today but inconsistent.
The repository's operating rules — established in docs/governance/AI_OPERATIONAL_CONSTRAINTS.md and reinforced through the Phase 6 / Phase 7 / Phase 8 review templates — require us to:
"Preserve upstream structure, naming, and patterns unless explicitly instructed otherwise."
The same rule was applied verbatim to four pre-existing P1/P2 security findings in docs/code-review/upstream-findings-report.md, which concludes:
"Verdict: Do not fix in our PR. Do not create GitHub issues. Track via MD files only."
Those four security findings (path traversal, query injection, HTTP scheme enforcement, credential leakage) all have the exact same disposition that applies here:
- We did not author the code.
- The code is identical to
upstream/main. - The code is not in our diff to
clean-pr. - Our CSAPI module is unaffected (or in this case, our CSAPI module exposes the upstream defect through a contributed entry point, but does not contain it).
- Modifying upstream code we didn't write violates governance.
This new audit applies the same rules to URL-handling defects. The verdict is the same: out of scope, do not patch, track via MD only.
I ran git diff upstream/main -- <path> for every file on the failure path. Results:
| File | Lines changed in our diff | Includes the defect line? |
|---|---|---|
src/shared/url-utils.ts |
0 | No (whole file is upstream) |
src/ogc-api/link-utils.ts |
0 | No (whole file is upstream) |
src/ogc-api/edr/url_builder.ts |
0 | No (whole file is upstream) |
src/ogc-api/info.ts |
79 lines added (CSAPI conformance helpers + hasConnectedSystems extension to parseCollections) |
No — diff hunks are at lines 103, 269, 279, 310; defect is at line 161 (untouched) |
src/ogc-api/endpoint.ts |
169 lines added (CSAPI integration: csapi(), csapiCollections, hasConnectedSystems, csapi-bridge glue, etc.) |
No for defect #4 — getCollectionItemsUrl body is unchanged from upstream |
Every defect on the failure path is in a region of code that we have not touched and may not touch.
Our additions on this code path are:
OgcApiEndpoint.csapi(collectionId)—endpoint.ts:376-412OgcApiEndpoint.hasConnectedSystemsgetter —endpoint.ts:333-337OgcApiEndpoint.csapiCollectionsgetter —endpoint.ts:237-241checkHasConnectedSystemsand Part-1/Part-2 prefix constants —info.ts(issue #186 fix)parseCollectionsextension settinghasConnectedSystemsfromogc-cs:link relations —info.ts:312-321src/ogc-api/csapi/**— entire CSAPI module (factory, helpers, query builder, types, URL helpers)csapi/helpers.ts isSafeHref— defensive URL acceptance check — line 122 does use the safe patterntry { new URL(href) } catch { return true }. This is correct and is the pattern the upstream code should be using.
None of those additions throw a TypeError: Invalid URL for relative bases. The closest-to-the-defect file we own — src/ogc-api/csapi/helpers.ts — actually demonstrates the correct pattern (lines 110-128): wrap the constructor in a try/catch and treat construction failure as "this is a relative URL, that's fine." The upstream getBaseUrl does the opposite.
A second point that strengthens the "not ours" classification: the policy that the library should always be constructed with an absolute URL is itself an upstream design decision. The original camptocamp library was built around the pattern new OgcApiEndpoint('https://example.com/path'), and every internal URL resolver assumes that. The getBaseUrl helper reflects that assumption. Whether to change that contract — to allow relative bases that resolve against document.URL in the browser, or against a configured base in Node — is a library API design choice that belongs to camptocamp, not to us. We can advocate for it (see §7), but unilaterally rewriting it inside our PR would:
- Change behaviour for existing camptocamp consumers in subtle ways.
- Introduce a SSR/Node concern that does not belong in our CSAPI scope.
- Risk PR rejection on the entirely orthogonal grounds of "scope creep."
While the URL defects themselves are upstream, two things in this failure are ours:
demo/src/pages/ServerConnectPage.vue:380-389 (in the explorer fork) reads:
detectedWarnings.push({
severity: 'warn',
summary: 'Library strict-mode validation skipped',
detail: 'The canonical OgcApiEndpoint.csapi() entry point could not be used '
+ `against this server (${initResult.strictModeError ?? 'unknown reason'}). `
+ 'The explorer fell back to permissive discovery so it can still connect, '
+ 'but this is a server-side conformance issue worth reporting upstream.',
})The trailing sentence — "this is a server-side conformance issue worth reporting upstream" — is factually wrong when the captured error is Failed to construct 'URL': Invalid URL. That error comes from inside the library, not from the server. The text was written assuming strict-mode would only fail when the server was non-conformant; we now know it can also fail when the library cannot resolve a relative baseUrl.
This is in the demo (not the library), and it is in our code (the explorer fork repo). It is fixable in scope. Recommended replacement (sketch — not yet committed):
detail: 'The canonical OgcApiEndpoint.csapi() entry point could not be used '
+ `against this server (${initResult.strictModeError ?? 'unknown reason'}). `
+ 'The explorer fell back to permissive discovery so it can still connect. '
+ 'This may indicate a server-side conformance issue, OR (if the error '
+ 'mentions \'Invalid URL\') a known limitation of the library when '
+ 'configured with a proxy-relative base URL — see '
+ 'OS4CSAPI/ogc-client-CSAPI_2/docs/implementation/'
+ 'phase-8-strict-mode-validation-warning-root-cause.md',This is fully resolved in issue #186 and committed as c323e06 on phase-8, effaa78 on clean-pr. It is the reason the diagnostics panel correctly shows "18 CSAPI conformance classes" today; without it the demo would have flagged csapi-go as non-CSAPI entirely. Mentioned here for completeness — it is not the cause of the strict-mode warning.
| Action | Scope | Status |
|---|---|---|
| File this report | docs/implementation/phase-8-strict-mode-validation-warning-root-cause.md |
This document |
| Add a session-memory note recording the audit so future sessions don't re-investigate | /memories/repo/csapi-explorer-deployment.md (extend) |
Pending |
Cross-link from docs/code-review/upstream-findings-report.md to indicate URL-handling defects join the existing security-finding cohort |
Optional doc update | Pending decision |
| Action | Scope | Priority |
|---|---|---|
| Soften the "server-side conformance issue" assertion in the strict-mode warning text and link to this report | demo/src/pages/ServerConnectPage.vue |
High — currently misleads users into filing wrong bugs upstream |
Optionally: add an Invalid URL heuristic in csapi-bridge.ts that reclassifies the warning into an explicit "known library limitation with proxy-relative base URLs" category |
demo/src/csapi-bridge.ts |
Medium |
We must not modify any of the five defect sites. Acceptable adjacent actions, if the team chooses to invest:
| Action | Why allowed | Scope |
|---|---|---|
Add a defensive guard inside our csapi() method that throws an EndpointError with a clear message when this.baseUrl is relative, before control reaches the upstream chain |
The guard is in code we own (the csapi() method itself) and only affects our entry point |
src/ogc-api/endpoint.ts:376-412 (already ours) |
Document the absolute-URL requirement in the csapi() JSDoc |
Pure docs change inside our contribution | Same file |
The defensive guard is the only library-side improvement available to us within governance. It does not fix the underlying upstream defect; it just produces a better error message when the upstream defect would otherwise fire on our code path.
Recommended issue to file against camptocamp/ogc-client (after Phase 8 ships, separate from PR #136):
getBaseUrlthrowsTypeError: Invalid URLfor relative-path arguments
src/shared/url-utils.ts:9callsnew URL(url)unconditionally when given a string argument, which throws for any relative path. Callers — includinggetLinkUrl,fetchDocument,fetchLink,parseBaseCollectionInfo— therefore cannot tolerate a relativebaseUrleven when the link href being resolved is absolute (because the second argument is evaluated before the constructor runs).Suggested fix: in browser environments, fall through the relative-path case to
new URL(url, globalThis.location?.toString() ?? 'http://localhost')— matching the behaviour of the no-argument branch.Reproduction: see attached report.
That issue should originate from a project maintainer / sponsor account, not from this contribution PR. Do not file it as part of PR #136.
This is not our code. It is camptocamp's pre-existing URL-handling code, exposed through our (correctly-implemented) csapi() entry point. We are forbidden from fixing it inside our CSAPI PR.
What we should fix is the demo's warning text, which currently slanders the server when it should mention the library limitation. That fix is small, in-scope, and lives entirely in our repo.
The strict-mode warning will continue to fire in the live demo against any preset that uses a proxy-relative base URL (CSAPI-Go, OSH, OSH SensorHub) until camptocamp accepts the upstream fix to getBaseUrl. Until then, the permissive-fallback path the demo already implements ensures the explorer continues to work — at the cost of one yellow card per connection.
Primary source files (all in OS4CSAPI/ogc-client-CSAPI_2, branch phase-8):
src/shared/url-utils.ts— defect #1src/ogc-api/link-utils.ts— defect #5; also thegetLinkUrlsite that triggers #1src/ogc-api/info.ts— defect #2src/ogc-api/edr/url_builder.ts— defect #3src/ogc-api/endpoint.ts— defect #4; also ourcsapi()entry pointsrc/ogc-api/csapi/helpers.ts— example of correct relative-URL handling
Demo files (in OS4CSAPI/ogc-csapi-explorer, branch main):
demo/src/pages/ServerConnectPage.vue— warning text (ours, fixable)demo/src/csapi-bridge.ts— strict/permissive bridge (ours, fixable)demo/functions/api/csapi-go/[[path]].ts— CF Pages reverse proxy (ours, working correctly)
Governance / precedent docs consulted:
docs/governance/AI_OPERATIONAL_CONSTRAINTS.mddocs/code-review/upstream-findings-report.mddocs/code-review/001-upstream-p1-path-traversal-item-id.mddocs/code-review/002-upstream-p1-query-param-injection.md
# 1. Confirm the upstream files are unchanged
cd ogc-client-CSAPI_2
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
# 2. Confirm info.ts and endpoint.ts diffs do NOT touch the defect lines
git diff upstream/main -- src/ogc-api/info.ts | grep -E '^@@'
# → hunks at +103, +269, +279, +310 (defect at line 161 is NOT in any hunk)
git diff upstream/main -- src/ogc-api/endpoint.ts | grep -E '^@@' | head -20
# → none of the hunks cover lines 638-682 (getCollectionItemsUrl body)
# 3. Reproduce the throw in isolation
node --input-type=module -e "
import { getBaseUrl } from './src/shared/url-utils.ts';
try { console.log(getBaseUrl('/api/csapi-go').toString()); }
catch (e) { console.log('THREW:', e.message); }
"
# → THREW: Invalid URL