clearotron 0.3.2-beta.2 → 0.3.2-beta.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/.env.example +10 -0
  2. package/INSTALL.md +2 -0
  3. package/README.md +15 -10
  4. package/bin/onboard.mjs +4 -4
  5. package/build-info.json +2 -2
  6. package/docs/CLIENT-MCP.md +54 -54
  7. package/docs/DELIVERY.md +16 -15
  8. package/docs/E2E.md +8 -8
  9. package/docs/GLOSSARY.md +2 -2
  10. package/docs/INTAKE.md +11 -11
  11. package/docs/ONBOARDING.md +17 -16
  12. package/docs/PORTAL.md +18 -16
  13. package/docs/README.md +10 -10
  14. package/docs/SECURITY.md +20 -20
  15. package/docs/architecture/01-product-overview.md +17 -16
  16. package/docs/architecture/02-architecture.md +6 -6
  17. package/docs/architecture/03-run-lifecycle.md +7 -7
  18. package/docs/architecture/04-configuration-reference.md +14 -13
  19. package/docs/architecture/05-config-governance.md +30 -29
  20. package/docs/architecture/05-customer-profiles.md +35 -35
  21. package/docs/architecture/06-operations-runbook.md +20 -20
  22. package/docs/architecture/07-quality-and-audit.md +17 -17
  23. package/docs/architecture/08-development-guide.md +3 -3
  24. package/docs/architecture/09-security-and-data.md +27 -27
  25. package/docs/architecture/README.md +1 -1
  26. package/docs/branding.md +8 -3
  27. package/docs/configuration.md +18 -18
  28. package/docs/writing-standard.md +3 -3
  29. package/driver/CHANGELOG.md +27 -0
  30. package/driver/package.json +1 -1
  31. package/driver/portal-service.mjs +34 -4
  32. package/driver/suite-census.json +110 -44
  33. package/mcp-server/CHANGELOG.md +4 -0
  34. package/mcp-server/package.json +1 -1
  35. package/mcp-server/packs/client/CONNECT.md +6 -6
  36. package/package.json +1 -1
  37. package/portal-ui/dist/assets/{index-BsbasHjM.js → index-Bki5jT_N.js} +3376 -2971
  38. package/portal-ui/dist/assets/{index-DNQpLYZF.css → index-De2RFLbT.css} +972 -205
  39. package/portal-ui/dist/index.html +2 -2
  40. package/portal-ui/package.json +1 -1
  41. package/providers/oauth-mcp-bridge/CHANGELOG.md +4 -0
  42. package/providers/oauth-mcp-bridge/package.json +1 -1
  43. package/scripts/ask-ai-render-check.mjs +287 -105
  44. package/scripts/release-note-required.mjs +16 -2
  45. package/scripts/settings-render-check.mjs +575 -0
  46. package/shared/brand.mjs +29 -0
  47. package/shared/connect-clients.mjs +99 -47
  48. package/shared/names-in-force.mjs +1 -0
  49. package/shared/stdio-connect.mjs +16 -2
  50. package/shared/writing-standard-classes.mjs +34 -3
