Steps 6.4-6.7B — Local surfaces, reference-exhibit validation, SciFi Observation surface

One commit for the work accumulated in the working tree since Step 6.3,
which had never been split into per-step commits:

- src/local-surfaces.js + src/surface-url.js (new); src/ui.js,
  src/validation.js, src/connection.js and public/index.html updated for
  local-surface hosting and generic surface rendering
- tests: local-surfaces (20), scifi-surfaces (24) and postmessage-interop (7)
  new; connection/museum-gallery/surface-validation suites updated
- reference exhibits: shared/contract-core.js defaults to Contract 5.3
  (major 5, minor 3, xzbt 5.3); museum-gallery advertises its surface
  catalog; aquarium/haunted-house/planetarium adapters updated
- SciFi-XZBT (Step 6.7A/6.7B): surface-mode.js + surface-bus.js,
  Observation-surface boot branch, local-change hooks, view.pillars /
  view.warp-flight targets; fixture byte-identical to G:/.vibe/SciFi-XZBT
- SciFi-XZBT contract adapter handshake fix: the inbound bridge filter no
  longer gates on an exact advisory xzbt value (Contract 5.3 §6.5), only on
  its presence/type, matching the host's own envelope validation; the
  adapter now advertises contract minor 3 / version 5.3.0, which it already
  implemented via the 5.3 surfaces field. Root cause of the five failing
  postmessage-interop tests (host hello was silently dropped).
- docs: architecture 6.4 and 6.7A, reference 6.6 and 6.7; evidence logs;
  test-fixtures/PROVENANCE.md resync record

Test results: NGN 154/154 (was 149/154); postmessage-interop 7/7 (was 2/7);
SciFi contract harness 21/21, real-adapter suite 32/32. git diff --check
clean for changed files; two pre-existing trailing-whitespace lines remain
in test-fixtures/reference-exhibits/scifi/index.html, copied verbatim from
the authoritative SciFi source.

