1/* ES5-SYNTAX DERIVATIVE OF bridge.js; NON-PUBLISHED. 2 * Modern browser APIs remain required. No added polyfills or weaker security fallback. 3 */ 4/* NON-PUBLISHED PROTOTYPE. No Ads/GA requests, cookies, storage or network interception. */ 5(function (root, factory) { 6 if (typeof module === 'object' && module.exports) module.exports = factory(); 7 else if (!root.MFResaBridge) root.MFResaBridge = factory(); 8})(typeof globalThis !== 'undefined' ? globalThis : this, function () { 9 'use strict'; 10 var VERSION = 1; 11 var FRAME_ORIGIN = 'https://app.resa.ninja'; 12 var RESTAURANTS = Object.freeze({ 13 '982a03d3-e02c-4679-abe5-061dd8ec0ca9': Object.freeze({ name: 'Mogador', parentOrigin: 'https://mianfan.fr' }), 14 'd74eb7ec-1313-4b8e-a14e-d9cfe352bac3': Object.freeze({ name: 'Peletier', parentOrigin: 'https://mianfan.fr' }), 15 'ede23ae3-8582-4ccc-83e5-3622db6798ad': Object.freeze({ name: 'Grands Boulevards', parentOrigin: 'https://mianfan.fr' }), 16 'd45d24fc-13a2-430c-9ddb-4749d85a3930': Object.freeze({ name: 'Dumpling Mogador', parentOrigin: 'https://mianfan.fr' }), 17 'ae2e8000-0c76-49bb-8f19-0aeb2b475c0f': Object.freeze({ name: 'Cocottes', parentOrigin: 'https://mianfan.fr' }), 18 'b214aa56-1a8b-4b60-b83e-ed0262d8a3e0': Object.freeze({ name: 'Montparnasse', parentOrigin: 'https://mianfan.fr' }), 19 'a9a56bd0-4f76-460c-94c9-b1bc08c0bd09': Object.freeze({ name: 'Naï Naï', parentOrigin: 'https://nai-nai-restaurant.fr' }) 20 }); 21 var NONCE = /^[0-9a-f]{64}$/; 22 var EVENT_ID = /^evt_[0-9a-f]{32}$/; 23 var parentInstances = new WeakMap(); 24 var frameInstances = new WeakMap(); 25 var normalize = function (text) { return String(text || '').replace(/\s+/g, ' ').trim(); }; 26 var sameKeys = function (object, keys) { return object !== null && typeof object === 'object' && 27 !Array.isArray(object) && Object.keys(object).length === keys.length && 28 keys.every(function (key) { return Object.prototype.hasOwnProperty.call(object, key); }); }; 29 function randomHex(win, byteCount) { 30 if (!win.crypto || typeof win.crypto.getRandomValues !== 'function') { 31 throw new Error('Secure randomness unavailable: bridge disabled.'); 32 } 33 var bytes = new Uint8Array(byteCount); 34 win.crypto.getRandomValues(bytes); 35 return Array.from(bytes, function (b) { return b.toString(16).padStart(2, '0'); }).join(''); 36 } 37 function restaurantFromUrl(href) { 38 try { 39 var url = new URL(href); 40 var match = /^\/booking-iframe\/web\/([0-9a-f-]{36})$/.exec(url.pathname); 41 if (url.origin !== FRAME_ORIGIN || !match || !RESTAURANTS[match[1]]) return null; 42 // Styling query parameters are allowed; unrelated routes are not. 43 return match[1]; 44 } catch (_) { return null; } 45 } 46 function textOnly(node) { 47 return node && Array.from(node.childNodes || []).every(function (child) { return child.nodeType === 3; }); 48 } 49 function directParagraphs(pane) { 50 var children = Array.from(pane.children || []); 51 return children.every(function (child) { return child.tagName === 'P'; }) ? children : []; 52 } 53 function classifyPane(pane) { 54 var p = directParagraphs(pane); 55 if (p.length === 4 && textOnly(p[0]) && textOnly(p[1]) && textOnly(p[3]) && 56 normalize(p[0].textContent) === 'Votre réservation a été soumise au restaurateur.' && 57 normalize(p[1].textContent) === 'Une fois votre réservation validée un email de confirmation vous sera envoyé.' && 58 normalize(p[3].textContent) === 'Bonne journée !') { 59 return { outcome: 'request_recorded', partySize: null }; 60 } 61 if (p.length !== 3 || !textOnly(p[2]) || normalize(p[2].textContent) !== 'Bonne journée !') return null; 62 // The first paragraph is configurable: never inspect it or infer acceptance from it. 63 var nodes = Array.from(p[1].childNodes || []); 64 if (nodes.length !== 4 || nodes[0].nodeType !== 3 || nodes[2].nodeType !== 3 || 65 nodes[1].tagName !== 'B' || nodes[3].tagName !== 'B' || 66 !textOnly(nodes[1]) || !textOnly(nodes[3]) || 67 normalize(nodes[0].textContent) !== 'Nous vous attendons donc le' || 68 normalize(nodes[2].textContent) !== 'pour') return null; 69 // Validate, but never retain or transmit the date/selected service text.
70 if (!/^[A-Za-zÃ-ÿ]+ \d{1,2} [A-Za-zÃ-ÿ]+ \d{4} à \d{1,2}:\d{2}$/.test(normalize(nodes[1].textContent))) return null; 71 var covers = /^([1-9][0-9]{0,2}|1000) couvert\(s\)\.$/.exec(normalize(nodes[3].textContent)); 72 return covers ? { outcome: 'auto_accepted', partySize: Number(covers[1]) } : null; 73 } 74 function readSnapshot(doc) { 75 var roots = doc.querySelectorAll('.fork-booking'); 76 var empty = { valid: false, form: false, final: false, cash: false, error: false, result: null }; 77 if (roots.length !== 1) return empty; 78 var root = roots[0]; 79 var forms = root.querySelectorAll('form.form-cont'); 80 var finals = root.querySelectorAll('.confirmation-cont'); 81 var cash = Boolean(root.querySelector('.form-takeResa-cash')); 82 var error = Boolean(doc.querySelector('.el-notification .el-icon-error, .el-message--error')); 83 var result = null; 84 if (finals.length === 1 && forms.length === 0) { 85 var children = Array.from(finals[0].children || []); 86 if (children.length === 1 && children[0].classList.contains('confirmation')) result = classifyPane(children[0]); 87 } 88 return { valid: forms.length <= 1 && finals.length <= 1, form: forms.length === 1, 89 final: finals.length > 0, cash: cash, error: error, result: result }; 90 } 91 function createCycleTracker(makeEventId) { 92 var cycle = null; 93 var cashBlocked = false; 94 return { 95 observe: function (snapshot) { 96 if (snapshot.cash) cashBlocked = true; 97 if (!snapshot.valid || cashBlocked) return null; 98 if (snapshot.form && !snapshot.final && (!cycle || cycle.done)) { 99 cycle = { eventId: makeEventId(), done: false, failed: false }; 100 } 101 if (!cycle || cycle.done) return null; 102 if (snapshot.error) cycle.failed = true; 103 if (!snapshot.result || snapshot.form || cycle.failed) return null; 104 cycle.done = true; 105 return { eventId: cycle.eventId, outcome: snapshot.result.outcome, partySize: snapshot.result.partySize }; 106 } 107 }; 108 } 109 var baseKeys = ['type', 'version', 'restaurantId', 'nonce']; 110 function validBase(data, type, id, nonce) { 111 return data && data.type === type && data.version === VERSION && data.restaurantId === id && 112 typeof data.nonce === 'string' && NONCE.test(data.nonce) && (nonce === undefined || data.nonce === nonce); 113 } 114 function validEvent(data, id, nonce) { 115 if (!sameKeys(data, baseKeys.concat(['eventId', 'outcome', 'partySize'])) || 116 !validBase(data, 'mf-resa-event', id, nonce) || typeof data.eventId !== 'string' || !EVENT_ID.test(data.eventId)) return false; 117 return data.outcome === 'request_recorded' ? data.partySize === null : 118 data.outcome === 'auto_accepted' && Number.isInteger(data.partySize) && data.partySize >= 1 && data.partySize <= 1000; 119 } 120 121 function createParentBridge(options) { 122 var win = options.window; 123 var iframe = options.iframe; 124 var id = options.restaurantId; 125 var restaurant = RESTAURANTS[id]; 126 if (!restaurant || win.location.origin !== restaurant.parentOrigin || typeof options.onConversion !== 'function') { 127 throw new Error('Invalid parent origin, restaurant or integration callback.'); 128 } 129 if (parentInstances.has(iframe)) throw new Error('A bridge already exists for this iframe.'); 130 var granted = false; 131 var destroyed = false; 132 var nonce = null; 133 var retryTimer = null; 134 var source = null; 135 var seen = new Set(); 136 var report = function (error) { if (typeof options.onError === 'function') options.onError(error); }; 137 var frameMatches = function () { return restaurantFromUrl(iframe.src) === id; }; 138 function stopRetry() { 139 if (retryTimer !== null) win.clearInterval(retryTimer); 140 retryTimer = null; 141 } 142 function sendControl(action) { 143 if (!nonce || !source) return; 144 source.postMessage({ type: 'mf-resa-control', version: VERSION, restaurantId: id, nonce: nonce, action: action }, FRAME_ORIGIN); 145 } 146 function revoke() { 147 granted = false; // Reject already queued events before messaging the iframe. 148 stopRetry(); 149 sendControl('revoke'); 150 nonce = null; 151 source = null; 152 seen.clear(); 153 } 154 function startSession() { 155 stopRetry(); 156 nonce = null; 157 source = null; 158 seen.clear(); 159 // Preserve the explicit choice while waiting for a lazy iframe to load. 160 if (!granted || !frameMatches() || !iframe.contentWindow) return; 161 source = iframe.contentWindow; 162 nonce = randomHex(win, 32); 163 seen.clear(); 164 var attempts = 0; 165 sendControl('grant'); 166 retryTimer = win.setInterval(function () { 167 if (!granted || !frameMatches() || iframe.contentWindow !== source || ++attempts >= 10) { 168 stopRetry(); 169 return; 170 } 171 sendControl('grant'); 172 }, 500); 173 } 174 function receive(event) { 175 if (destroyed || !granted || !frameMatches() || event.origin !== FRAME_ORIGIN || 176 event.source !== iframe.contentWindow) return; 177 var data = event.data; 178 if (sameKeys(data, ['type', 'version', 'restaurantId']) && data.type === 'mf-resa-hello' && 179 data.version === VERSION && data.restaurantId === id) { 180 try { startSession(); } catch (error) { revoke(); report(error); } 181 return; 182 } 183 if (!nonce || iframe.contentWindow !== source || event.source !== source) return; 184 if (sameKeys(data, baseKeys) && validBase(data, 'mf-resa-ready', id, nonce)) { stopRetry(); return; } 185 if (!validEvent(data, id, nonce)) return; 186 if (!seen.has(data.eventId)) { 187 if (seen.size >= 1000) { report(new Error('Session event limit reached.')); return; } 188 seen.add(data.eventId); // At-most-once callback even if the callback fails. 189 try { 190 var result = options.onConversion(Object.freeze({ measurement: 'web_confirmation', 191 eventId: data.eventId, restaurantId: id, restaurantName: restaurant.name, 192 outcome: data.outcome, partySize: data.partySize })); 193 if (result && typeof result.then === 'function') report(new Error('Integration callback must be synchronous.')); 194 } catch (_) { report(new Error('Integration callback failed; event is not retried.')); } 195 } 196 // This ACK means handed off to the callback, not received by Google or matched to a booking. 197 if (granted && nonce === data.nonce && event.source === source) { 198 source.postMessage({ type: 'mf-resa-ack', version: VERSION, restaurantId: id, nonce: nonce, eventId: data.eventId }, FRAME_ORIGIN); 199 } 200 } 201 function onLoad() { 202 if (destroyed || !granted) return;
203 // A navigation can preserve WindowProxy identity: invalidate the old nonce regardless. 204 nonce = null; 205 source = null; 206 try { startSession(); } catch (error) { revoke(); report(error); } 207 } 208 win.addEventListener('message', receive); 209 iframe.addEventListener('load', onLoad); 210 var api = { 211 setConsent: function (value) { 212 if (destroyed) throw new Error('Bridge has been destroyed.'); 213 if (value !== true) { revoke(); return; } 214 if (granted) return; 215 granted = true; 216 try { startSession(); } catch (error) { revoke(); throw error; } 217 }, 218 destroy: function () { 219 if (destroyed) return; 220 revoke(); 221 destroyed = true; 222 win.removeEventListener('message', receive); 223 iframe.removeEventListener('load', onLoad); 224 parentInstances.delete(iframe); 225 } 226 }; 227 parentInstances.set(iframe, api); 228 return api; 229 } 230 231 function createFrameBridge(options) { 232 var win = options.window; 233 var doc = options.document; 234 var id = restaurantFromUrl(win.location.href); 235 if (!id || win.parent === win) throw new Error('Not an allowed restaurant iframe.'); 236 if (frameInstances.has(win)) throw new Error('An iframe bridge is already installed.'); 237 var parentOrigin = RESTAURANTS[id].parentOrigin; 238 var report = function () { if (typeof options.onError === 'function') options.onError(new Error('Iframe bridge initialization/observation failed; disabled.')); }; 239 var active = false; 240 var destroyed = false; 241 var nonce = null; 242 var tracker = null; 243 var observer = null; 244 var retryTimer = null; 245 var pending = new Map(); 246 // Safe exclusion only; this boolean survives refusal, not any booking or user information. 247 var documentCashBlocked = false; 248 function currentRouteAllowed() { return restaurantFromUrl(win.location.href) === id; } 249 function revoke() { 250 active = false; 251 nonce = null; 252 tracker = null; 253 pending.clear(); 254 if (observer) observer.disconnect(); 255 observer = null; 256 if (retryTimer !== null) win.clearInterval(retryTimer); 257 retryTimer = null; 258 } 259 function send(data) { 260 if (!active || !currentRouteAllowed()) { revoke(); return; } 261 win.parent.postMessage(data, parentOrigin); 262 } 263 function scan() { 264 if (!active || !currentRouteAllowed()) { revoke(); return; } 265 try { 266 var snapshot = readSnapshot(doc); 267 if (snapshot.cash) documentCashBlocked = true; 268 if (documentCashBlocked) return; 269 var result = tracker.observe(snapshot); 270 if (!result) return; 271 var data = { type: 'mf-resa-event', version: VERSION, restaurantId: id, nonce: nonce, 272 eventId: result.eventId, outcome: result.outcome, partySize: result.partySize }; 273 pending.set(result.eventId, { data: data, attempts: 1 }); 274 send(data); 275 } catch (_) { revoke(); report(); } 276 } 277 function receive(event) { 278 if (destroyed || event.origin !== parentOrigin || event.source !== win.parent) return; 279 var data = event.data; 280 if (sameKeys(data, baseKeys.concat(['eventId'])) && active && 281 validBase(data, 'mf-resa-ack', id, nonce) && typeof data.eventId === 'string' && EVENT_ID.test(data.eventId)) { 282 pending.delete(data.eventId); 283 return; 284 } 285 if (!sameKeys(data, baseKeys.concat(['action'])) || !validBase(data, 'mf-resa-control', id) || 286 (data.action !== 'grant' && data.action !== 'revoke')) return; 287 if (data.action === 'revoke') { if (data.nonce === nonce) revoke(); return; } 288 if (!currentRouteAllowed()) { revoke(); return; } 289 if (active && data.nonce === nonce) { 290 send({ type: 'mf-resa-ready', version: VERSION, restaurantId: id, nonce: nonce }); 291 return; 292 } 293 revoke(); 294 nonce = data.nonce; 295 active = true; 296 try { 297 if (!doc.body || !win.crypto || typeof win.crypto.getRandomValues !== 'function') throw new Error('Missing prerequisites.'); 298 tracker = createCycleTracker(function () { return 'evt_' + randomHex(win, 16); }); 299 observer = new win.MutationObserver(scan); 300 observer.observe(doc.body, { childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: ['class'] }); 301 // An existing final screen has no preceding observed form: it is never backfilled. 302 scan(); 303 } catch (_) { revoke(); report(); } 304 if (!active) return; 305 send({ type: 'mf-resa-ready', version: VERSION, restaurantId: id, nonce: nonce }); 306 retryTimer = win.setInterval(function () { 307 if (!active || !currentRouteAllowed()) { revoke(); return; } 308 pending.forEach(function (item) { 309 if (item.attempts >= 10) return; 310 item.attempts += 1; 311 send(item.data); // Same event ID on every retry. 312 }); 313 }, 2000); 314 } 315 win.addEventListener('message', receive); 316 var api = { destroy: function () { 317 if (destroyed) return; 318 revoke(); 319 destroyed = true; 320 win.removeEventListener('message', receive); 321 frameInstances.delete(win); 322 } }; 323 frameInstances.set(win, api); 324 // Initialization signal only. No DOM is read and no measurement is buffered before a grant. 325 win.parent.postMessage({ type: 'mf-resa-hello', version: VERSION, restaurantId: id }, parentOrigin); 326 return api; 327 } 328 return Object.freeze({ VERSION: VERSION, RESTAURANTS: RESTAURANTS, FRAME_ORIGIN: FRAME_ORIGIN, 329 restaurantFromUrl: restaurantFromUrl, classifyPane: classifyPane, readSnapshot: readSnapshot, 330 createCycleTracker: createCycleTracker, validEvent: validEvent, 331 createParentBridge: createParentBridge, createFrameBridge: createFrameBridge }); 332});
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.