generated from Labyricorn/labyricorn-project-template
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:
+1
-1
@@ -38,7 +38,7 @@ export class ExhibitConnection {
|
||||
async attach() {
|
||||
if (!this.frame || this.loading) return;
|
||||
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); }
|
||||
this.changed();
|
||||
}
|
||||
|
||||
+4
-4
@@ -13,7 +13,7 @@ export class ExhibitHost {
|
||||
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.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); }
|
||||
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.')); }
|
||||
this.pending.clear(); this.resetView(); this.changed();
|
||||
}
|
||||
async connect(transport) {
|
||||
this.disconnect(); this.transport = transport;
|
||||
async connect(transport, exhibitBaseUrl) {
|
||||
this.disconnect(); this.transport = transport; this.exhibitBaseUrl = exhibitBaseUrl ?? null;
|
||||
this.unsubscribe = transport.subscribe(message => this.receive(message));
|
||||
this.status = 'negotiating'; this.changed();
|
||||
const generation = this.generation;
|
||||
@@ -151,7 +151,7 @@ export class ExhibitHost {
|
||||
if (generation !== this.generation) return;
|
||||
validateCatalog(description);
|
||||
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.changed();
|
||||
}
|
||||
|
||||
+31
-7
@@ -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.
|
||||
// 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
|
||||
@@ -88,8 +89,22 @@ function isRelativeSurfaceUrl(url) {
|
||||
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.
|
||||
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 (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)';
|
||||
@@ -98,6 +113,10 @@ function invalidSurfaceEntryReason(d, seenIds) {
|
||||
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;
|
||||
}
|
||||
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.
|
||||
*
|
||||
* 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).
|
||||
* 3. Count primary:true in the working set:
|
||||
* - Exactly 1 → conformant; return the working set.
|
||||
* - 0 or >1 → reject the whole catalog; log a diagnostic; return [].
|
||||
*
|
||||
* @param {object} message The describe.result message.
|
||||
* @param {function} logFn Optional function(diagnostic:string) called for each diagnostic.
|
||||
* @param {object} message The describe.result message.
|
||||
* @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.
|
||||
*/
|
||||
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 raw = message.surfaces;
|
||||
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 valid = [];
|
||||
for (let i = 0; i < raw.length; i++) {
|
||||
const reason = invalidSurfaceEntryReason(raw[i], seenIds);
|
||||
const reason = invalidSurfaceEntryReason(raw[i], seenIds, exhibitBaseUrl);
|
||||
if (reason) {
|
||||
log(`SURFACE_INVALID: Discarded surfaces[${i}]: ${reason}.`);
|
||||
continue;
|
||||
|
||||
Reference in New Issue
Block a user