Step 6.7 live verification (browser Observation, packaged standalone) is
still pending and is not claimed here.
This commit is contained in:
2026-09-14 19:45:27 -07:00
parent ed76cf6189
commit 745912e451
40 changed files with 5346 additions and 210 deletions
+8
View File
@@ -26,6 +26,14 @@ export class ExhibitConnection {
const frame = this.createFrame(); this.frame = frame;
frame.addEventListener('load', () => {
if (generation !== this.generation) return;
try {
// Redirected entry points establish the session at their final document.
if (frame.contentWindow) this.url = exhibitURL(frame.contentWindow.location.href, this.base);
} catch {
this.disconnect();
this.host.report(new ProtocolError('INVALID_LOCATION', 'Exhibit navigation left the permitted origin.'));
return;
}
clearTimeout(this.timer); this.loading = false; this.attach();
});
this.timer = setTimeout(() => {
+101
View File
@@ -0,0 +1,101 @@
import { resolveSurfaceURL } from './surface-url.js';
import { validateSurfaceCatalog } from './validation.js';
// A resource check catches HTTP errors (iframes fire load even for a 404).
// Redirects are refused before navigation; the local server needs none.
export async function checkSurfaceResource(url, signal) {
const response = await fetch(url, { method: 'HEAD', redirect: 'error', signal });
if (!response.ok) throw new Error(`Surface resource returned HTTP ${response.status}.`);
}
// Presentation frames have no host transport and never establish host sessions.
export class LocalSurfaces {
constructor({ host, createFrame, changed = () => {}, checkResource = checkSurfaceResource, loadTimeoutMs = 15000 }) {
Object.assign(this, { host, createFrame, changed, checkResource, loadTimeoutMs });
this.entries = new Map();
this.unsubscribe = host.subscribe(() => { this.reconcile(); this.changed(); });
this.reconcile();
}
reconcile() {
const base = this.host.exhibitBaseUrl;
const catalog = this.host.status === 'connected' && base ? this.host.surfaces : [];
if (catalog === this.catalog && base === this.base) return;
const valid = validateSurfaceCatalog({ surfaces: catalog }, base);
for (const [id, entry] of this.entries) {
const descriptor = valid.find(s => s.id === id);
if (base !== this.base || !descriptor || descriptor.url !== entry.descriptor.url
|| descriptor.primary !== entry.descriptor.primary) {
this.release(entry); this.entries.delete(id);
}
}
this.catalog = catalog; this.base = base;
for (const descriptor of valid) {
const url = resolveSurfaceURL(descriptor.url, base);
// Full URL equality matters: queries and fragments can designate other views.
const control = descriptor.primary && url === new URL(base).href;
const entry = this.entries.get(descriptor.id) ?? { url, control, state: control ? 'control' : 'closed', frame: null, error: '' };
entry.descriptor = descriptor;
this.entries.set(descriptor.id, entry);
}
}
release(entry) {
entry.attempt = null; clearTimeout(entry.timer); entry.abort?.abort();
// Deterministic, exhibit-agnostic pre-removal lifecycle notice: NGN is
// the only side that knows *for certain* a frame is about to be removed
// or replaced, so it gives the frame one last synchronous chance to
// clean itself up before that happens. This is a direct same-origin
// call, not a postMessage, so it can't be dropped by the frame's
// context going away before a queued message is delivered. A frame
// that defines no such hook (or throws) is unaffected -- this carries
// no exhibit-state knowledge and nothing depends on it running.
try { entry.frame?.contentWindow?.__xzbtSurfaceDispose?.(); } catch { /* best-effort */ }
entry.frame?.remove(); entry.frame = null;
}
close(id) {
const entry = this.entries.get(id);
if (!entry || entry.control) return;
this.release(entry); entry.state = 'closed'; entry.error = ''; this.changed();
}
async open(id, reload = false) {
const entry = this.entries.get(id);
if (!entry || entry.control || (!reload && ['loading', 'open'].includes(entry.state))) return;
this.release(entry);
const attempt = {}; entry.attempt = attempt;
entry.abort = new AbortController(); entry.state = 'loading'; entry.error = '';
const current = () => entry.attempt === attempt && this.entries.get(id) === entry;
const fail = error => {
if (!current()) return;
this.release(entry); entry.state = 'error'; entry.error = error.message; this.changed();
};
entry.timer = setTimeout(() => fail(new Error('Surface load timed out. Check the URL and reload.')), this.loadTimeoutMs);
this.changed();
try {
await this.checkResource(entry.url, entry.abort.signal);
if (!current()) return;
const frame = this.createFrame(entry); entry.frame = frame;
frame.addEventListener('load', () => {
if (!current()) return;
try {
if (new URL(frame.contentWindow.location.href).origin !== new URL(this.base).origin) {
throw new Error('Surface navigated outside the exhibit origin.');
}
clearTimeout(entry.timer); entry.state = 'open'; this.changed();
} catch (error) { fail(error); }
});
frame.addEventListener('error', () => fail(new Error('Surface frame could not load.')));
frame.src = entry.url;
} catch (error) { fail(error); }
}
reload(id) { return this.open(id, true); }
// Called by the UI's container observer if a pane is removed externally.
sweep() {
for (const entry of this.entries.values()) {
if (entry.frame && !entry.frame.isConnected) this.close(entry.descriptor.id);
}
}
dispose() {
this.unsubscribe();
for (const entry of this.entries.values()) this.release(entry);
this.entries.clear();
}
}
+14
View File
@@ -0,0 +1,14 @@
// Contract 5.3 §31.4. Discovery and rendering share this resolver.
export function resolveSurfaceURL(value, exhibitBaseUrl) {
if (typeof value !== 'string' || !value.trim() || value !== value.trim() || /[\u0000-\u001f\u007f]/.test(value)
|| /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(value) || value.startsWith('//')) {
throw new Error('Surface URL must be a same-origin-relative reference (§31.4).');
}
const base = new URL(exhibitBaseUrl);
const resolved = new URL(value, base);
if (!['http:', 'https:'].includes(base.protocol) || resolved.origin !== base.origin
|| resolved.username || resolved.password) {
throw new Error('Surface URL must resolve same-origin with the supplying HTTP exhibit (§31.4).');
}
return resolved.href;
}
+53 -8
View File
@@ -2,6 +2,7 @@ import { ExhibitHost } from './host.js';
import { argumentSchema } from './validation.js';
import { postMessageTransport } from './transport/post-message.js';
import { ExhibitConnection } from './connection.js';
import { LocalSurfaces } from './local-surfaces.js';
const host = new ExhibitHost();
const byId = id => document.getElementById(id);
@@ -9,6 +10,7 @@ const json = value => JSON.stringify(value, null, 2);
const rows = new Map();
let renderedCatalog = null;
let renderedSurfaces = null;
const surfaceRows = new Map();
const connection = new ExhibitConnection({ host, base: location.href, transport: postMessageTransport, changed: render,
createFrame() {
const frame = document.createElement('iframe'); frame.title = 'Connected exhibit';
@@ -17,6 +19,16 @@ const connection = new ExhibitConnection({ host, base: location.href, transport:
return frame;
}
});
const localSurfaces = new LocalSurfaces({ host, changed: render, createFrame(entry) {
const frame = document.createElement('iframe'); frame.title = entry.descriptor.label;
// Like the control frame, append only after assigning src.
queueMicrotask(() => {
if (entry.frame === frame) surfaceRows.get(entry.descriptor.id)?.pane.append(frame);
});
return frame;
} });
new MutationObserver(() => localSurfaces.sweep()).observe(byId('surfaces'), { childList: true, subtree: true });
window.addEventListener('pagehide', () => connection.disconnect());
function element(tag, text, parent) {
const node = document.createElement(tag); if (text !== undefined) node.textContent = text;
parent?.append(node); return node;
@@ -102,18 +114,39 @@ function buildCatalog() {
}
}
function buildSurfaces() {
const container = byId('surfaces'); container.replaceChildren();
// Preserve live panes across registry refreshes; replacing their ancestors
// would unload the documents even if their descriptor URLs did not change.
const container = byId('surfaces');
for (const [id, row] of surfaceRows) if (!localSurfaces.entries.has(id)) {
row.card.remove(); surfaceRows.delete(id);
}
container.querySelectorAll(':scope > p').forEach(node => node.remove());
byId('surface-count').textContent = host.surfaces.length ? `(${host.surfaces.length})` : '';
if (!host.surfaces.length) {
element('p', host.sessionId ? 'No presentation surfaces advertised.' : 'No presentation surfaces advertised.', container);
element('p', 'No presentation surfaces advertised.', container);
return;
}
for (const surface of host.surfaces) {
const card = element('article', undefined, container); card.className = 'surface';
element('h3', surface.label, card);
element('code', surface.id, card);
const badges = element('div', undefined, card);
if (surface.primary) { const b = element('span', 'Primary', badges); b.className = 'surface-badge primary'; }
let row = surfaceRows.get(surface.id);
if (!row) {
const card = element('article', undefined, container); card.className = 'surface';
const metadata = element('div', undefined, card);
const status = element('p', '', card); status.setAttribute('role', 'status');
const controls = element('div', undefined, card); controls.className = 'controls';
const buttons = {};
for (const [label, method] of [['Open', 'open'], ['Reload', 'reload'], ['Close', 'close']]) {
const button = element('button', label, controls); buttons[method] = button;
button.addEventListener('click', () => localSurfaces[method](surface.id));
}
const pane = element('div', undefined, card);
row = { card, metadata, status, buttons, pane };
surfaceRows.set(surface.id, row);
}
row.metadata.replaceChildren();
element('h3', surface.label, row.metadata);
element('code', surface.id, row.metadata);
const badges = element('div', undefined, row.metadata);
const b = element('span', surface.primary ? 'Primary' : 'Non-primary', badges); b.className = 'surface-badge';
if (surface.role) { const b = element('span', `Role: ${surface.role}`, badges); b.className = 'surface-badge'; }
if (surface.category) { const b = element('span', `Category: ${surface.category}`, badges); b.className = 'surface-badge'; }
const meta = [];
@@ -121,7 +154,7 @@ function buildSurfaces() {
if (surface.url) meta.push(`URL: ${surface.url}`);
if (surface.aspectRatio) meta.push(`Aspect ratio: ${surface.aspectRatio}`);
if (surface.requires?.length) meta.push(`Requires: ${surface.requires.join(', ')}`);
if (meta.length) { const p = element('p', meta.join(' · '), card); p.className = 'surface-meta'; }
if (meta.length) { const p = element('p', meta.join(' · '), row.metadata); p.className = 'surface-meta'; }
}
}
function render() {
@@ -148,6 +181,18 @@ function render() {
byId('disconnect').disabled = !connection.frame;
if (host.catalog !== renderedCatalog) { renderedCatalog = host.catalog; buildCatalog(); }
if (host.surfaces !== renderedSurfaces) { renderedSurfaces = host.surfaces; buildSurfaces(); }
for (const [id, row] of surfaceRows) {
const entry = localSurfaces.entries.get(id);
const state = entry?.state ?? 'unavailable';
const requirements = (Array.isArray(entry?.descriptor.requires) ? entry.descriptor.requires : [])
.filter(id => host.capabilities.find(c => c.id === id)?.state !== 'ready');
row.status.textContent = state === 'control'
? 'Open in the authoritative control frame. Use Connection controls to reload or disconnect.'
: `${state}${entry?.error ? ': ' + entry.error : ''}${requirements.length ? ' · Unavailable: ' + requirements.join(', ') : ''}`;
row.buttons.open.disabled = !entry || ['control', 'open', 'loading'].includes(state) || requirements.length > 0;
row.buttons.reload.disabled = !entry || ['control', 'closed'].includes(state);
row.buttons.close.disabled = !entry || ['control', 'closed'].includes(state);
}
for (const row of rows.values()) {
row.current.textContent = host.values.has(row.target.id) ? `Reported value: ${json(host.values.get(row.target.id))}` : 'No persistent value reported.';
if (!row.initialized && row.valueEditor && host.values.has(row.target.id)) {
+11 -26
View File
@@ -1,3 +1,5 @@
import { resolveSurfaceURL } from './surface-url.js';
export class ProtocolError extends Error {
constructor(code, message) { super(message); this.name = 'ProtocolError'; this.code = code; }
}
@@ -80,28 +82,6 @@ export function validateReportedValue(target, value) {
catch (error) { throw new ProtocolError('INVALID_MESSAGE', `${target.id}: ${error.message}`); }
}
}
// 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) {
if (typeof url !== 'string' || url.length === 0) return false;
if (url.indexOf('//') === 0) return false; // protocol-relative
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(url)) return false; // has a scheme
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.
// exhibitBaseUrl is optional; when present, §31.4 same-origin resolution is applied.
function invalidSurfaceEntryReason(d, seenIds, exhibitBaseUrl) {
@@ -112,11 +92,16 @@ function invalidSurfaceEntryReason(d, seenIds, exhibitBaseUrl) {
if (typeof d.label !== 'string' || d.label.length === 0) return 'label is required';
if (d.kind !== 'surface') return 'kind must be the constant "surface"';
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 (exhibitBaseUrl) {
const reason = crossOriginReason(d.url, exhibitBaseUrl);
if (reason) return reason;
for (const field of ['description', 'role', 'aspectRatio', 'category']) {
if (d[field] !== undefined && typeof d[field] !== 'string') return `${field} must be a string`;
}
if (d.requires !== undefined && (!Array.isArray(d.requires) || d.requires.some(id => typeof id !== 'string'))) {
return 'requires must be an array of capability IDs';
}
// Legacy callers without a base get form validation against a synthetic origin.
// This fallback is never used for rendering: LocalSurfaces requires a real base.
try { resolveSurfaceURL(d.url, exhibitBaseUrl ?? 'http://surface-validation.invalid/'); }
catch (error) { return `url: ${error.message}`; }
return null;
}