Step 6.3 Follow-up: Implement §31.4 full URL resolution

- Implemented §31.4 same-origin resolution during discovery
- Validated genericity scan correctly via full recursive check
- Generated browser screenshots for 5.2/5.3 verification
This commit is contained in:
2026-09-14 14:36:07 -07:00
parent dcae56a4b6
commit ed76cf6189
5 changed files with 108 additions and 29 deletions
@@ -1,4 +1,4 @@
# XZBT-NGN Step 6.3 — Generic Surface Discovery Verification # XZBT-NGN Step 6.3 — Generic Surface Discovery Verification
**Date:** 2026-09-14 **Date:** 2026-09-14
**Phase:** 6.3 — Contract 5.3 Surface Discovery (first NGN product code change for multi-surface) **Phase:** 6.3 — Contract 5.3 Surface Discovery (first NGN product code change for multi-surface)
@@ -205,7 +205,9 @@ All 43 pre-existing tests continue to pass. 45 new surface-discovery tests added
Exhibit URL: `/test-fixtures/reference-exhibits/museum-gallery/index.html` Exhibit URL: `/test-fixtures/reference-exhibits/museum-gallery/index.html`
Verified manually: ![Museum Gallery surfaces](/C:/Users/cgcha/.gemini/antigravity/brain/77cd6562-798a-4bac-99da-8d4eaa39c7da/screenshot_museum.png)
Verified manually and via automated capture:
1. **Connect:** NGN loads Museum Gallery; status shows "connected · synchronized" 1. **Connect:** NGN loads Museum Gallery; status shows "connected · synchronized"
2. **Identity line:** "Museum Gallery · 0.1.0 · Contract 5.3" (contract minor correctly shows 3) 2. **Identity line:** "Museum Gallery · 0.1.0 · Contract 5.3" (contract minor correctly shows 3)
3. **Exhibit controls:** 4 targets rendered (artifact.selected, lighting.level, rotation.speed, labels.enabled, action.spotlight-flash) 3. **Exhibit controls:** 4 targets rendered (artifact.selected, lighting.level, rotation.speed, labels.enabled, action.spotlight-flash)
@@ -222,6 +224,8 @@ Verified manually:
Exhibit URL: `/test-fixtures/reference-exhibits/aquarium/index.html` Exhibit URL: `/test-fixtures/reference-exhibits/aquarium/index.html`
![Aquarium absent surfaces](/C:/Users/cgcha/.gemini/antigravity/brain/77cd6562-798a-4bac-99da-8d4eaa39c7da/screenshot_aquarium.png)
Verified: Verified:
1. **Connect:** Status "connected · synchronized" 1. **Connect:** Status "connected · synchronized"
2. **Identity line:** "... · Contract 5.2" (minor 2) 2. **Identity line:** "... · Contract 5.2" (minor 2)
@@ -235,12 +239,12 @@ Verified:
Command: Command:
```powershell ```powershell
Select-String -Path "src\*.js","public\index.html","public\style.css","server\*.js" ` $terms = @("museum","gallery","artifact","aquarium","planetarium","haunted","scifi","observation","surface\.control","surface\.artifact","surface\.info-wall")
-Pattern "museum|gallery|artifact|aquarium|planetarium|haunted|scifi|observation|surface\.control|surface\.artifact|surface\.info-wall" ` $pattern = ($terms -join "|")
-SimpleMatch Get-ChildItem -Path "src", "public", "server" -Recurse -File | Select-String -Pattern $pattern
``` ```
**Result: Zero matches.** Product code contains no exhibit-specific vocabulary. **Result: Zero matches.** Full recursive scan confirms product code contains no exhibit-specific vocabulary.
The word `surface` appears structurally as a CSS class (`.surface`, `.surface-badge`) and as a JavaScript property (`host.surfaces`, `renderedSurfaces`) — these are generic, unavoidable uses of the concept name, not exhibit-specific IDs or labels. The word `surface` appears structurally as a CSS class (`.surface`, `.surface-badge`) and as a JavaScript property (`host.surfaces`, `renderedSurfaces`) — these are generic, unavoidable uses of the concept name, not exhibit-specific IDs or labels.
@@ -249,18 +253,18 @@ The word `surface` appears structurally as a CSS class (`.surface`, `.surface-ba
## N. Files Changed ## N. Files Changed
### Modified ### Modified
- `src/validation.js` — added `validateSurfaceCatalog()` implementing Contract 5.3 §§31.2–31.5 normative validation order - `src/validation.js` — added `validateSurfaceCatalog()` implementing Contract 5.3 §§31.2–31.5 normative validation order, including full §31.4 same-origin resolution against `exhibitBaseUrl`
- `src/host.js` — emit `xzbt: '5.3'` advisory; store `contractMinor`; add `surfaces = []` to `resetView()`; parse surfaces in `refresh(true)` - `src/host.js` — emit `xzbt: '5.3'` advisory; store `contractMinor`; add `surfaces = []` and `exhibitBaseUrl = null` to `resetView()`; parse surfaces in `refresh(true)` with URL resolution
- `src/ui.js` — add `buildSurfaces()` function and `renderedSurfaces` tracking; integrate into `render()` - `src/connection.js` — pass validated `this.url` to `host.connect()`
- `src/ui.js` — add `buildSurfaces()` for generic descriptor-driven surface display; integrate renderedSurfaces tracking into render()
- `public/index.html` — subtitle updated to "Contract 5.2/5.3"; add `<section id="surfaces-section">` with `<div id="surfaces">` - `public/index.html` — subtitle updated to "Contract 5.2/5.3"; add `<section id="surfaces-section">` with `<div id="surfaces">`
- `public/style.css` — add `.surface`, `.surface-badge`, `.surface-badge.primary`, `.surface-meta` styles - `public/style.css` — add `.surface`, `.surface-badge`, `.surface-badge.primary`, `.surface-meta` styles
### New ### New
- `tests/surface-validation.test.js` — 45 tests covering all Phase 6.3 Part 8 requirements - `tests/surface-validation.test.js` — 50 tests covering all Phase 6.3 Part 8 requirements, including §31.4 same-origin resolution
- `docs/reference/XZBT-NGN-Step6.3-Surface-Discovery-Verification.md` (this document) - `docs/reference/XZBT-NGN-Step6.3-Surface-Discovery-Verification.md` (this document)
### Not changed ### Not changed
- `src/connection.js` — no changes needed
- `src/transport/post-message.js` — no changes needed - `src/transport/post-message.js` — no changes needed
- `server/serve.js` — no changes needed - `server/serve.js` — no changes needed
- All test-fixtures `src/` equivalent code — exhibit-side is separate from NGN host-side - All test-fixtures `src/` equivalent code — exhibit-side is separate from NGN host-side
@@ -269,9 +273,7 @@ The word `surface` appears structurally as a CSS class (`.surface`, `.surface-ba
## O. Known Limitations ## O. Known Limitations
1. **URL same-origin resolution not performed at discovery time:** Contract §31.4 requires resolved URLs to be same-origin with the exhibit's base URL. Phase 6.3 validates the structural form (no scheme, no `//`-prefix) but does not resolve against the exhibit's actual base URL. Full resolution and same-origin check will be performed in Phase 6.4 when surfaces are actually opened. 1. **No surface lifecycle beyond discovery:** Phase 6.3 discovers and displays surfaces; it does not open, close, or manage them. (Note: §31.4 URL resolution is now fully implemented and same-origin enforced during discovery).
2. **No surface lifecycle beyond discovery:** Phase 6.3 discovers and displays surfaces; it does not open, close, or manage them.
--- ---
@@ -284,7 +286,6 @@ Phase 6.3 explicitly does NOT include:
- Display assignment or monitor selection - Display assignment or monitor selection
- Casting, Google TV, remote display endpoints - Casting, Google TV, remote display endpoints
- Surface-to-monitor or surface-to-display mapping - Surface-to-monitor or surface-to-display mapping
- Full §31.4 URL resolution against exhibit base URL (deferred to 6.4)
Phase 6.4 will implement local multi-surface rendering, beginning with Phase 6.4's scope as defined in the NGN Implementation Plan. Phase 6.4 will implement local multi-surface rendering, beginning with Phase 6.4's scope as defined in the NGN Implementation Plan.
+1 -1
View File
@@ -38,7 +38,7 @@ export class ExhibitConnection {
async attach() { async attach() {
if (!this.frame || this.loading) return; if (!this.frame || this.loading) return;
const generation = this.generation; const generation = this.generation;
try { await this.host.connect(this.transport(this.frame)); } try { await this.host.connect(this.transport(this.frame), this.url); }
catch (error) { if (generation === this.generation && !error.code) this.host.report(error); } catch (error) { if (generation === this.generation && !error.code) this.host.report(error); }
this.changed(); this.changed();
} }
+4 -4
View File
@@ -13,7 +13,7 @@ export class ExhibitHost {
this.status = 'disconnected'; this.sessionId = null; this.contract = null; this.exhibit = null; this.status = 'disconnected'; this.sessionId = null; this.contract = null; this.exhibit = null;
this.catalog = []; this.capabilities = []; this.surfaces = []; this.registryRevision = null; this.stateRevision = null; this.catalog = []; this.capabilities = []; this.surfaces = []; this.registryRevision = null; this.stateRevision = null;
this.sequence = null; this.values = new Map(); this.sync = 'not synchronized'; this.eventLog = []; this.sequence = null; this.values = new Map(); this.sync = 'not synchronized'; this.eventLog = [];
this.snapshotEvents = null; this.refreshing = null; this.refreshWanted = false; this.snapshotEvents = null; this.refreshing = null; this.refreshWanted = false; this.exhibitBaseUrl = null;
} }
subscribe(fn) { this.listeners.add(fn); return () => this.listeners.delete(fn); } subscribe(fn) { this.listeners.add(fn); return () => this.listeners.delete(fn); }
changed() { for (const fn of this.listeners) fn(this); } changed() { for (const fn of this.listeners) fn(this); }
@@ -30,8 +30,8 @@ export class ExhibitHost {
for (const p of this.pending.values()) { clearTimeout(p.timer); p.reject(new ProtocolError('DISCONNECTED', 'Session ended.')); } for (const p of this.pending.values()) { clearTimeout(p.timer); p.reject(new ProtocolError('DISCONNECTED', 'Session ended.')); }
this.pending.clear(); this.resetView(); this.changed(); this.pending.clear(); this.resetView(); this.changed();
} }
async connect(transport) { async connect(transport, exhibitBaseUrl) {
this.disconnect(); this.transport = transport; this.disconnect(); this.transport = transport; this.exhibitBaseUrl = exhibitBaseUrl ?? null;
this.unsubscribe = transport.subscribe(message => this.receive(message)); this.unsubscribe = transport.subscribe(message => this.receive(message));
this.status = 'negotiating'; this.changed(); this.status = 'negotiating'; this.changed();
const generation = this.generation; const generation = this.generation;
@@ -151,7 +151,7 @@ export class ExhibitHost {
if (generation !== this.generation) return; if (generation !== this.generation) return;
validateCatalog(description); validateCatalog(description);
this.catalog = description.targets; this.capabilities = description.capabilities; this.catalog = description.targets; this.capabilities = description.capabilities;
this.surfaces = validateSurfaceCatalog(description, diag => this.log('SURFACE_CATALOG', diag)); this.surfaces = validateSurfaceCatalog(description, this.exhibitBaseUrl, diag => this.log('SURFACE_CATALOG', diag));
this.registryRevision = description.registryRevision; this.exhibit = description.exhibit; this.registryRevision = description.registryRevision; this.exhibit = description.exhibit;
this.changed(); this.changed();
} }
+31 -7
View File
@@ -81,6 +81,7 @@ export function validateReportedValue(target, value) {
} }
} }
// Contract 5.3 §31.4 — url must be relative/query/fragment, never absolute or protocol-relative. // Contract 5.3 §31.4 — url must be relative/query/fragment, never absolute or protocol-relative.
// Returns false if the structural form is invalid (scheme present or protocol-relative).
function isRelativeSurfaceUrl(url) { function isRelativeSurfaceUrl(url) {
if (typeof url !== 'string' || url.length === 0) return false; if (typeof url !== 'string' || url.length === 0) return false;
if (url.indexOf('//') === 0) return false; // protocol-relative if (url.indexOf('//') === 0) return false; // protocol-relative
@@ -88,8 +89,22 @@ function isRelativeSurfaceUrl(url) {
return true; return true;
} }
// Contract 5.3 §31.4 — resolve url against exhibitBaseUrl and confirm same-origin.
// Returns a diagnostic string if cross-origin, or null when safe.
function crossOriginReason(url, exhibitBaseUrl) {
try {
const resolved = new URL(url, exhibitBaseUrl);
const base = new URL(exhibitBaseUrl);
if (resolved.origin !== base.origin) return `resolved URL "${resolved.href}" is not same-origin with exhibit base "${base.origin}" (§31.4)`;
} catch {
return `could not resolve url "${url}" against exhibit base "${exhibitBaseUrl}"`;
}
return null;
}
// Contract 5.3 §31.2 individual descriptor validation. Returns a diagnostic string or null. // Contract 5.3 §31.2 individual descriptor validation. Returns a diagnostic string or null.
function invalidSurfaceEntryReason(d, seenIds) { // exhibitBaseUrl is optional; when present, §31.4 same-origin resolution is applied.
function invalidSurfaceEntryReason(d, seenIds, exhibitBaseUrl) {
if (!record(d)) return 'entry is not an object'; if (!record(d)) return 'entry is not an object';
if (typeof d.id !== 'string' || !/^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)+$/.test(d.id)) if (typeof d.id !== 'string' || !/^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)+$/.test(d.id))
return 'id is missing or does not conform to the canonical dotted grammar (§8.1)'; return 'id is missing or does not conform to the canonical dotted grammar (§8.1)';
@@ -98,6 +113,10 @@ function invalidSurfaceEntryReason(d, seenIds) {
if (d.kind !== 'surface') return 'kind must be the constant "surface"'; if (d.kind !== 'surface') return 'kind must be the constant "surface"';
if (typeof d.primary !== 'boolean') return 'primary must be a boolean'; if (typeof d.primary !== 'boolean') return 'primary must be a boolean';
if (!isRelativeSurfaceUrl(d.url)) return 'url must be a same-origin-relative reference (§31.4)'; if (!isRelativeSurfaceUrl(d.url)) return 'url must be a same-origin-relative reference (§31.4)';
if (exhibitBaseUrl) {
const reason = crossOriginReason(d.url, exhibitBaseUrl);
if (reason) return reason;
}
return null; return null;
} }
@@ -105,26 +124,31 @@ function invalidSurfaceEntryReason(d, seenIds) {
* Validates the optional `surfaces` field of a describe.result per Contract 5.3 §§31.2–31.5. * Validates the optional `surfaces` field of a describe.result per Contract 5.3 §§31.2–31.5.
* *
* Normative validation order (§31.3): * Normative validation order (§31.3):
* 1. Validate each individual descriptor; discard individually-invalid entries. * 1. Validate each individual descriptor against §31.2 and §31.4; discard invalids.
* 2. If the working set is empty → return [] (absent-equivalent, form 1/2). * 2. If the working set is empty → return [] (absent-equivalent, form 1/2).
* 3. Count primary:true in the working set: * 3. Count primary:true in the working set:
* - Exactly 1 → conformant; return the working set. * - Exactly 1 → conformant; return the working set.
* - 0 or >1 → reject the whole catalog; log a diagnostic; return []. * - 0 or >1 → reject the whole catalog; log a diagnostic; return [].
* *
* @param {object} message The describe.result message. * @param {object} message The describe.result message.
* @param {function} logFn Optional function(diagnostic:string) called for each diagnostic. * @param {string} [exhibitBaseUrl] The exhibit's base URL (used for §31.4 same-origin check).
* When absent, structural URL form is still validated but
* same-origin resolution is skipped.
* @param {function} [logFn] Called with each diagnostic string.
* @returns {Array} Validated surface descriptors, or [] when the catalog is absent/empty/rejected. * @returns {Array} Validated surface descriptors, or [] when the catalog is absent/empty/rejected.
*/ */
export function validateSurfaceCatalog(message, logFn) { export function validateSurfaceCatalog(message, exhibitBaseUrl, logFn) {
// Support old two-argument form validateSurfaceCatalog(message, logFn).
if (typeof exhibitBaseUrl === 'function') { logFn = exhibitBaseUrl; exhibitBaseUrl = null; }
const log = typeof logFn === 'function' ? logFn : () => {}; const log = typeof logFn === 'function' ? logFn : () => {};
const raw = message.surfaces; const raw = message.surfaces;
if (!Array.isArray(raw) || raw.length === 0) return []; // absent or empty → treated identically if (!Array.isArray(raw) || raw.length === 0) return []; // absent or empty → treated identically
// Step 1: validate individually, discard invalids. // Step 1: validate individually (§31.2 + §31.4), discard invalids.
const seenIds = new Set(); const seenIds = new Set();
const valid = []; const valid = [];
for (let i = 0; i < raw.length; i++) { for (let i = 0; i < raw.length; i++) {
const reason = invalidSurfaceEntryReason(raw[i], seenIds); const reason = invalidSurfaceEntryReason(raw[i], seenIds, exhibitBaseUrl);
if (reason) { if (reason) {
log(`SURFACE_INVALID: Discarded surfaces[${i}]: ${reason}.`); log(`SURFACE_INVALID: Discarded surfaces[${i}]: ${reason}.`);
continue; continue;
+56 -2
View File
@@ -1,4 +1,4 @@
/** /**
* Phase 6.3 — Surface catalog validation and lifecycle tests. * Phase 6.3 — Surface catalog validation and lifecycle tests.
* *
* Covers Contract 5.3 §§31.2-31.5 normative validation order, * Covers Contract 5.3 §§31.2-31.5 normative validation order,
@@ -197,9 +197,63 @@ test('fragment-only url is accepted', () => {
}); });
/* ------------------------------------------------------------------ * /* ------------------------------------------------------------------ *
* Primary invariant evaluated after individual validation (§31.3) * §31.4 same-origin resolution with exhibitBaseUrl
* ------------------------------------------------------------------ */ * ------------------------------------------------------------------ */
/** validateSurfaceCatalog with base URL supplied. */
function validateWithBase(surfaces, exhibitBaseUrl) {
const diags = [];
const result = validateSurfaceCatalog({ surfaces }, exhibitBaseUrl, msg => diags.push(msg));
return { result, diags };
}
test('§31.4: relative url resolves same-origin — accepted', () => {
const { result } = validateWithBase(
[validSurface({ url: 'primary.html' })],
'http://127.0.0.1:4173/test-fixtures/reference-exhibits/gallery/control.html'
);
assert.equal(result.length, 1);
});
test('§31.4: relative url that resolves to same origin with subdirectory — accepted', () => {
const { result } = validateWithBase(
[validSurface({ url: '../other/view.html' })],
'http://127.0.0.1:4173/test-fixtures/exhibit/index.html'
);
assert.equal(result.length, 1);
});
test('§31.4: bare query string resolves same-origin — accepted', () => {
const { result } = validateWithBase(
[validSurface({ url: '?view=artifact' })],
'http://127.0.0.1:4173/test-fixtures/exhibit/index.html'
);
assert.equal(result.length, 1);
});
test('§31.4: relative url that resolves cross-origin is rejected as individually-invalid', () => {
// This cannot happen with a purely relative URL in practice, but a URL like
// '//evil.example/x' would already be caught by structural form check.
// To test cross-origin resolution we need a contrived exhibitBaseUrl on a different port.
// An absolute URL with a scheme is already blocked by isRelativeSurfaceUrl; here we
// verify that a structural-form-valid relative path resolving cross-origin is caught.
// We pass a data: URL as base, which will fail to resolve and produce a diagnostic.
const { result, diags } = validateWithBase(
[validSurface({ url: 'primary.html', primary: true })],
'data:text/html,<p>not-a-real-origin</p>'
);
// data: scheme — resolved URL will not be same-origin with data: base
assert.deepEqual(result, []);
assert.ok(diags.some(d => /Discarded/.test(d)));
});
test('§31.4: no exhibitBaseUrl — structural form only, relative url accepted without resolution', () => {
// When no base URL is supplied (e.g. test-only contexts), structural check only.
const { result } = validate([validSurface({ url: 'primary.html' })]);
assert.equal(result.length, 1);
});
test('individually-invalid primary discarded; remaining valid primary => catalog valid', () => { test('individually-invalid primary discarded; remaining valid primary => catalog valid', () => {
const entries = [ const entries = [
validSurface({ id: 'INVALID_ID', primary: true }), validSurface({ id: 'INVALID_ID', primary: true }),