diff --git a/plugins/browser-inbox/frontend/src/index.js b/plugins/browser-inbox/frontend/src/index.js index 197a43a..e0039de 100644 --- a/plugins/browser-inbox/frontend/src/index.js +++ b/plugins/browser-inbox/frontend/src/index.js @@ -87,6 +87,33 @@ return text(value).trim().replace(/^\/+|\/+$/g, ''); } + function cleanDomain(value) { + return text(value).trim().toLowerCase().replace(/^\.+/, ''); + } + + function domainFromUrl(value) { + try { + return cleanDomain(new URL(text(value).trim()).hostname); + } catch (_) { + return ''; + } + } + + function domainFromCapture(capture) { + return cleanDomain(capture && capture.domain) || domainFromUrl(capture && capture.url); + } + + function normalizeDomainBindings(value) { + var result = {}; + if (!value || typeof value !== 'object' || Array.isArray(value)) return result; + Object.keys(value).forEach(function (domain) { + var normalizedDomain = cleanDomain(domain); + var workspaceRoot = cleanWorkspace(value[domain]); + if (normalizedDomain && workspaceRoot) result[normalizedDomain] = workspaceRoot; + }); + return result; + } + function workspaceFromProps(props) { var node = props && props.workspaceNode; return cleanWorkspace((props && (props.workspaceRootPath || props.workspaceName || props.workspaceNodeId)) @@ -162,6 +189,7 @@ source: text(item.source), browserName: text(item.browserName), workspaceRootPath: cleanWorkspace(item.workspaceRootPath), + workspaceName: cleanWorkspace(item.workspaceName || item.workspaceRootPath), _storageKey: storageKey || '' }; }).slice(0, MAX_CAPTURES); @@ -180,7 +208,8 @@ text: item.text, source: item.source, browserName: item.browserName, - workspaceRootPath: item.workspaceRootPath + workspaceRootPath: item.workspaceRootPath, + workspaceName: item.workspaceName || item.workspaceRootPath || '' }; }); } @@ -228,6 +257,7 @@ var statusClass = ''; var disposed = false; var unsubscribers = []; + var domainBindings = {}; var toolbar = el('div', { className: 'browser-inbox-toolbar' }); var titleEl = el('span', { className: 'browser-inbox-title', textContent: scope.mode === 'global' ? 'Browser Inbox' : 'Browser Inbox ยท ' + scope.label }); @@ -263,9 +293,21 @@ function persist() { if (!api || !api.settings || typeof api.settings.write !== 'function') return Promise.resolve(); - var toStore = scope.mode === 'global' - ? captures.filter(function (item) { return !item._storageKey || item._storageKey === GLOBAL_KEY; }) - : captures; + if (scope.mode === 'global') { + var grouped = {}; + captures.forEach(function (item) { + var key = item._storageKey || GLOBAL_KEY; + grouped[key] = grouped[key] || []; + grouped[key].push(item); + }); + return Promise.all(Object.keys(grouped).map(function (key) { + return api.settings.write(key, storageCaptures(sortCaptures(grouped[key]))); + })).catch(function (err) { + statusText = 'Could not save inbox: ' + (err && err.message ? err.message : String(err)); + statusClass = 'error'; + }); + } + var toStore = captures; return api.settings.write(scope.key, storageCaptures(toStore)).catch(function (err) { statusText = 'Could not save inbox: ' + (err && err.message ? err.message : String(err)); statusClass = 'error'; @@ -302,11 +344,15 @@ } function addCapture(capture) { + capture = applyDomainBinding(capture); + if (scope.mode === 'workspace' && capture.workspaceRootPath && capture.workspaceRootPath !== scope.workspaceRoot) { + return Promise.resolve(); + } var existing = captures.some(function (item) { return item.captureId === capture.captureId; }); if (existing) return Promise.resolve(); - capture._storageKey = scope.key; + capture._storageKey = storageKeyForCapture(capture); captures = sortCaptures([capture].concat(captures)); selectedId = capture.captureId; statusText = 'Capture received'; @@ -314,6 +360,21 @@ return persist().then(render); } + function applyDomainBinding(capture) { + if (!capture || capture.workspaceRootPath) return capture; + var workspaceRoot = domainBindings[domainFromCapture(capture)]; + if (!workspaceRoot) return capture; + capture.workspaceRootPath = workspaceRoot; + capture.workspaceName = workspaceRoot; + return capture; + } + + function storageKeyForCapture(capture) { + var workspaceRoot = cleanWorkspace(capture && capture.workspaceRootPath); + if (workspaceRoot) return WORKSPACE_PREFIX + encodeKey(workspaceRoot); + return scope.key; + } + function removeCapture(captureId) { captures = captures.filter(function (item) { return item.captureId !== captureId; @@ -408,6 +469,7 @@ if (!api || !api.settings || typeof api.settings.read !== 'function') return Promise.resolve(); if (scope.mode === 'global') { return api.settings.read().then(function (settings) { + domainBindings = normalizeDomainBindings((settings || {}).domainBindings); var all = []; globalCaptureKeys(settings || {}).forEach(function (key) { all = all.concat(normalizeStoredCaptures((settings || {})[key], key)); @@ -420,6 +482,7 @@ }); } return api.settings.read().then(function (settings) { + domainBindings = normalizeDomainBindings((settings || {}).domainBindings); var scopedCaptures = normalizeStoredCaptures((settings || {})[scope.key], scope.key); var globalCaptures = normalizeStoredCaptures((settings || {})[GLOBAL_KEY], GLOBAL_KEY).filter(function (item) { return item.workspaceRootPath === scope.workspaceRoot; diff --git a/scripts/smoke-browser-inbox-plugin.js b/scripts/smoke-browser-inbox-plugin.js index ba1b556..e960486 100644 --- a/scripts/smoke-browser-inbox-plugin.js +++ b/scripts/smoke-browser-inbox-plugin.js @@ -334,6 +334,65 @@ async function mountWithApi(api, props = { workspaceNode: { name: 'Project' }, w throw new Error('workspace-tagged global capture leaked into another workspace'); } + const bindingApi = makeApi({ + domainBindings: { + 'client.example.com': 'ClientA', + 'project.example.com': 'Project', + }, + }); + const bindingGlobal = await mountWithApi(bindingApi, {}); + await bindingApi.handlers['browser.capture.page']({ + name: 'browser.capture.page', + timestamp: '2026-06-29T00:00:00Z', + payload: { + captureId: 'bound-client-capture', + capturedAt: '2026-06-29T00:00:00.000Z', + kind: 'page', + url: 'https://client.example.com/page', + title: 'Bound Client Page', + domain: 'client.example.com', + }, + }); + await flush(); + if (bindingApi.getStoredCaptures('captures:workspace:ClientA').length !== 1) { + throw new Error('domain-bound capture was not stored under ClientA workspace key'); + } + if (bindingApi.getStoredCaptures('captures:global').length !== 0) { + throw new Error('domain-bound capture was stored in global queue'); + } + const bindingClient = await mountWithApi(bindingApi, { workspaceNode: { name: 'ClientA' }, workspaceRootPath: 'ClientA' }); + if (!walk(bindingClient.container, (node) => node.getAttribute && node.getAttribute('data-browser-capture-id') === 'bound-client-capture')) { + throw new Error('domain-bound capture was not rendered in bound workspace'); + } + const bindingAggregate = await mountWithApi(bindingApi, {}); + if (!walk(bindingAggregate.container, (node) => node.getAttribute && node.getAttribute('data-browser-capture-id') === 'bound-client-capture')) { + throw new Error('global browser inbox did not aggregate domain-bound capture'); + } + + await bindingApi.handlers['browser.capture.page']({ + name: 'browser.capture.page', + timestamp: '2026-06-29T00:10:00Z', + payload: { + captureId: 'explicit-project-capture', + capturedAt: '2026-06-29T00:10:00.000Z', + kind: 'page', + url: 'https://client.example.com/explicit', + title: 'Explicit Project Page', + domain: 'client.example.com', + workspaceRootPath: 'Project', + }, + }); + await flush(); + if (!bindingApi.getStoredCaptures('captures:workspace:Project').some((capture) => capture.captureId === 'explicit-project-capture')) { + throw new Error('explicit workspace capture was not stored under its payload workspace'); + } + if (bindingApi.getStoredCaptures('captures:workspace:ClientA').some((capture) => capture.captureId === 'explicit-project-capture')) { + throw new Error('domain binding overrode explicit workspaceRootPath'); + } + component.unmount && component.unmount(bindingGlobal.container); + component.unmount && component.unmount(bindingClient.container); + component.unmount && component.unmount(bindingAggregate.container); + console.log('browser inbox plugin smoke passed'); })().catch((err) => { console.error(err);