@@ -0,0 +1,575 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright 2026 Cordillera Sàrl. Additional terms under section 7 of the AGPL-3.0 apply — see ADDITIONAL-TERMS.md
3
+ //
4
+ // settings-render-check.mjs — Installation settings, Preferences, About and the sign-in page, drawn in a
5
+ // real browser, in both themes.
6
+ //
7
+ // node scripts/settings-render-check.mjs [--shot-dir <dir>] [--quiet]
8
+ //
9
+ // WHY A BROWSER. The tests beside these screens read their source and drive their contract, and neither
10
+ // can see what this can: that no variable name, credential path or README reference reaches the text a
11
+ // reader sees; that a blur turned on survives a reload with its button still pressed, including in a
12
+ // browser whose storage refuses; that the administrator's help on the sign-in card really is folded shut;
13
+ // and that a long reset command stays inside the card.
14
+ //
15
+ // THE FIXTURES HAVE ONE AUTHOR. The settings rows are the driver's own inventory run against a fixture
16
+ // environment, the sign-in page is `loginPage` itself, and About's facts are `productIdentity`. A
17
+ // hand-written row would carry only the detail its author remembered; these carry the detail the product
18
+ // writes, so "none of it reaches the page" is asserted against the real thing.
19
+ //
20
+ // With `--shot-dir`, one full-height PNG per state, in light and dark, at 1280 wide. `--quiet` leaves out
21
+ // the closing dump of everything the probes read.
22
+
23
+ import { navigateOrRefuse } from './headless-page.mjs' // Page.navigate returns an errorText, and nothing read it
24
+ import { createServer } from 'node:http'
25
+ import { reapOnExit } from '../shared/reap-on-exit.mjs' // — a detached group dies with this script
26
+ import { readFileSync, existsSync, rmSync, writeFileSync, mkdtempSync, mkdirSync } from 'node:fs'
27
+ import { join, extname, dirname } from 'node:path'
28
+ import { fileURLToPath } from 'node:url'
29
+ import { spawn } from 'node:child_process'
30
+ import { engineInventory, providerInventory } from '../driver/config-inventory.mjs'
31
+ import { authView } from '../driver/portal-config-view.mjs'
32
+ import { loginPage, LOGIN_IN_FRONT_DOC } from '../driver/portal-service.mjs'
33
+ import { productIdentity } from '../shared/product-identity.mjs'
34
+ import { browserRun } from '../shared/browser-temp-root.mjs'
35
+
36
+ const REPO = join(dirname(fileURLToPath(import.meta.url)), '..')
37
+ const DIST = join(REPO, 'portal-ui', 'dist')
38
+ if (!existsSync(join(DIST, 'index.html'))) {
39
+ console.error('settings-render-check: portal-ui/dist is not built. Run `npm run build:ui` first.')
40
+ process.exit(2)
41
+ }
42
+ const shotDir = process.argv.includes('--shot-dir') ? process.argv[process.argv.indexOf('--shot-dir') + 1] : null
43
+ if (shotDir) mkdirSync(shotDir, { recursive: true })
44
+ const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.svg': 'image/svg+xml', '.json': 'application/json' }
45
+
46
+ // ── the fixtures ─────────────────────────────────────────────────────────────────────────────────────
47
+
48
+ const EMAIL = 'dana@northwind.example'
49
+ const CONTACT = 'mailto:it@northwind.example'
50
+ const ISSUER = 'https://login.example-firm.test/'
51
+ const LONG_RESET = 'npx clearotron@0.3.0-beta.5 passphrase --reset --base $HOME/trademark-demo'
52
+ const BLUR_KEY = 'cordillera-blur-names'
53
+
54
+ // The deployment the board draws: a register and the research key set, the open-web key not, no case-law
55
+ // sign-in, the engine on a subscription. The values are inert fixture words and never leave this process:
56
+ // the inventory records names and states, never a value.
57
+ const credsDir = mkdtempSync(join(process.env.TMPDIR || '/tmp', 'settings-creds-'))
58
+ const deployment = {
59
+ CLEAROTRON_DATABASE: 'corsearch', CORSEARCH_SESSION_KEY: 'fixture-not-a-key', PERPLEXITY_API_KEY: 'fixture-not-a-key',
60
+ OAUTH_BRIDGE_CREDS_DIR: credsDir, PATH: process.env.PATH ?? '', HOME: process.env.HOME ?? '',
61
+ }
62
+ const adminConfig = {
63
+ available: true, note: null, source: 'live', lastRun: null, built: null, flags: [],
64
+ // The program is PRESENT here whatever this machine has, because the board's deployment runs searches;
65
+ // the engine row's red states belong to the engine-state tests, not to this page's layout.
66
+ engine: { ...engineInventory(deployment), binaryPresent: true, program: 'claude', install: 'npm install -g @anthropic-ai/claude-code' },
67
+ providers: providerInventory(deployment),
68
+ auth: authView({ mode: 'auth-proxy', oidcIssuer: ISSUER }),
69
+ }
70
+ const about = productIdentity()
71
+
72
+ const state = { contact: CONTACT, reset: null }
73
+ // THE PAGE'S OWN LOADS, COUNTED. A screen that asks the server twice paints exactly like one that asks once,
74
+ // and each request spends the person's rate budget, so the only instrument that sees a double load is a count.
75
+ const loads = []
76
+
77
+ const server = createServer((req, res) => {
78
+ const path = new URL(req.url, 'http://localhost').pathname
79
+ const json = (o) => { res.writeHead(200, { 'content-type': 'application/json' }); res.end(JSON.stringify(o)) }
80
+ // THE ADMIN ADDRESSES ARE BOTH A SCREEN AND ITS DATA, decided by what the caller asked for — the rule
81
+ // driver/portal-static.mjs applies: the app's own fetches ask for JSON, a browser navigation for HTML.
82
+ const wantsPage = String(req.headers.accept ?? '').includes('text/html')
83
+ if (!wantsPage && (path === '/portal/admin/config' || path === '/portal/api/about')) loads.push(path)
84
+ if (path === '/portal/api/me') {
85
+ return json({ permissions: { run: true, manage: true }, email: EMAIL, accounts: '*',
86
+ access: [{ kind: 'everything' }], organisations: [{ key: 'northwind', name: 'Northwind Group' }],
87
+ accountNames: { northwind: 'Northwind Foods' }, brand: 'Northwind Group', administratorContact: state.contact })
88
+ }
89
+ if (path === '/portal/admin/config' && !wantsPage) return json(adminConfig)
90
+ if (path === '/portal/api/about') return json(about)
91
+ if (path === '/portal/admin/roster' && !wantsPage) return json({ customers: [{ key: 'northwind', name: 'Northwind Foods' }] })
92
+ if (path === '/portal/login') {
93
+ res.writeHead(200, { 'content-type': 'text/html' })
94
+ return res.end(loginPage({ email: EMAIL, resetCommand: state.reset }))
95
+ }
96
+ if (path.startsWith('/portal/api/') || (path.startsWith('/portal/admin/') && !wantsPage)) return json({})
97
+ const file = path === '/' || (path.startsWith('/portal') && !path.includes('.')) ? '/index.html' : path.replace(/^\/portal/, '')
98
+ const full = join(DIST, file)
99
+ if (!existsSync(full)) { res.writeHead(404); res.end('no'); return }
100
+ res.writeHead(200, { 'content-type': MIME[extname(full)] ?? 'application/octet-stream' })
101
+ res.end(readFileSync(full))
102
+ })
103
+ await new Promise((r) => server.listen(0, '127.0.0.1', r))
104
+ const origin = `http://127.0.0.1:${server.address().port}`
105
+
106
+ // ── the browser ──────────────────────────────────────────────────────────────────────────────────────
107
+
108
+ const { profile: userDir, env: chromeEnv } = browserRun('settings-check-')
109
+ const chrome = spawn('google-chrome', [
110
+ '--headless=new', '--disable-gpu', '--no-sandbox', '--hide-scrollbars',
111
+ `--user-data-dir=${userDir}`, '--window-size=1280,900', '--remote-debugging-port=0', 'about:blank',
112
+ ], { stdio: ['ignore', 'pipe', 'pipe'], detached: true, env: chromeEnv })
113
+ reapOnExit(chrome)
114
+
115
+ let devtools = ''
116
+ const wsUrl = await new Promise((resolve, reject) => {
117
+ const t = setTimeout(() => reject(new Error(`chrome did not report a devtools endpoint within 60s. Its stderr was:\n${devtools || '(nothing)'}`)), 60000)
118
+ chrome.stderr.on('data', (c) => {
119
+ devtools += c
120
+ const m = devtools.match(/ws:\/\/[^\s]+/)
121
+ if (m) { clearTimeout(t); resolve(m[0]) }
122
+ })
123
+ })
124
+ const ws = new WebSocket(wsUrl)
125
+ let id = 0
126
+ const pending = new Map()
127
+ ws.addEventListener('message', (e) => { const m = JSON.parse(e.data); if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id) } })
128
+ await new Promise((r) => ws.addEventListener('open', r))
129
+ const send = (method, params = {}) => new Promise((r) => { const i = ++id; pending.set(i, r); ws.send(JSON.stringify({ id: i, method, params })) })
130
+ const { result: targets } = await send('Target.getTargets')
131
+ const page = targets.targetInfos.find((t) => t.type === 'page')
132
+ const { result: sess } = await send('Target.attachToTarget', { targetId: page.targetId, flatten: true })
133
+ const sessionId = sess.sessionId
134
+ const cmd = (method, params = {}) => new Promise((r) => { const i = ++id; pending.set(i, r); ws.send(JSON.stringify({ id: i, sessionId, method, params })) })
135
+ await cmd('Page.enable')
136
+ // A probe that throws says so, rather than returning nothing for the assertions after it to misread.
137
+ const evalIn = async (expr) => {
138
+ const r = (await cmd('Runtime.evaluate', { expression: expr, awaitPromise: true, returnByValue: true })).result
139
+ if (r?.exceptionDetails) console.error(` ! a probe threw: ${r.exceptionDetails.exception?.description ?? r.exceptionDetails.text}`)
140
+ return r?.result?.value
141
+ }
142
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
143
+
144
+ /** Wait for a condition in the page, and say so when it never came, rather than measuring a half-drawn page. */
145
+ async function settle(condition, what, ms = 8000) {
146
+ const until = Date.now() + ms
147
+ while (Date.now() < until) {
148
+ if (await evalIn(`(() => { try { return !!(${condition}) } catch { return false } })()`)) return true
149
+ await sleep(100)
150
+ }
151
+ failures++
152
+ console.error(` ✗ ${what} — never happened`)
153
+ return false
154
+ }
155
+
156
+ async function open(path, ready, what) {
157
+ await cmd('Page.navigate', { url: 'about:blank' })
158
+ await sleep(100)
159
+ await navigateOrRefuse(cmd, `${origin}${path}`, { what: 'settings-render-check' })
160
+ return settle(ready, what)
161
+ }
162
+
163
+ // A RELOAD IS NOT DONE WHEN IT IS ASKED FOR. `Page.reload` returns while the old document is still up, and
164
+ // a condition read then is read off the page being replaced. The old document is marked first, so the wait
165
+ // is for a new document that is ready rather than for an old one that already was.
166
+ async function reload(ready, what) {
167
+ await evalIn('window.__beforeReload = true')
168
+ await cmd('Page.reload', {})
169
+ return settle(`!window.__beforeReload && (${ready})`, what)
170
+ }
171
+
172
+ async function setTheme(theme) {
173
+ await evalIn(`(() => { document.documentElement.setAttribute('data-theme', ${JSON.stringify(theme)}); try { localStorage.setItem('cordillera-theme', ${JSON.stringify(theme)}) } catch {} return true })()`)
174
+ await sleep(250)
175
+ }
176
+
177
+ // A FULL-HEIGHT VIEWPORT, not a beyond-viewport capture: a beyond-viewport capture paints the sticky rail
178
+ // and top bar where the 900px viewport put them.
179
+ async function capture(name) {
180
+ if (!shotDir) return
181
+ await evalIn('window.scrollTo(0, 0)')
182
+ const h = await evalIn('Math.max(document.documentElement.scrollHeight, document.body.scrollHeight)') ?? 900
183
+ await cmd('Emulation.setDeviceMetricsOverride', { width: 1280, height: Math.max(900, h), deviceScaleFactor: 1, mobile: false })
184
+ await sleep(400)
185
+ const shot = await cmd('Page.captureScreenshot', { format: 'png' })
186
+ await cmd('Emulation.clearDeviceMetricsOverride', {})
187
+ const data = shot.result?.data
188
+ if (!data) throw new Error(`no screenshot came back for ${name}`)
189
+ writeFileSync(join(shotDir, `${name}.png`), Buffer.from(data, 'base64'))
190
+ shots.push(`${name}.png`)
191
+ }
192
+
193
+ /** A real press, with the mouse, where the reader would press. */
194
+ async function press(selector) {
195
+ const at = await evalIn(`(() => {
196
+ const el = document.querySelector(${JSON.stringify(selector)})
197
+ if (!el) return null
198
+ el.scrollIntoView({ block: 'center' })
199
+ const b = el.getBoundingClientRect()
200
+ return { x: Math.round(b.left + b.width / 2), y: Math.round(b.top + b.height / 2) }
201
+ })()`)
202
+ if (!at) return false
203
+ for (const type of ['mousePressed', 'mouseReleased']) {
204
+ await cmd('Input.dispatchMouseEvent', { type, x: at.x, y: at.y, button: 'left', clickCount: 1 })
205
+ }
206
+ await sleep(250)
207
+ return true
208
+ }
209
+
210
+ let failures = 0
211
+ const shots = []
212
+ const ok = (cond, msg) => { if (!cond) { failures++; console.error(` ✗ ${msg}`) } else console.log(` ✓ ${msg}`) }
213
+ const same = (a, b) => JSON.stringify(a) === JSON.stringify(b)
214
+
215
+ // What a reader must never see on Installation settings. Scanned over the text the page draws, so the
216
+ // rows the server sent — which DO carry this detail — are what is being tested.
217
+ const LEAKS = `[/\\b[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+\\b/, /(?<![:\\w\\/.])(?:~|\\.{1,2})?\\/[\\w.-]+\\/[\\w.\\/-]+|\\.json\\b/, /README|\\.md\\b/i]`
218
+
219
+ // ── Installation settings ────────────────────────────────────────────────────────────────────────────
220
+
221
+ const settingsProbe = `(() => {
222
+ const flat = (s) => (s ?? '').replace(/\\s+/g, ' ').trim()
223
+ const screen = document.querySelector('.screen')
224
+ const groups = [...screen.querySelectorAll('.cfg-group')]
225
+ const row = (r) => ({
226
+ name: flat(r.querySelector('.cfg-name')?.textContent), val: flat(r.querySelector('.cfg-val')?.textContent) || null,
227
+ state: flat(r.querySelector('.cfg-state')?.textContent),
228
+ notes: [...r.querySelectorAll('.cfg-note')].map((n) => flat(n.textContent)),
229
+ mono: [...r.querySelectorAll('.cfg-note.mono')].map((n) => flat(n.textContent)),
230
+ guide: r.querySelector('.cfg-act a.pill') ? { text: flat(r.querySelector('.cfg-act a.pill').textContent), href: r.querySelector('.cfg-act a.pill').getAttribute('href'), target: r.querySelector('.cfg-act a.pill').getAttribute('target') } : null,
231
+ stateRight: Math.round(r.querySelector('.cfg-state').getBoundingClientRect().right),
232
+ })
233
+ const pageText = document.body.innerText
234
+ return {
235
+ renderedChars: flat(screen.innerText).length,
236
+ title: flat(screen.querySelector('h1.page-title')?.textContent),
237
+ sections: groups.map((g) => flat(g.querySelector(':scope > .eyebrow')?.textContent)),
238
+ signIn: [...(groups[0]?.querySelectorAll('.cfg-row') ?? [])].map(row),
239
+ engine: [...(groups[1]?.querySelectorAll('.cfg-row') ?? [])].map(row),
240
+ categories: [...(groups[2]?.querySelectorAll('.cfg-cat') ?? [])].map((c) => ({
241
+ label: flat(c.querySelector('.cfg-sub')?.textContent),
242
+ note: flat(c.querySelector('.cfg-cat-note')?.textContent) || null,
243
+ rows: [...c.querySelectorAll('.cfg-row')].map(row),
244
+ })),
245
+ leaks: ${LEAKS}.flatMap((re) => { const m = re.exec(pageText); return m ? [m[0]] : [] }),
246
+ }
247
+ })()`
248
+
249
+ const menuProbe = `(async () => {
250
+ const avatar = document.querySelector('button[aria-label="Settings and about"]')
251
+ if (!avatar) return null
252
+ avatar.click()
253
+ await new Promise((r) => setTimeout(r, 300))
254
+ const items = [...document.querySelectorAll('[role="menu"] [role="menuitem"]')].map((b) => b.textContent.trim())
255
+ avatar.click()
256
+ return items
257
+ })()`
258
+
259
+ // ── Preferences ──────────────────────────────────────────────────────────────────────────────────────
260
+
261
+ const preferencesProbe = `(() => {
262
+ const flat = (s) => (s ?? '').replace(/\\s+/g, ' ').trim()
263
+ const screen = document.querySelector('.screen')
264
+ const link = screen.querySelector('.pref-line a')
265
+ const eye = screen.querySelector('.pref-blur button')
266
+ const topEye = document.querySelector('header.topbar button[aria-label="Blur names for screen sharing"]')
267
+ const address = screen.querySelector('.pref-dl dd[data-anon="mark"]')
268
+ let stored = null
269
+ try { stored = localStorage.getItem('${BLUR_KEY}') } catch { stored = 'refused' }
270
+ return {
271
+ renderedChars: flat(screen.innerText).length,
272
+ title: flat(screen.querySelector('h1.page-title')?.textContent),
273
+ cards: [...screen.querySelectorAll('.pref-card > .pref-title')].map((h) => flat(h.textContent)),
274
+ rows: [...screen.querySelectorAll('.pref-dl dt')].map((d) => flat(d.textContent) + ': ' + flat(d.nextElementSibling?.textContent)),
275
+ lines: [...screen.querySelectorAll('.pref-line')].map((p) => flat(p.textContent)),
276
+ link: link ? { text: flat(link.textContent), href: link.getAttribute('href') } : null,
277
+ logOut: screen.querySelector('a.pill[href="/portal/sign-out"]') ? flat(screen.querySelector('a.pill[href="/portal/sign-out"]').textContent) : null,
278
+ blurLine: flat(screen.querySelector('.pref-blur-line')?.textContent),
279
+ eye: eye ? { label: eye.getAttribute('aria-label'), pressed: eye.getAttribute('aria-pressed'), text: flat(eye.textContent), cls: eye.className } : null,
280
+ topEye: topEye ? { pressed: topEye.getAttribute('aria-pressed'), cls: topEye.className, icon: topEye.innerHTML === eye?.innerHTML } : null,
281
+ blurredDocument: document.documentElement.classList.contains('anon-on'),
282
+ addressFilter: address ? getComputedStyle(address).filter : null,
283
+ // EVERY PLACE THE COMPANY'S NAME IS DRAWN, anywhere on the page — the rail and the top bar as well as
284
+ // the screen — that nothing marks for the blur. "It covers every mark and company on screen" is a
285
+ // promise about the whole page, and the rail's switcher once broke it with nothing measuring it.
286
+ unmarkedCompany: (() => {
287
+ const out = []
288
+ const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT)
289
+ for (let n = walker.nextNode(); n; n = walker.nextNode()) {
290
+ if (!n.textContent.includes('Northwind Foods')) continue
291
+ const el = n.parentElement
292
+ if (!el || el.closest('[data-anon="mark"]')) continue
293
+ out.push((el.tagName || '?').toLowerCase() + (el.className ? '.' + String(el.className).split(' ')[0] : ''))
294
+ }
295
+ return out
296
+ })(),
297
+ companyDrawn: document.body.innerText.includes('Northwind Foods') || [...document.querySelectorAll('option')].some((o) => o.textContent.includes('Northwind Foods')),
298
+ stored,
299
+ notKept: /not kept|starts switched off|until you reload|every time you open|These two settings stay/i.test(screen.innerText),
300
+ theme: document.documentElement.getAttribute('data-theme'),
301
+ }
302
+ })()`
303
+
304
+ // ── About ────────────────────────────────────────────────────────────────────────────────────────────
305
+
306
+ const aboutProbe = `(() => {
307
+ const flat = (s) => (s ?? '').replace(/\\s+/g, ' ').trim()
308
+ const screen = document.querySelector('.screen')
309
+ const dts = [...screen.querySelectorAll('.about-dl dt')]
310
+ const dd = (label) => dts.find((d) => flat(d.textContent) === label)?.nextElementSibling
311
+ return {
312
+ renderedChars: flat(screen.innerText).length,
313
+ title: flat(screen.querySelector('h1.page-title')?.textContent),
314
+ rows: dts.map((d) => flat(d.textContent)),
315
+ build: flat(dd('Build')?.querySelector('.mono')?.textContent) || null,
316
+ buildFont: dd('Build')?.querySelector('.mono') ? getComputedStyle(dd('Build').querySelector('.mono')).fontFamily : null,
317
+ source: dd('Source')?.querySelector('a') ? { text: flat(dd('Source').querySelector('a').textContent), href: dd('Source').querySelector('a').getAttribute('href'), lines: dd('Source').querySelector('a').getClientRects().length } : null,
318
+ fullText: dd('Licence')?.querySelector('a.pill') ? { text: flat(dd('Licence').querySelector('a.pill').textContent), href: dd('Licence').querySelector('a.pill').getAttribute('href') } : null,
319
+ copyright: flat(dd('Copyright')?.textContent),
320
+ pills: [...screen.querySelectorAll('.about-docs a.pill')].map((a) => flat(a.textContent)),
321
+ }
322
+ })()`
323
+
324
+ // ── Sign in ──────────────────────────────────────────────────────────────────────────────────────────
325
+
326
+ const signInProbe = `(() => {
327
+ const flat = (s) => (s ?? '').replace(/\\s+/g, ' ').trim()
328
+ const card = document.querySelector('.card')
329
+ const fold = card?.querySelector('details.fold')
330
+ const hints = fold ? [...fold.querySelectorAll('.hint')] : []
331
+ const code = fold?.querySelector('code')
332
+ const cs = card ? getComputedStyle(card) : null
333
+ const inner = card ? (() => { const b = card.getBoundingClientRect(); return { left: b.left + parseFloat(cs.paddingLeft), right: b.right - parseFloat(cs.paddingRight) } })() : null
334
+ const codeRects = code ? [...code.getClientRects()].map((r) => ({ left: Math.round(r.left), right: Math.round(r.right) })) : []
335
+ return {
336
+ heading: flat(card?.querySelector('h1')?.textContent),
337
+ identity: flat(card?.querySelector('h1 + p')?.textContent),
338
+ field: !!card?.querySelector('input#passphrase[type="password"]'),
339
+ button: flat(card?.querySelector('form button[type="submit"]')?.textContent),
340
+ lead: flat(card?.querySelector('p.lead')?.textContent),
341
+ leadBold: !!card?.querySelector('p.lead > b'),
342
+ summary: flat(fold?.querySelector('summary')?.textContent),
343
+ open: fold ? fold.open : null,
344
+ // A closed fold keeps its content laid out but hidden, so a rectangle is not the test; visibility is.
345
+ hintsShown: hints.filter((h) => h.checkVisibility()).length,
346
+ hints: hints.map((h) => flat(h.textContent)),
347
+ setUp: fold?.querySelector('a') ? { text: flat(fold.querySelector('a').textContent), href: fold.querySelector('a').getAttribute('href') } : null,
348
+ code: flat(code?.textContent),
349
+ // The card's own width: its content box, which the page caps at 420px.
350
+ cardWidth: card ? Math.round(card.getBoundingClientRect().width - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight) - parseFloat(cs.borderLeftWidth) - parseFloat(cs.borderRightWidth)) : null,
351
+ codeInside: !!inner && codeRects.length > 0 && codeRects.every((r) => r.left >= inner.left - 1 && r.right <= inner.right + 1),
352
+ codeLines: codeRects.length,
353
+ overflowsPage: document.documentElement.scrollWidth > document.documentElement.clientWidth,
354
+ }
355
+ })()`
356
+
357
+ const out = {}
358
+ const SPEC = {
359
+ signInName: "Through your organisation's sign-in service",
360
+ webSearch: ['The engine’s own web search', 'provided by the engine', 'Available', 'The model searches the web itself during a run.'],
361
+ categories: ['Trademark register', 'Common-law and marketplace research', 'Open-web search', 'Case law and oppositions'],
362
+ caseLawGap: 'Until these are set up, a Full country search still runs and its report discloses the case-law gap instead of reporting no adverse case law.',
363
+ adminLine: 'To change the address, the permissions or the companies on it, contact your Clearotron administrator.',
364
+ blurLine: 'On the top bar. It covers every mark and company on screen, and an open report whole. It stays as you left it on this computer.',
365
+ reset: 'Lost the passphrase? Run clearotron passphrase --reset on the machine running this portal. It mints a new one and prints it once.',
366
+ addPeople: 'To add people, put it behind a login system such as your company single sign-on. How to set that up',
367
+ }
368
+
369
+ for (const theme of ['light', 'dark']) {
370
+ console.log(`\n── ${theme} ──`)
371
+
372
+ // ── Installation settings
373
+ console.log('\nInstallation settings:')
374
+ loads.length = 0
375
+ if (await open('/portal/admin/config', "document.querySelectorAll('.cfg-row').length >= 10", 'Installation settings drew its rows')) {
376
+ await setTheme(theme)
377
+ const s = (await evalIn(settingsProbe)) ?? {}
378
+ out[`settings-${theme}`] = s
379
+ ok(s.renderedChars > 400, `the page rendered real text (${s.renderedChars} chars) — every absence below is worthless without it`)
380
+ ok(s.title === 'Installation settings', `the page is titled "Installation settings" (saw "${s.title}")`)
381
+ ok(same(s.sections, ['Sign-in', 'Engine', 'Providers']), `three sections: Sign-in, Engine, Providers (saw ${JSON.stringify(s.sections)})`)
382
+ ok(s.signIn.length === 1, `Sign-in is one row (saw ${s.signIn.length})`)
383
+ const si = s.signIn[0]
384
+ ok(si && si.name === SPEC.signInName && si.val === null && si.state === 'Configured',
385
+ `the sign-in row reads "${SPEC.signInName} · Configured" (saw ${JSON.stringify(si)})`)
386
+ ok(si && same(si.mono, [`auth-proxy · issuer ${ISSUER}`]), `the mode and issuer are the row's quiet mono line (saw ${JSON.stringify(si?.mono)})`)
387
+ const rights = [...s.signIn, ...s.engine, ...s.categories.flatMap((c) => c.rows)].map((r) => r.stateRight)
388
+ ok(rights.length >= 10 && new Set(rights).size === 1, `every state lines up in one column (right edges ${JSON.stringify([...new Set(rights)])})`)
389
+ ok(s.engine.length === 2 && s.engine[0].name === 'Anthropic' && s.engine[0].state === 'Subscription',
390
+ `Engine carries the engine row first (saw ${JSON.stringify(s.engine[0])})`)
391
+ const web = s.engine[1]
392
+ ok(web && same([web.name, web.val, web.state, web.notes[0]], SPEC.webSearch),
393
+ `the engine's own web search sits under Engine, in the page's words (saw ${JSON.stringify(web)})`)
394
+ ok(same(s.categories.map((c) => c.label), SPEC.categories), `four provider categories, in order (saw ${JSON.stringify(s.categories.map((c) => c.label))})`)
395
+ const repeated = s.categories.flatMap((c) => c.rows.filter((r) => [r.name, r.val ?? '', ...r.notes].some((t) => t.includes(c.label))).map((r) => `${c.label} on ${r.name}`))
396
+ ok(repeated.length === 0, `no category name is repeated on a row (saw ${JSON.stringify(repeated)})`)
397
+ ok(!s.categories.flatMap((c) => c.rows).some((r) => /web search/i.test(r.name)), 'the engine\'s web search is not also listed as a provider')
398
+ const rows = Object.fromEntries(s.categories.flatMap((c) => c.rows).map((r) => [r.name, r]))
399
+ ok(rows.Corsearch?.state === 'Configured' && rows.Perplexity?.state === 'Configured', 'the configured register and research key read Configured')
400
+ ok(rows.SerpAPI?.state === 'Missing' && same(rows.SerpAPI?.notes, ['A key is needed'])
401
+ && rows.SerpAPI?.guide?.text === 'Setup guide' && /providers\/README\.md#research-and-support-sources$/.test(rows.SerpAPI?.guide?.href ?? '') && rows.SerpAPI?.guide?.target === '_blank',
402
+ `a missing key says "A key is needed" beside a Setup guide that opens its guide (saw ${JSON.stringify(rows.SerpAPI)})`)
403
+ for (const name of ['CourtListener', 'Legal Data Hunter']) {
404
+ ok(rows[name]?.state === 'Not set up' && same(rows[name]?.notes, ['One-time sign-in needed']) && rows[name]?.guide?.text === 'Setup guide'
405
+ && /oauth-mcp-bridge\/README\.md#one-time-setup-per-remote-mcp-server$/.test(rows[name]?.guide?.href ?? ''),
406
+ `${name} says "One-time sign-in needed" beside its Setup guide (saw ${JSON.stringify(rows[name])})`)
407
+ }
408
+ ok(rows.CourtListener?.val === 'US federal case law' && rows['Legal Data Hunter']?.val === 'statutes and case law, 108 countries' && rows['EUR-Lex']?.val === 'EU judgments',
409
+ 'each case-law source says what it covers, without its web address')
410
+ const caseLaw = s.categories.find((c) => c.label === 'Case law and oppositions')
411
+ ok(caseLaw?.note === SPEC.caseLawGap, `what an unset case-law source costs is said under the category (saw ${JSON.stringify(caseLaw?.note)})`)
412
+ ok(!s.categories.flatMap((c) => c.rows).some((r) => r.notes.some((n) => /case-law gap/.test(n))), 'and not again on a row')
413
+ const boa = rows['EUIPO Boards of Appeal']
414
+ ok(boa?.state === 'Not in this build' && same(boa?.notes, ['Reports covering the EU say so']) && boa?.guide === null,
415
+ `a source this build does not ship says which reports disclose it, and offers no guide (saw ${JSON.stringify(boa)})`)
416
+ ok(s.leaks.length === 0, `no variable name, credential path or README reference anywhere on the page (saw ${JSON.stringify(s.leaks)})`)
417
+ const menu = (await evalIn(menuProbe)) ?? {}
418
+ ok(Array.isArray(menu) && menu.includes('Installation settings') && !menu.includes('Global config'),
419
+ `the avatar menu names the page "Installation settings" (saw ${JSON.stringify(menu)})`)
420
+ await capture(`installation-settings-${theme}`)
421
+ // Counted after the Setup guide links were read, which needs the second load to have answered.
422
+ await sleep(500)
423
+ const asked = (route) => loads.filter((l) => l === route).length
424
+ ok(asked('/portal/admin/config') === 1 && asked('/portal/api/about') === 1,
425
+ `one visit asks for the settings once and for the source address once (saw ${JSON.stringify(loads)})`)
426
+ }
427
+
428
+ // ── Preferences, contact set and unset
429
+ for (const [label, contact] of [['contact-set', CONTACT], ['contact-unset', null]]) {
430
+ console.log(`\nPreferences, ${label}:`)
431
+ state.contact = contact
432
+ if (!(await open('/portal/preferences', "document.querySelector('.pref-blur button')", `Preferences drew (${label})`))) continue
433
+ await setTheme(theme)
434
+ const p = (await evalIn(preferencesProbe)) ?? {}
435
+ out[`preferences-${label}-${theme}`] = p
436
+ ok(p.renderedChars > 200, `the page rendered real text (${p.renderedChars} chars)`)
437
+ ok(p.title === 'Your preferences', `titled "Your preferences" (saw "${p.title}")`)
438
+ ok(same(p.cards, ['Your sign-in', 'Appearance', 'Blur names while sharing a screen']), `three cards, in order (saw ${JSON.stringify(p.cards)})`)
439
+ ok(same(p.rows, [`Address: ${EMAIL}`, 'Permissions: Runs clearances · Manages', 'Access to: Everything']),
440
+ `Your sign-in carries the address, the permissions and the access (saw ${JSON.stringify(p.rows)})`)
441
+ ok(p.lines[0] === SPEC.adminLine, `the administrator line reads as written (saw ${JSON.stringify(p.lines[0])})`)
442
+ if (contact) ok(p.link?.text === 'Clearotron administrator' && p.link?.href === CONTACT, `with a contact set, "Clearotron administrator" links to it (saw ${JSON.stringify(p.link)})`)
443
+ else ok(p.link === null, `with no contact set, "Clearotron administrator" is plain text (saw ${JSON.stringify(p.link)})`)
444
+ ok(p.logOut === 'Log out', 'Log out is where it was')
445
+ ok(p.lines[1] === 'Also on the top bar, under the circle icon', `the appearance line reads as written (saw ${JSON.stringify(p.lines[1])})`)
446
+ ok(p.blurLine === SPEC.blurLine, `the blur card states its three facts (saw ${JSON.stringify(p.blurLine)})`)
447
+ ok(p.eye && p.eye.label === 'Blur names for screen sharing' && p.eye.text === '' && p.eye.pressed === 'false' && p.topEye?.cls === p.eye.cls && p.topEye?.icon,
448
+ `the card shows the top bar's own eye button, unpressed, with no label or badge (saw ${JSON.stringify({ eye: p.eye, top: p.topEye })})`)
449
+ ok(!p.notKept, 'no sentence on the page says the blur choice is not kept')
450
+ await capture(`preferences-${label}-${theme}`)
451
+ if (!contact) {
452
+ // THE THEME CHOICE STILL WORKS: the other option applies at once, and pressing this one brings it back.
453
+ const other = theme === 'light' ? 2 : 1
454
+ await press(`.pref-themes button:nth-child(${other})`)
455
+ const flipped = await evalIn("[document.documentElement.getAttribute('data-theme'), [...document.querySelectorAll('.pref-themes button')].map((b) => b.getAttribute('aria-pressed'))]")
456
+ await press(`.pref-themes button:nth-child(${3 - other})`)
457
+ const back = await evalIn("document.documentElement.getAttribute('data-theme')")
458
+ ok(same(flipped, [theme === 'light' ? 'dark' : 'light', other === 2 ? ['false', 'true'] : ['true', 'false']]) && back === theme,
459
+ `the theme choice applies and comes back (saw ${JSON.stringify({ flipped, back })})`)
460
+ }
461
+ }
462
+ state.contact = CONTACT
463
+
464
+ // ── the blur, remembered
465
+ console.log('\nBlur, remembered:')
466
+ if (await open('/portal/preferences', "document.querySelector('.pref-blur button')", 'Preferences drew for the blur')) {
467
+ await evalIn(`(() => { try { localStorage.removeItem('${BLUR_KEY}') } catch {} return true })()`)
468
+ await setTheme(theme)
469
+ await press('.pref-blur button')
470
+ const on = (await evalIn(preferencesProbe)) ?? {}
471
+ ok(on.blurredDocument && on.eye?.pressed === 'true' && on.topEye?.pressed === 'true' && /blur/.test(on.addressFilter ?? ''),
472
+ `pressing the card's eye blurs the page and presses both buttons (saw ${JSON.stringify({ blurred: on.blurredDocument, card: on.eye?.pressed, top: on.topEye?.pressed, filter: on.addressFilter })})`)
473
+ ok(on.stored === 'on', `the choice is kept in this browser (saw ${JSON.stringify(on.stored)})`)
474
+ ok(on.companyDrawn && on.unmarkedCompany.length === 0,
475
+ `with the blur on, the company's name is drawn unmarked for the blur at: ${JSON.stringify(on.unmarkedCompany)} (drawn at all: ${on.companyDrawn})`)
476
+ await capture(`blur-on-${theme}`)
477
+ if (await reload("document.querySelector('.pref-blur button')", 'Preferences drew again after the reload')) {
478
+ await sleep(300)
479
+ const back = (await evalIn(preferencesProbe)) ?? {}
480
+ ok(back?.blurredDocument && back.eye?.pressed === 'true' && back.topEye?.pressed === 'true' && /blur/.test(back.addressFilter ?? ''),
481
+ `after a reload the page is still blurred and the eye is still pressed (saw ${JSON.stringify({ blurred: back?.blurredDocument, card: back?.eye?.pressed, top: back?.topEye?.pressed, filter: back?.addressFilter })})`)
482
+ await capture(`blur-reloaded-${theme}`)
483
+ // THE TWO BUTTONS ARE ONE CONTROL: the top bar turns it off, the card agrees, and the next press of
484
+ // the card turns it on again rather than doing nothing.
485
+ await press('header.topbar button[aria-label="Blur names for screen sharing"]')
486
+ const off = (await evalIn(preferencesProbe)) ?? {}
487
+ ok(!off.blurredDocument && off.eye?.pressed === 'false' && off.topEye?.pressed === 'false' && off.stored === 'off',
488
+ `the top bar's eye turns it off and the card agrees (saw ${JSON.stringify({ blurred: off.blurredDocument, card: off.eye?.pressed, stored: off.stored })})`)
489
+ await press('.pref-blur button')
490
+ const again = (await evalIn(preferencesProbe)) ?? {}
491
+ ok(again.blurredDocument && again.topEye?.pressed === 'true', 'and the card\'s next press turns it on again — no dead click')
492
+ await press('.pref-blur button')
493
+ if (await reload("document.querySelector('.pref-blur button')", 'Preferences drew after turning the blur off')) {
494
+ await sleep(300)
495
+ const stayedOff = (await evalIn(preferencesProbe)) ?? {}
496
+ ok(stayedOff && !stayedOff.blurredDocument && stayedOff.eye?.pressed === 'false', 'turned off, it stays off after a reload')
497
+ }
498
+ }
499
+ }
500
+
501
+ // ── About
502
+ console.log('\nAbout:')
503
+ if (await open('/portal/about', "document.querySelector('.about-dl')", 'About drew')) {
504
+ await setTheme(theme)
505
+ const a = (await evalIn(aboutProbe)) ?? {}
506
+ out[`about-${theme}`] = a
507
+ ok(a.title === 'About', `titled "About" (saw "${a.title}")`)
508
+ ok(same(a.rows, ['Product', 'Version', 'Build', 'Source', 'Licence', 'Copyright', 'Model access', 'Trade marks']), `the rows, in order (saw ${JSON.stringify(a.rows)})`)
509
+ ok(a.source?.text === 'CordilleraSarl/Clearotron ↗' && a.source?.href === about.sourceUrl && a.source?.lines === 1,
510
+ `the Source row is a readable one-line link to this build's source (saw ${JSON.stringify(a.source)})`)
511
+ ok(about.commit ? a.build === about.commit.slice(0, 12) && /mono|monospace|Fira|Menlo|Courier/i.test(a.buildFont ?? '') : a.build === null,
512
+ `the build identifier stays one row above it, in mono (saw ${JSON.stringify({ build: a.build, font: a.buildFont })})`)
513
+ ok(a.fullText?.text === 'Full text' && /gnu\.org/.test(a.fullText?.href ?? ''), `the licence keeps "Full text" (saw ${JSON.stringify(a.fullText)})`)
514
+ ok(a.copyright === '2026 Cordillera Sàrl', `copyright (saw ${JSON.stringify(a.copyright)})`)
515
+ ok(same(a.pills, ['Notices', 'Trademarks', 'Contributing', 'Security', 'Code of conduct', 'GitHub', 'clearotron.ai']), `the link pills (saw ${JSON.stringify(a.pills)})`)
516
+ await capture(`about-${theme}`)
517
+ }
518
+
519
+ // ── Sign in: help closed, open, and with a long reset command
520
+ for (const [label, reset] of [['sign-in-help', null], ['sign-in-help-long-command', LONG_RESET]]) {
521
+ console.log(`\n${label}:`)
522
+ state.reset = reset
523
+ if (!(await open('/portal/login', "document.querySelector('.card details.fold')", `the sign-in page drew (${label})`))) continue
524
+ await setTheme(theme)
525
+ const closed = (await evalIn(signInProbe)) ?? {}
526
+ out[`${label}-closed-${theme}`] = closed
527
+ ok(closed.heading === 'Sign in' && closed.identity === `Clearotron portal, as ${EMAIL}.` && closed.field && closed.button === 'Sign in',
528
+ `the card keeps its heading, identity line, field and button (saw ${JSON.stringify({ h: closed.heading, id: closed.identity, button: closed.button })})`)
529
+ ok(closed.lead === 'This Clearotron signs in one person: you.' && closed.leadBold, `one bold line under the button (saw ${JSON.stringify(closed.lead)})`)
530
+ ok(closed.summary === 'Administrator help' && closed.open === false && closed.hintsShown === 0,
531
+ `the administrator's lines sit in a closed "Administrator help" fold (saw ${JSON.stringify({ summary: closed.summary, open: closed.open, shown: closed.hintsShown })})`)
532
+ if (!reset) await capture(`sign-in-help-closed-${theme}`)
533
+ await press('.card details.fold > summary')
534
+ const opened = (await evalIn(signInProbe)) ?? {}
535
+ out[`${label}-open-${theme}`] = opened
536
+ ok(opened.open === true && opened.hintsShown === 2, `pressing it opens both lines (saw ${JSON.stringify({ open: opened.open, shown: opened.hintsShown })})`)
537
+ ok(opened.hints[0] === (reset ? SPEC.reset.replace('clearotron passphrase --reset', reset) : SPEC.reset) && opened.hints[1] === SPEC.addPeople,
538
+ `the fold holds the product's two lines word for word (saw ${JSON.stringify(opened.hints)})`)
539
+ ok(opened.setUp?.text === 'How to set that up' && opened.setUp?.href === LOGIN_IN_FRONT_DOC, `with the "How to set that up" link (saw ${JSON.stringify(opened.setUp)})`)
540
+ ok(opened.codeInside && !opened.overflowsPage && opened.cardWidth <= 420,
541
+ `the reset command stays inside the card at its own width (card ${opened.cardWidth}px, ${opened.codeLines} line(s), inside: ${opened.codeInside})`)
542
+ await capture(`${label}-open-${theme}`)
543
+ }
544
+ state.reset = null
545
+ }
546
+
547
+ // ── a browser whose storage refuses ──────────────────────────────────────────────────────────────────
548
+ //
549
+ // localStorage throws outright in a null-origin or locked-down document. The page must still draw, the
550
+ // eye must still blur, and a reload must start it off rather than take the page down.
551
+ console.log('\nBlur, in a browser whose storage refuses:')
552
+ const { result: refusal } = await cmd('Page.addScriptToEvaluateOnNewDocument', {
553
+ source: "Object.defineProperty(window, 'localStorage', { configurable: true, get() { throw new DOMException('The operation is insecure.', 'SecurityError') } })",
554
+ })
555
+ if (await open('/portal/preferences', "document.querySelector('.pref-blur button')", 'Preferences drew in a browser whose storage refuses')) {
556
+ const before = (await evalIn(preferencesProbe)) ?? {}
557
+ ok(before.stored === 'refused' && !before.blurredDocument && before.eye?.pressed === 'false', `the storage refuses, and the page starts unblurred (saw ${JSON.stringify({ stored: before.stored, blurred: before.blurredDocument })})`)
558
+ await press('.pref-blur button')
559
+ const pressed = (await evalIn(preferencesProbe)) ?? {}
560
+ ok(pressed.blurredDocument && pressed.eye?.pressed === 'true' && pressed.topEye?.pressed === 'true', 'pressing the eye still blurs the page')
561
+ if (await reload("document.querySelector('.pref-blur button')", 'Preferences drew again after a reload with storage refusing')) {
562
+ const after = (await evalIn(preferencesProbe)) ?? {}
563
+ ok(after && !after.blurredDocument && after.eye?.pressed === 'false', 'a reload starts it off, with nothing to remember it by, and the page still draws')
564
+ }
565
+ }
566
+ await cmd('Page.removeScriptToEvaluateOnNewDocument', { identifier: refusal?.identifier })
567
+
568
+ if (!process.argv.includes('--quiet')) console.log(`\n${JSON.stringify(out, null, 2)}`)
569
+ try { process.kill(-chrome.pid, 'SIGKILL') } catch { /* already gone */ }
570
+ server.close()
571
+ rmSync(userDir, { recursive: true, force: true })
572
+ rmSync(credsDir, { recursive: true, force: true })
573
+ if (shotDir) console.log(`\nwrote ${shots.length} screenshot(s) to ${shotDir}:\n ${shots.join('\n ')}`)
574
+ console.log(failures ? `\n${failures} failed` : '\nInstallation settings, Preferences, About and Sign in draw correctly in both themes')
575
+ process.exit(failures ? 1 : 0)
package/shared/brand.mjs CHANGED
@@ -96,6 +96,35 @@ export const BRAND = {
96
96
  */
97
97
  export const ORGANISATION_NAME = process.env.CLEAROTRON_ORGANISATION_NAME?.trim() || null;
98
98
 
99
+ /**
100
+ * Where a signed-in person asks for a change to their own sign-in: an `href`, or NULL when the
101
+ * installation names nobody.
102
+ *
103
+ * Preferences tells a person to contact their Clearotron administrator to change the address, the
104
+ * permissions or the companies on their sign-in. With a contact set those words are a link to it; unset,
105
+ * they are plain text. Unset is the default, and no contact is invented.
106
+ *
107
+ * A LINK OR NOTHING. The value ends up in an `href` in a browser, so it is accepted only as a mail address
108
+ * (with or without `mailto:`) or an http(s) address, and is returned as the `href` itself. Anything else —
109
+ * a name, a telephone number, a `javascript:` address — reads as unset, because words that open something
110
+ * other than what they say are worse than words that open nothing.
111
+ */
112
+ const MAIL_ADDRESS = /^[^\s@<>()"',;:\\]+@[^\s@<>()"',;:\\]+\.[^\s@<>()"',;:\\]+$/;
113
+ export function administratorContact(raw) {
114
+ const value = String(raw ?? "").trim();
115
+ if (!value) return null;
116
+ const mail = /^mailto:/i.test(value) ? value.slice("mailto:".length) : value;
117
+ if (MAIL_ADDRESS.test(mail)) return `mailto:${mail}`;
118
+ if (!/^https?:\/\//i.test(value)) return null;
119
+ try {
120
+ const url = new URL(value);
121
+ return (url.protocol === "https:" || url.protocol === "http:") && url.hostname ? url.href : null;
122
+ } catch {
123
+ return null;
124
+ }
125
+ }
126
+ export const ADMINISTRATOR_CONTACT = administratorContact(process.env.CLEAROTRON_ADMINISTRATOR_CONTACT);
127
+
99
128
  /**
100
129
  * THE CONFIDENTIALITY POSTURE ON A DELIVERED DOCUMENT — one rule, both report templates.
101
130
  *