oxedyne/daimond/dev/verify_telemetry.mjs
59.5 KiB, 1 run
created by r2519314175:729, which is this file's identity for as long as the history lasts, whatever it is later renamed to
download · who wrote it · its history
| 1 | // verify_telemetry.mjs — beta telemetry carries numbers, and never a word of anybody's. |
| 2 | // |
| 3 | // The promise this file defends is the one Daimond is sold on: your content does |
| 4 | // not reach our server. A beta tester agrees to usage and failure counts; they do |
| 5 | // not agree to a chat fragment arriving inside a stack trace, or a Diamond's name |
| 6 | // arriving inside a "what were you doing" box. So the design has no string field |
| 7 | // at all, and this proves that AT THE NETWORK rather than by reading the code. |
| 8 | // |
| 9 | // ── What is proved, and what could fake it ────────────────────────── |
| 10 | // |
| 11 | // A negative check is the easiest kind to fake, and this file's most important |
| 12 | // check is negative. Three things guard against a green for the wrong reason: |
| 13 | // |
| 14 | // 1. THE MARKERS ARE PROVED PRESENT FIRST. A distinctive string is typed into |
| 15 | // a chat, given to a Diamond as its name, and used as a file path -- and |
| 16 | // each is then read back out of the running app before anything is asserted |
| 17 | // about the wire. "No marker on the wire" is worthless if the marker was |
| 18 | // never in the app. |
| 19 | // |
| 20 | // 2. THE BATCH IS PROVED TO HAVE LEFT. "No content in the telemetry request" |
| 21 | // is trivially true when there is no telemetry request. The consented pass |
| 22 | // asserts a batch went, and asserts the codes in it are exactly the ones |
| 23 | // emitted after consent. |
| 24 | // |
| 25 | // 3. THE CHECK IS PROVED RED IN THE SAME RUN. Two further passes serve a |
| 26 | // DELIBERATELY BROKEN `telemetry.js` -- one that appends an on-screen chat |
| 27 | // message to the outgoing body, and one that consents to itself at load -- |
| 28 | // and this file fails unless the leak check fires on the first and the |
| 29 | // before-consent check fires on the second. Each break is SERVED AT |
| 30 | // `js/telemetry.js` in place of the file on disk, so nothing is edited and |
| 31 | // anybody can re-run it -- see `serveModule`, and the incident in its |
| 32 | // comment for why it is a route and no longer an init script. |
| 33 | // |
| 34 | // ── Running it ────────────────────────────────────────────────────── |
| 35 | // |
| 36 | // bash dev/world.sh 5 --up |
| 37 | // eval "$(bash dev/world.sh 5 --env)" |
| 38 | // node dev/verify_telemetry.mjs |
| 39 | // bash dev/world.sh 5 --down |
| 40 | // |
| 41 | // Headless. The gateway does NOT need to be running: this is a check on what |
| 42 | // leaves the browser, and a batch that reaches a closed port has still left. |
| 43 | import fs from 'node:fs'; |
| 44 | import path from 'node:path'; |
| 45 | import { createRequire } from 'node:module'; |
| 46 | import { fileURLToPath } from 'node:url'; |
| 47 | |
| 48 | import { open, chat, transcript, scratch, APP } from './harness.mjs'; |
| 49 | |
| 50 | const HERE = path.dirname(fileURLToPath(import.meta.url)); |
| 51 | const ROOT = path.join(HERE, '..'); |
| 52 | const SRC_JS = path.join(ROOT, 'www/js/telemetry.js'); |
| 53 | const SRC_RS = path.join(ROOT, 'gateway/src/handlers/telemetry.rs'); |
| 54 | |
| 55 | // The two break passes at the end prove the negatives fire, on every run. These |
| 56 | // flags do the same thing the other way round -- they break the build the MAIN |
| 57 | // passes drive -- so the headline checks can be SEEN red rather than only |
| 58 | // inferred red from a later pass going green: |
| 59 | // |
| 60 | // node dev/verify_telemetry.mjs --break=note # the no-content check must fail |
| 61 | // node dev/verify_telemetry.mjs --break=consent # the before-consent check must fail |
| 62 | // node dev/verify_telemetry.mjs --break=narrow # the build-ordinal checks must fail |
| 63 | // node dev/verify_telemetry.mjs --break=halfway # the queued-batch check must fail |
| 64 | // node dev/verify_telemetry.mjs --break=eager # the withdrawal-survives-a-reload check must fail |
| 65 | // node dev/verify_telemetry.mjs --break=forget # the consent-survives-a-reload check must fail |
| 66 | // node dev/verify_telemetry.mjs --break=inert # the something-emits check must fail |
| 67 | // node dev/verify_telemetry.mjs --break=undisclosed # the policy check must fail |
| 68 | const BREAK = (process.argv.find((a) => a.startsWith('--break=')) || '').split('=')[1] || ''; |
| 69 | |
| 70 | const ok = [], bad = []; |
| 71 | const check = (name, pass, detail) => { |
| 72 | (pass ? ok : bad).push(name + (detail ? ' — ' + detail : '')); |
| 73 | console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : '')); |
| 74 | }; |
| 75 | |
| 76 | // Distinctive enough that nothing else in the app or the suite could produce |
| 77 | // them, so a hit is a leak and never a coincidence. |
| 78 | const MARK = { |
| 79 | chat: 'ZQXJ7731-what-I-typed-in-confidence', |
| 80 | diamond: 'ZQXJ7731-Diamond-Name', |
| 81 | file: 'zqxj7731-private-note.md', |
| 82 | body: 'ZQXJ7731-the-contents-of-my-file', |
| 83 | }; |
| 84 | const MARKERS = Object.values(MARK); |
| 85 | |
| 86 | // ── The wire, read as a stranger would read it ────────────────────── |
| 87 | |
| 88 | /// Is this request going to Daimond's own origin? |
| 89 | /// |
| 90 | /// The model provider is deliberately excluded. A prompt reaching the provider |
| 91 | /// the user chose is the product working; a prompt reaching OUR origin is the |
| 92 | /// promise broken, and those are different facts that must not be conflated. |
| 93 | const appOrigin = new URL(APP).origin; |
| 94 | const toApp = (r) => { try { return new URL(r.url).origin === appOrigin; } catch { return false; } }; |
| 95 | const toTelemetry = (r) => { try { return toApp(r) && new URL(r.url).pathname === '/api/telemetry'; } |
| 96 | catch { return false; } }; |
| 97 | |
| 98 | /// Every place a marker turned up in what left the browser for our origin. |
| 99 | /// |
| 100 | /// Both the address and the body: a payload smuggled in a query string is still |
| 101 | /// a payload, which is why the URL is searched too. |
| 102 | function leaks(requests, only) { |
| 103 | const out = []; |
| 104 | for (const r of requests.filter(only)) { |
| 105 | const hay = r.url + '\n' + (r.body || ''); |
| 106 | for (const m of MARKERS) { |
| 107 | if (hay.indexOf(m) !== -1) out.push(`${m} in ${new URL(r.url).pathname}`); |
| 108 | } |
| 109 | } |
| 110 | return out; |
| 111 | } |
| 112 | |
| 113 | /// Is this parsed body numbers all the way down, with no key outside the |
| 114 | /// declared set? Stated here rather than borrowed from the module under test, |
| 115 | /// so a module that redefined its own rules could not redefine the check. |
| 116 | const KEYS = ['v', 'b', 'l', 'w', 't', 'd', 'e']; |
| 117 | function shapeFaults(body) { |
| 118 | const faults = []; |
| 119 | let j; |
| 120 | try { j = JSON.parse(body); } catch (e) { return ['the body is not JSON']; } |
| 121 | if (!j || typeof j !== 'object' || Array.isArray(j)) return ['the body is not an object']; |
| 122 | const isInt = (x) => typeof x === 'number' && Number.isInteger(x) && x >= 0; |
| 123 | for (const k of Object.keys(j)) { |
| 124 | if (KEYS.indexOf(k) === -1) { faults.push(`the field "${k}" is not one of ${KEYS.join(',')}`); continue; } |
| 125 | if (k === 'e') { |
| 126 | if (!Array.isArray(j.e)) { faults.push('"e" is not a list'); continue; } |
| 127 | j.e.forEach((row, i) => { |
| 128 | if (!Array.isArray(row) || row.length !== 3 || !row.every(isInt)) { |
| 129 | faults.push(`event ${i} is not three whole numbers: ${JSON.stringify(row)}`); |
| 130 | } |
| 131 | }); |
| 132 | } else if (!isInt(j[k])) { |
| 133 | faults.push(`"${k}" is ${JSON.stringify(j[k])}, which is not a whole number`); |
| 134 | } |
| 135 | } |
| 136 | return faults; |
| 137 | } |
| 138 | |
| 139 | /// The event codes a captured batch carried. |
| 140 | function codesIn(requests) { |
| 141 | const seen = new Set(); |
| 142 | for (const r of requests.filter(toTelemetry)) { |
| 143 | try { (JSON.parse(r.body).e || []).forEach((row) => seen.add(row[0])); } catch (e) { /* not ours */ } |
| 144 | } |
| 145 | return seen; |
| 146 | } |
| 147 | |
| 148 | // ── The breaks, served rather than written to disk ────────────────── |
| 149 | |
| 150 | /// A `telemetry.js` that appends the user's own on-screen words to the batch. |
| 151 | /// |
| 152 | /// This is the leak in its most plausible form -- a "just a little context" |
| 153 | /// field -- and it is placed AFTER the module's own integer check, because a |
| 154 | /// break the module catches for us would prove nothing about the wire. |
| 155 | function breakWithNote(src) { |
| 156 | const anchor = 'body: JSON.stringify(body),'; |
| 157 | if (src.indexOf(anchor) === -1) throw new Error('the break anchor is gone from telemetry.js'); |
| 158 | return src.replace(anchor, |
| 159 | 'body: JSON.stringify(Object.assign({}, body, { note: ' + |
| 160 | '((document.getElementById("chat-output") || {}).textContent || "").slice(0, 400) })),'); |
| 161 | } |
| 162 | |
| 163 | /// A `telemetry.js` that consents to itself at load, which is what a release |
| 164 | /// that "forgot the check" would look like. |
| 165 | function breakWithSelfConsent(src) { |
| 166 | return src + '\ntry { window.DaimondTelemetry.consent({ wave: 1 }); } catch (e) {}\n'; |
| 167 | } |
| 168 | |
| 169 | /// A `telemetry.js` that judges the build ordinal as though it were a count. |
| 170 | /// |
| 171 | /// This is the defect exactly as it stood: one ceiling for every field, so an |
| 172 | /// eight-hex-digit build id beginning 8-f -- half of all builds -- is floored to |
| 173 | /// zero on the last step before the wire. It is the break the ordinal checks |
| 174 | /// below are proved red against. |
| 175 | const NARROW_ANCHOR = 'var MAX_BUILD = 4294967295;'; |
| 176 | function breakWithNarrowCeiling(src) { |
| 177 | const n = src.split(NARROW_ANCHOR).length - 1; |
| 178 | if (n !== 1) { |
| 179 | console.error(`break 'narrow': the anchor appears ${n} times in telemetry.js, ` |
| 180 | + 'so nothing was broken and the run below would prove nothing.'); |
| 181 | process.exit(2); |
| 182 | } |
| 183 | return src.replace(NARROW_ANCHOR, 'var MAX_BUILD = MAX_N;'); |
| 184 | } |
| 185 | |
| 186 | /// How long a session waits for the module's own flush timer, when the timer is |
| 187 | /// the thing under test. The shipped interval is a minute, which is right for a |
| 188 | /// tester's battery and wrong for a check: a withdrawal that is only asserted |
| 189 | /// against a flush nobody waited for is a withdrawal nobody has seen work. |
| 190 | const FAST_FLUSH = 1200; |
| 191 | |
| 192 | /// The same module with its flush interval shortened, and nothing else changed. |
| 193 | /// |
| 194 | /// Used only by the sessions in section 1c, which have to watch a queued batch |
| 195 | /// either go or not go. The anchor is asserted, so a renamed constant stops the |
| 196 | /// run rather than quietly leaving the sessions waiting on a timer that never |
| 197 | /// comes round -- which would make "no batch left" true for the wrong reason. |
| 198 | const FLUSH_ANCHOR = 'var FLUSH_MS = 60000;'; |
| 199 | function withFastFlush(src) { |
| 200 | const n = src.split(FLUSH_ANCHOR).length - 1; |
| 201 | if (n !== 1) { |
| 202 | console.error(`the flush anchor appears ${n} times in telemetry.js, so a ` |
| 203 | + 'withdrawal session would wait on the shipped minute and prove nothing.'); |
| 204 | process.exit(2); |
| 205 | } |
| 206 | return src.replace(FLUSH_ANCHOR, `var FLUSH_MS = ${FAST_FLUSH};`); |
| 207 | } |
| 208 | |
| 209 | /// A `withdraw()` that drops the recorder and leaves its timer running. |
| 210 | /// |
| 211 | /// THE PLAUSIBLE DEFECT, and the reason the withdrawal check is proved at the |
| 212 | /// network rather than against `armed()`. `rec = null` alone reads exactly like |
| 213 | /// a working withdrawal from outside -- `armed()` false, `emit()` refusing -- |
| 214 | /// while the closure's own timer still holds the buffer and sends it a minute |
| 215 | /// later. That is the shape the stub in the incident had, and a check that |
| 216 | /// asked the module how it felt would pass on it. |
| 217 | const CLOSE_ANCHOR = ` close: function () { |
| 218 | if (timer) { clearTimeout(timer); timer = null; } |
| 219 | buf.length = 0; |
| 220 | dropped = 0; |
| 221 | },`; |
| 222 | function breakWithHalfWithdrawal(src) { |
| 223 | if (src.split(CLOSE_ANCHOR).length - 1 !== 1) { |
| 224 | console.error('break \'halfway\': the close() anchor is gone from telemetry.js, ' |
| 225 | + 'so nothing was broken and the run below would prove nothing.'); |
| 226 | process.exit(2); |
| 227 | } |
| 228 | return src.replace(CLOSE_ANCHOR, |
| 229 | ' close: function () { /* halfway: the recorder goes, its timer does not */ },'); |
| 230 | } |
| 231 | |
| 232 | /// A `resume()` that re-arms without consulting what was written down. |
| 233 | /// |
| 234 | /// The dangerous half of the reload pair, and the one that reads as harmless |
| 235 | /// while it is being written: the gateway says this account is in the beta, so |
| 236 | /// why ask twice? Because "in the beta" is membership and not agreement, and a |
| 237 | /// tester who withdrew is still a member. This is what a withdrawal undone by |
| 238 | /// the next visit looks like from the inside. |
| 239 | const EAGER_ANCHOR = ' if (remembered() !== account) return false;'; |
| 240 | function breakWithEagerResume(src) { |
| 241 | if (src.split(EAGER_ANCHOR).length - 1 !== 1) { |
| 242 | console.error('break \'eager\': the resume() anchor is gone from telemetry.js.'); |
| 243 | process.exit(2); |
| 244 | } |
| 245 | return src.replace(EAGER_ANCHOR, ' /* eager: what was written down is not consulted */'); |
| 246 | } |
| 247 | |
| 248 | /// A `consent()` that arms without writing the agreement down. |
| 249 | /// |
| 250 | /// The harmless-looking half: everything works, for one sitting. This is the |
| 251 | /// state the module was in by design until the gateway could answer for the |
| 252 | /// membership, and the check it fires is the one that says so. |
| 253 | const FORGET_ANCHOR = ' if (account) remember(account);'; |
| 254 | function breakWithForgetfulConsent(src) { |
| 255 | if (src.split(FORGET_ANCHOR).length - 1 !== 1) { |
| 256 | console.error('break \'forget\': the consent() anchor is gone from telemetry.js.'); |
| 257 | process.exit(2); |
| 258 | } |
| 259 | return src.replace(FORGET_ANCHOR, ' /* forget: the agreement is not written down */'); |
| 260 | } |
| 261 | |
| 262 | /// Which patch each break applies to the module the page loads. |
| 263 | /// |
| 264 | /// `inert` and `undisclosed` patch nothing, because what they simulate is not a |
| 265 | /// change to this module: they are the two states the app must never ship in -- |
| 266 | /// a loaded client that nothing emits to, and one the published policy does not |
| 267 | /// describe. They replace `grant` and `load`, which simulated the state the tree |
| 268 | /// WAS in (nothing granted consent, nothing loaded the client) and became dead |
| 269 | /// levers the moment it was built: a flag that can no longer make a check fail |
| 270 | /// is a flag that says the check is proved when it is not. |
| 271 | const PATCHES = { |
| 272 | note: breakWithNote, |
| 273 | consent: breakWithSelfConsent, |
| 274 | narrow: breakWithNarrowCeiling, |
| 275 | halfway: breakWithHalfWithdrawal, |
| 276 | eager: breakWithEagerResume, |
| 277 | forget: breakWithForgetfulConsent, |
| 278 | inert: (s) => s, |
| 279 | undisclosed: (s) => s, |
| 280 | }; |
| 281 | if (BREAK && !PATCHES[BREAK]) { |
| 282 | console.error(`unknown break '${BREAK}'; one of: ${Object.keys(PATCHES).join(', ')}`); |
| 283 | process.exit(2); |
| 284 | } |
| 285 | /// The module as this run drives it: patched under `--break`, otherwise as it is. |
| 286 | const asDriven = (src) => (BREAK ? PATCHES[BREAK](src) : src); |
| 287 | |
| 288 | /// Serve `src` AS `js/telemetry.js`, in place of the file on disk. |
| 289 | /// |
| 290 | /// IT USED TO BE `addInitScript`, which ran the module before the page's own |
| 291 | /// scripts -- correct while `index.html` did not load it, and quietly wrong the |
| 292 | /// moment it did: the page's copy then re-ran the IIFE and replaced |
| 293 | /// `window.DaimondTelemetry` with an UNPATCHED module. Every break went missing |
| 294 | /// and every timer session waited 1.2 seconds on a sixty-second flush, so six |
| 295 | /// checks turned red at once and the leak check -- the most important negative |
| 296 | /// in this file -- stopped firing on a build that leaks. |
| 297 | /// |
| 298 | /// Serving it at its own address cannot drift that way again: whatever the page |
| 299 | /// loads is what this run patched, because it is the same request. |
| 300 | async function serveModule(page, src) { |
| 301 | await page.route('**/js/telemetry.js', (r) => r.fulfill({ |
| 302 | status: 200, contentType: 'application/javascript', body: src, |
| 303 | })); |
| 304 | } |
| 305 | |
| 306 | // ── One browser session ───────────────────────────────────────────── |
| 307 | |
| 308 | /// # Arguments |
| 309 | /// * `label` - Names the profile and the session. |
| 310 | /// * `patch` - What to do to `telemetry.js` before the page loads it. |
| 311 | /// * `giveConsent` - Mint a recorder, or leave the session unconsented. |
| 312 | /// * `buildId` - Serve this build id from `build.json` instead of the real one, |
| 313 | /// so a property about build ids can be asserted rather than whatever the |
| 314 | /// day's build happens to be. |
| 315 | /// * `quick` - Skip the marker fixture. Only for a session that is not about |
| 316 | /// content leaking; the leak checks need the markers proved present first. |
| 317 | async function runSession({ label, patch, giveConsent, buildId = '', quick = false }) { |
| 318 | const src = patch(fs.readFileSync(SRC_JS, 'utf8')); |
| 319 | const requests = []; |
| 320 | const profile = scratch('telemetry-' + label); |
| 321 | fs.rmSync(profile, { recursive: true, force: true }); |
| 322 | |
| 323 | // Both hooks have to be in place BEFORE the first navigation: the module is |
| 324 | // injected the way `index.html` will one day carry it, and the capture must |
| 325 | // not miss a request made during boot. |
| 326 | const s = await open({ |
| 327 | name: 'tele-' + label, |
| 328 | profile, |
| 329 | defaults: false, |
| 330 | route: async (page) => { |
| 331 | if (buildId) { |
| 332 | // The updater reads the same file. It only reloads a HIDDEN tab |
| 333 | // (`apply` in js/updater.js), and this one is not hidden, so a |
| 334 | // substituted id moves the chip and nothing else. |
| 335 | await page.route('**/build.json', (r) => r.fulfill({ |
| 336 | status: 200, contentType: 'application/json', |
| 337 | body: JSON.stringify({ build: buildId }), |
| 338 | })); |
| 339 | } |
| 340 | await serveModule(page, src); |
| 341 | page.on('request', (r) => { |
| 342 | requests.push({ url: r.url(), method: r.method(), body: r.postData() || '' }); |
| 343 | }); |
| 344 | }, |
| 345 | }); |
| 346 | const { page } = s; |
| 347 | |
| 348 | const present = { module: false, chat: false, diamond: false, file: false }; |
| 349 | try { |
| 350 | present.module = await page.evaluate(() => !!window.DaimondTelemetry); |
| 351 | |
| 352 | if (quick) { |
| 353 | const after = giveConsent ? await page.evaluate(() => { |
| 354 | const T = window.DaimondTelemetry; |
| 355 | const granted = T.consent({ wave: 3 }); |
| 356 | T.emit('app.open', 830); |
| 357 | return { granted: granted, armed: T.armed(), wave: T.wave() }; |
| 358 | }) : null; |
| 359 | const sent = await page.evaluate(() => window.DaimondTelemetry.flush()); |
| 360 | await page.waitForTimeout(800); |
| 361 | return { requests, present, before: null, after, sent }; |
| 362 | } |
| 363 | |
| 364 | // A Diamond carrying the marker as its NAME, and a file carrying it as |
| 365 | // its PATH and its CONTENT. Through the real wasm, into the real store. |
| 366 | const built = await page.evaluate(async (m) => { |
| 367 | const mod = await import('/pkg/oxedyne_daimond.js'); |
| 368 | const app = new mod.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 4096, '', true); |
| 369 | const id = await app.create_diamond(m.diamond); |
| 370 | await app.run_tool('file_write', JSON.stringify({ path: m.file, content: m.body })); |
| 371 | const names = JSON.parse(await app.list_diamonds()).map((d) => d.name); |
| 372 | const listing = String(await app.run_tool('file_list', JSON.stringify({ path: '.' }))); |
| 373 | return { id, names, listing }; |
| 374 | }, MARK); |
| 375 | present.diamond = built.names.indexOf(MARK.diamond) !== -1; |
| 376 | present.file = built.listing.indexOf(MARK.file) !== -1; |
| 377 | |
| 378 | // And the marker typed into a chat, sent, and answered. |
| 379 | await chat(s, MARK.chat); |
| 380 | present.chat = (await transcript(s)).indexOf(MARK.chat) !== -1; |
| 381 | |
| 382 | // Events emitted BEFORE consent. Nothing should keep them, and nothing |
| 383 | // should send them when consent arrives later. |
| 384 | const before = await page.evaluate(() => ({ |
| 385 | emitted: window.DaimondTelemetry.emit('panel.open', 5), |
| 386 | armed: window.DaimondTelemetry.armed(), |
| 387 | flushed: false, |
| 388 | })); |
| 389 | await page.evaluate(() => window.DaimondTelemetry.emit('tool.run', 2)); |
| 390 | const flushedBefore = await page.evaluate(() => window.DaimondTelemetry.flush()); |
| 391 | before.flushed = flushedBefore; |
| 392 | |
| 393 | let after = null; |
| 394 | if (giveConsent) { |
| 395 | after = await page.evaluate(() => { |
| 396 | const T = window.DaimondTelemetry; |
| 397 | const granted = T.consent({ wave: 3 }); |
| 398 | T.emit('app.open', 830); |
| 399 | T.emit('onboard.step', 6); |
| 400 | T.emit('chat.new', 1); |
| 401 | T.emit('turn.send', 1); |
| 402 | T.emit('error.thrown', 1); |
| 403 | return { granted: granted, armed: T.armed(), wave: T.wave() }; |
| 404 | }); |
| 405 | } |
| 406 | // A flush either way: the unconsented session must still send nothing. |
| 407 | const sent = await page.evaluate(() => window.DaimondTelemetry.flush()); |
| 408 | await page.waitForTimeout(800); |
| 409 | |
| 410 | return { requests, present, before, after, sent }; |
| 411 | } finally { |
| 412 | try { await s.browser.close(); } catch (e) { /* ignore */ } |
| 413 | } |
| 414 | } |
| 415 | |
| 416 | /// A consenting session that is left to the module's OWN flush timer. |
| 417 | /// |
| 418 | /// Everything else in this file makes a batch go by calling `flush()`. That |
| 419 | /// cannot answer the question section 1c asks, which is whether a batch already |
| 420 | /// sitting in the buffer goes when nobody asks it to -- so this one emits, does |
| 421 | /// `act`, and then waits while the timer comes round twice. |
| 422 | /// |
| 423 | /// # Arguments |
| 424 | /// * `label` - Names the profile and the session. |
| 425 | /// * `act` - JavaScript run in the page after consent: the withdrawal under |
| 426 | /// test, or nothing at all for the control. |
| 427 | async function timerSession({ label, act }) { |
| 428 | // `asDriven` first, so a `--break` run drives the same module the Node-side |
| 429 | // checks above read. A session on the unpatched file under a break flag is a |
| 430 | // session answering about a different program. |
| 431 | const src = withFastFlush(asDriven(fs.readFileSync(SRC_JS, 'utf8'))); |
| 432 | const requests = []; |
| 433 | const profile = scratch('telemetry-' + label); |
| 434 | fs.rmSync(profile, { recursive: true, force: true }); |
| 435 | const s = await open({ |
| 436 | name: 'tele-' + label, |
| 437 | profile, |
| 438 | defaults: false, |
| 439 | route: async (page) => { |
| 440 | await serveModule(page, src); |
| 441 | page.on('request', (r) => { |
| 442 | requests.push({ url: r.url(), method: r.method(), body: r.postData() || '' }); |
| 443 | }); |
| 444 | }, |
| 445 | }); |
| 446 | try { |
| 447 | const armed = await s.page.evaluate(() => { |
| 448 | const T = window.DaimondTelemetry; |
| 449 | T.consent({ wave: 3 }); |
| 450 | T.emit('app.open', 830); |
| 451 | T.emit('panel.open', 2); |
| 452 | return T.armed(); |
| 453 | }); |
| 454 | if (act) await s.page.evaluate(act); |
| 455 | await s.page.waitForTimeout(FAST_FLUSH * 3); |
| 456 | return { armed, batches: requests.filter(toTelemetry).length }; |
| 457 | } finally { |
| 458 | try { await s.browser.close(); } catch (e) { /* ignore */ } |
| 459 | } |
| 460 | } |
| 461 | |
| 462 | /// Does taking consent back actually stop a batch that is already queued? |
| 463 | /// |
| 464 | /// Answered at the network, in two sessions that differ by one line: the control |
| 465 | /// consents and waits, and must SEND -- without that, "nothing was sent" is a |
| 466 | /// statement about a timer that never fired. |
| 467 | /// |
| 468 | /// The withdrawal is not a guessed name. Anything the module exports whose name |
| 469 | /// says it takes consent back is called; if it exports nothing of the kind, that |
| 470 | /// is itself the answer, and no session is run for it. |
| 471 | /// A session that agrees, optionally withdraws, and then RELOADS. |
| 472 | /// |
| 473 | /// The reload is the point. Consent that does not survive one covers a tester's |
| 474 | /// first sitting and no other; a withdrawal that does not survive one is undone |
| 475 | /// by the next visit, which is worse than never having offered it. Both are |
| 476 | /// answered by the same session with one flag between them. |
| 477 | /// |
| 478 | /// The boot path is simulated the way `rearm()` in `www/js/passcode.js` drives |
| 479 | /// it -- `resume()` with the wave and account the gateway would answer on this |
| 480 | /// boot -- because the gateway is not part of a world and the property under |
| 481 | /// test is the browser's, not the server's. |
| 482 | /// |
| 483 | /// # Arguments |
| 484 | /// * `label` - Names the profile and the session. |
| 485 | /// * `withdrawFirst` - Take consent back before reloading. |
| 486 | async function reloadSession({ label, withdrawFirst }) { |
| 487 | const src = withFastFlush(asDriven(fs.readFileSync(SRC_JS, 'utf8'))); |
| 488 | const requests = []; |
| 489 | const profile = scratch('telemetry-' + label); |
| 490 | fs.rmSync(profile, { recursive: true, force: true }); |
| 491 | const s = await open({ |
| 492 | name: 'tele-' + label, |
| 493 | profile, |
| 494 | defaults: false, |
| 495 | route: async (page) => { |
| 496 | await serveModule(page, src); |
| 497 | page.on('request', (r) => { |
| 498 | requests.push({ url: r.url(), method: r.method(), body: r.postData() || '' }); |
| 499 | }); |
| 500 | }, |
| 501 | }); |
| 502 | try { |
| 503 | await s.page.evaluate((take) => { |
| 504 | const T = window.DaimondTelemetry; |
| 505 | T.consent({ wave: 3, account: 'acct-under-test' }); |
| 506 | T.emit('app.open', 830); |
| 507 | if (take) T.withdraw(); |
| 508 | }, !!withdrawFirst); |
| 509 | // Everything before this line is discarded with the page; what is being |
| 510 | // asked is what the NEXT visit does -- so the count starts HERE. Counting |
| 511 | // from the top of the session would let a batch sent before the reload |
| 512 | // stand in for one sent after it, and the consent half of the pair would |
| 513 | // then pass on a build that never resumed at all. |
| 514 | const beforeReload = requests.length; |
| 515 | await s.page.reload({ waitUntil: 'domcontentloaded' }); |
| 516 | const back = await s.page.evaluate(() => { |
| 517 | const T = window.DaimondTelemetry; |
| 518 | const resumed = T.resume({ wave: 3, account: 'acct-under-test' }); |
| 519 | // `panel.open` and nothing else, because a code is what makes the |
| 520 | // answer unambiguous. The dying page flushes on `pagehide` -- that is |
| 521 | // `app.close`, added with the emit sites -- and its request starts |
| 522 | // during the reload, so COUNTING batches would let the old page's |
| 523 | // last gasp stand in for the new page's recorder. Code 4 can only |
| 524 | // have come from a recorder this visit restored. |
| 525 | T.emit('panel.open', 2); |
| 526 | return { resumed: resumed, armed: T.armed(), agreed: T.agreed('acct-under-test') }; |
| 527 | }); |
| 528 | await s.page.waitForTimeout(FAST_FLUSH * 3); |
| 529 | const after = requests.slice(beforeReload); |
| 530 | return Object.assign(back, { |
| 531 | batches: after.filter(toTelemetry).length, |
| 532 | carried: codesIn(after).has(4) }); |
| 533 | } finally { |
| 534 | try { await s.browser.close(); } catch (e) { /* ignore */ } |
| 535 | } |
| 536 | } |
| 537 | |
| 538 | /// A session that consents FIRST and is then used, with this file emitting |
| 539 | /// nothing at all. |
| 540 | /// |
| 541 | /// Every other session here calls `emit()` by hand, which proves the transport |
| 542 | /// and proves nothing whatever about the app: a build with the call sites |
| 543 | /// deleted passes all of them. Here the only thing that can put an event in the |
| 544 | /// buffer is `www/js/daimond.js` doing its own work, so what comes back is what |
| 545 | /// the app actually reports when somebody uses it. |
| 546 | async function realSession(label) { |
| 547 | const src = withFastFlush(asDriven(fs.readFileSync(SRC_JS, 'utf8'))); |
| 548 | const requests = []; |
| 549 | const profile = scratch('telemetry-' + label); |
| 550 | fs.rmSync(profile, { recursive: true, force: true }); |
| 551 | const s = await open({ |
| 552 | name: 'tele-' + label, |
| 553 | profile, |
| 554 | defaults: false, |
| 555 | route: async (page) => { |
| 556 | await serveModule(page, src); |
| 557 | page.on('request', (r) => { |
| 558 | requests.push({ url: r.url(), method: r.method(), body: r.postData() || '' }); |
| 559 | }); |
| 560 | }, |
| 561 | }); |
| 562 | try { |
| 563 | // Consent before anything is done, so everything after it is inside the |
| 564 | // agreement. Nothing else is emitted from here. |
| 565 | const armed = await s.page.evaluate(() => |
| 566 | window.DaimondTelemetry.consent({ wave: 3, account: 'acct-under-test' })); |
| 567 | // A real turn, through the real composer, answered by the mock provider. |
| 568 | await chat(s, 'Make a file called notes.md with one line in it.'); |
| 569 | await s.page.waitForTimeout(FAST_FLUSH * 3); |
| 570 | return { armed, requests, codes: codesIn(requests) }; |
| 571 | } finally { |
| 572 | try { await s.browser.close(); } catch (e) { /* ignore */ } |
| 573 | } |
| 574 | } |
| 575 | |
| 576 | /// The consent card, driven the way a person drives it. |
| 577 | /// |
| 578 | /// The gateway is not part of a world, so the redemption reply is stubbed -- |
| 579 | /// with the shape `gateway/src/handlers/passcode.rs` actually answers, `wave` |
| 580 | /// and `account_id` included. Everything else is the app: the real card, the |
| 581 | /// real strings, the real module. |
| 582 | /// |
| 583 | /// # Arguments |
| 584 | /// * `answer` - `'yes'`, `'no'`, or `'escape'` for the person who closes it. |
| 585 | async function cardSession(answer) { |
| 586 | const src = withFastFlush(asDriven(fs.readFileSync(SRC_JS, 'utf8'))); |
| 587 | const requests = []; |
| 588 | const profile = scratch('telemetry-card-' + answer); |
| 589 | fs.rmSync(profile, { recursive: true, force: true }); |
| 590 | const s = await open({ |
| 591 | name: 'tele-card-' + answer, profile, defaults: false, |
| 592 | route: async (page) => { |
| 593 | await serveModule(page, src); |
| 594 | page.on('request', (r) => { |
| 595 | requests.push({ url: r.url(), method: r.method(), body: r.postData() || '' }); |
| 596 | }); |
| 597 | }, |
| 598 | }); |
| 599 | try { |
| 600 | const shown = await s.page.evaluate(async () => { |
| 601 | window.DaimondGateway.state = () => ({ authed: false, refused: 'beta_only', refusal: '' }); |
| 602 | window.DaimondGateway.redeemPasscode = async () => ({ |
| 603 | created: true, pro: true, wave: 3, handle: 'quiet-harbour-41', |
| 604 | authed: true, account: 'acct-under-test' }); |
| 605 | window.DaimondPasscode.show(); |
| 606 | await new Promise((r) => setTimeout(r, 200)); |
| 607 | document.querySelector('.beta-input').value = 'a1b2-c3d4-e5f6'; |
| 608 | // The Redeem button is the last in the entry card's row, whatever it |
| 609 | // is called in the locale under test. |
| 610 | const row = document.querySelectorAll('.beta-box .beta-row button'); |
| 611 | row[row.length - 1].click(); |
| 612 | await new Promise((r) => setTimeout(r, 500)); |
| 613 | const box = document.querySelector('.beta-consent'); |
| 614 | if (!box) return { asked: false }; |
| 615 | const link = box.querySelector('a.legal-link'); |
| 616 | const btns = box.querySelectorAll('.beta-row button'); |
| 617 | return { |
| 618 | asked: true, |
| 619 | answers: btns.length, |
| 620 | // Neither answer may be the bigger one. Read off the rendered |
| 621 | // boxes rather than off the class names, because a class that |
| 622 | // stopped meaning what it says is exactly how a "real choice" |
| 623 | // quietly becomes a nudge. |
| 624 | sameSize: btns.length === 2 |
| 625 | && Math.abs(btns[0].getBoundingClientRect().height |
| 626 | - btns[1].getBoundingClientRect().height) < 2, |
| 627 | href: link ? link.getAttribute('href') : '', |
| 628 | // NAMED APART from the reading taken after the answer. Both were |
| 629 | // called `armed`, and the merge below silently overwrote this one |
| 630 | // with that one -- so the check that nothing is armed while the |
| 631 | // question is still up was reading the state AFTER yes was |
| 632 | // pressed, and failed on a working app. A check that reports the |
| 633 | // wrong moment is worse than no check: it sends somebody hunting |
| 634 | // through the app for a defect that is in the test. |
| 635 | armedBefore: window.DaimondTelemetry.armed(), |
| 636 | }; |
| 637 | }); |
| 638 | if (shown.asked) { |
| 639 | await s.page.evaluate((how) => { |
| 640 | const btns = document.querySelectorAll('.beta-consent .beta-row button'); |
| 641 | if (how === 'no') btns[0].click(); |
| 642 | if (how === 'yes') btns[1].click(); |
| 643 | }, answer); |
| 644 | if (answer === 'escape') { |
| 645 | // The way out that is not a button: the card's own key handler, |
| 646 | // which is what a person who wants no part of this presses. |
| 647 | await s.page.keyboard.press('Escape'); |
| 648 | } |
| 649 | } |
| 650 | await s.page.waitForTimeout(FAST_FLUSH * 2); |
| 651 | const state = await s.page.evaluate(() => ({ |
| 652 | armed: window.DaimondTelemetry.armed(), |
| 653 | agreed: window.DaimondTelemetry.agreed('acct-under-test'), |
| 654 | cardUp: !!document.querySelector('.beta-scrim'), |
| 655 | })); |
| 656 | return Object.assign(shown, state, { batches: requests.filter(toTelemetry).length }); |
| 657 | } finally { |
| 658 | try { await s.browser.close(); } catch (e) { /* ignore */ } |
| 659 | } |
| 660 | } |
| 661 | |
| 662 | function withdrawalNames() { |
| 663 | return Object.keys(client) |
| 664 | .filter((k) => /withdraw|revoke/i.test(k) && typeof client[k] === 'function'); |
| 665 | } |
| 666 | |
| 667 | async function withdrawal() { |
| 668 | const names = withdrawalNames(); |
| 669 | const control = await timerSession({ label: 'wd-control', act: '' }); |
| 670 | const after = names.length |
| 671 | ? await timerSession({ label: 'wd-stop', |
| 672 | act: `window.DaimondTelemetry.${names[0]}();` }) |
| 673 | : null; |
| 674 | return { names, control: control.batches, after: after ? after.batches : -1 }; |
| 675 | } |
| 676 | |
| 677 | // ┌───────────────────────────────────────────────────────────────────┐ |
| 678 | // │ 1. The two copies of the vocabulary agree │ |
| 679 | // └───────────────────────────────────────────────────────────────────┘ |
| 680 | |
| 681 | // The module AS THIS RUN DRIVES IT. Under `--break` the page is served a |
| 682 | // patched copy, so a Node-side check that read the file on disk would be |
| 683 | // checking a different module from the one in the browser -- and would go green |
| 684 | // while the browser went red, which is the shape of a check that proves nothing. |
| 685 | const nodeCopy = (() => { |
| 686 | if (!BREAK) return SRC_JS; |
| 687 | const p = scratch('telemetry-driven-' + BREAK + '.js'); |
| 688 | fs.writeFileSync(p, asDriven(fs.readFileSync(SRC_JS, 'utf8'))); |
| 689 | return p; |
| 690 | })(); |
| 691 | const client = createRequire(import.meta.url)(nodeCopy); |
| 692 | |
| 693 | /// The gateway's copy, read out of the Rust: variant → code, and variant → name. |
| 694 | function rustVocabulary() { |
| 695 | const rs = fs.readFileSync(SRC_RS, 'utf8'); |
| 696 | const codes = {}, names = {}; |
| 697 | for (const m of rs.matchAll(/^\s{4}([A-Z][A-Za-z]*)\s*=\s*(\d+),$/gm)) codes[m[1]] = Number(m[2]); |
| 698 | for (const m of rs.matchAll(/Self::([A-Za-z]+)\s*=>\s*"([a-z.]+)",/g)) names[m[1]] = m[2]; |
| 699 | const out = {}; |
| 700 | for (const v of Object.keys(codes)) if (names[v]) out[codes[v]] = names[v]; |
| 701 | return out; |
| 702 | } |
| 703 | |
| 704 | const rust = rustVocabulary(); |
| 705 | const js = {}; |
| 706 | client.EVENTS.forEach((e) => { js[e.code] = e.name; }); |
| 707 | |
| 708 | check('the gateway\'s event list was actually read', Object.keys(rust).length > 0, |
| 709 | `${Object.keys(rust).length} events found in telemetry.rs`); |
| 710 | check('the client\'s event list was actually read', Object.keys(js).length > 0, |
| 711 | `${Object.keys(js).length} events found in telemetry.js`); |
| 712 | // Both directions, so neither copy can be a superset of the other unnoticed. |
| 713 | const missingInRust = Object.keys(js).filter((c) => rust[c] !== js[c]); |
| 714 | const missingInJs = Object.keys(rust).filter((c) => js[c] !== rust[c]); |
| 715 | check('every event the client can send, the gateway knows by the same name', |
| 716 | missingInRust.length === 0, missingInRust.map((c) => `${c}=${js[c]}`).join(', ')); |
| 717 | check('and the gateway knows no event the client does not', |
| 718 | missingInJs.length === 0, missingInJs.map((c) => `${c}=${rust[c]}`).join(', ')); |
| 719 | check('every event says what its number means and what question it answers', |
| 720 | client.EVENTS.every((e) => e.n && e.n.length > 3 && e.asks && e.asks.length > 20), |
| 721 | client.EVENTS.filter((e) => !e.asks || e.asks.length <= 20).map((e) => e.name).join(', ')); |
| 722 | // The declaration is a user-facing document; a code that moved would silently |
| 723 | // re-label every batch already collected. |
| 724 | check('no two events share a code or a name', |
| 725 | new Set(client.EVENTS.map((e) => e.code)).size === client.EVENTS.length |
| 726 | && new Set(client.EVENTS.map((e) => e.name)).size === client.EVENTS.length); |
| 727 | |
| 728 | // ── 1b. And the TOOL table is the registry's, not somebody's memory ── |
| 729 | // |
| 730 | // `TOOLS` turns a tool's name into the integer that goes on the wire, and a name |
| 731 | // that is not in it becomes 0 -- 'other'. So a tool missing from the list is not |
| 732 | // a gap in the data: it is a WRONG NUMBER, reported under a heading that says |
| 733 | // something else was run. Half the table went missing once and was corrected by |
| 734 | // hand; twelve names went missing again -- ask, the Social pair, the spreadsheet |
| 735 | // and document tools, the links, runs and verify -- and by 2026-08-28 a third of |
| 736 | // the tool surface reported as 'other', which is exactly the evidence a decision |
| 737 | // about what to sell in a tool pack would rest on. |
| 738 | // |
| 739 | // READ OUT OF THE REGISTRY, `Tool::name` in `src/tools.rs`, because the two ways |
| 740 | // this was kept true before both depended on somebody remembering. A variant |
| 741 | // added there adds an arm here, and this goes red the same day. |
| 742 | // |
| 743 | // ONE DIRECTION ONLY, deliberately. Every registry name must be in the table; a |
| 744 | // name in the table that the registry no longer has is NOT a failure -- a tool |
| 745 | // that is removed keeps its position for ever, or every number already gathered |
| 746 | // under the ones after it changes meaning. |
| 747 | const SRC_TOOLS = path.join(ROOT, 'src/tools.rs'); |
| 748 | |
| 749 | /// Every tool's wire name, read off `Tool::name`'s match arms. |
| 750 | /// |
| 751 | /// Two shapes of arm, because one name is a constant: `Tool::SocialSend => |
| 752 | /// SOCIAL_SEND_TOOL`, which is resolved by finding the constant's own value. An |
| 753 | /// arm whose right-hand side is neither is reported rather than skipped, or a |
| 754 | /// third shape would quietly shrink the list this checks against. |
| 755 | function registryToolNames() { |
| 756 | const rs = fs.readFileSync(SRC_TOOLS, 'utf8'); |
| 757 | // FROM THE FIRST ARM, not from the signature: several enums in that file have a |
| 758 | // `name(&self)`, and the first of them is a build system's. The arms end at the |
| 759 | // match's own closing brace. |
| 760 | const first = rs.match(/Tool::FileRead\s*=>\s*"file_read",/); |
| 761 | if (!first) return { names: [], odd: ['Tool::name\'s arms were not found at all'] }; |
| 762 | const arms = rs.slice(first.index, rs.indexOf('\n }', first.index)); |
| 763 | const out = [], odd = []; |
| 764 | for (const m of arms.matchAll(/Tool::[A-Za-z]+\s*=>\s*([^,\n]+),/g)) { |
| 765 | const rhs = m[1].trim(); |
| 766 | if (rhs.startsWith('"')) { out.push(rhs.slice(1, -1)); continue; } |
| 767 | const c = rs.match(new RegExp('const ' + rhs + ': &str = "([a-z_]+)"')); |
| 768 | if (c) out.push(c[1]); else odd.push(rhs); |
| 769 | } |
| 770 | return { names: out, odd }; |
| 771 | } |
| 772 | |
| 773 | const registry = registryToolNames(); |
| 774 | check('the tool registry was actually read out of src/tools.rs', |
| 775 | registry.names.length > 20 && registry.odd.length === 0, |
| 776 | `${registry.names.length} tools, unreadable arms: ${registry.odd.join(', ') || 'none'}`); |
| 777 | const unnamed = registry.names.filter((n) => client.TOOLS.indexOf(n) === -1); |
| 778 | check('EVERY TOOL THE REGISTRY HAS IS NAMED IN THE CLIENT\'S TABLE', |
| 779 | unnamed.length === 0, |
| 780 | unnamed.length ? `${unnamed.length} would report as 'other': ${unnamed.join(', ')}` : ''); |
| 781 | // 'other' is position 0 and is not a tool; nothing else may repeat, or two tools |
| 782 | // share a number and the operator cannot tell which ran. |
| 783 | check('and no tool shares a number with another', |
| 784 | new Set(client.TOOLS).size === client.TOOLS.length, |
| 785 | client.TOOLS.filter((n, i) => client.TOOLS.indexOf(n) !== i).join(', ')); |
| 786 | // ┌───────────────────────────────────────────────────────────────────┐ |
| 787 | // │ 1c. The consent moment, and the three things that must come with │ |
| 788 | // │ it. Written as implications, so they hold today and bite on │ |
| 789 | // │ the day somebody builds it │ |
| 790 | // └───────────────────────────────────────────────────────────────────┘ |
| 791 | // |
| 792 | // AUDITED 2026-08-14, AND NONE OF IT HAS MOVED. Nothing in the shipped app asks |
| 793 | // a beta tester whether they will send usage counts. Passcode redemption is |
| 794 | // built (`www/js/passcode.js`) and carries no such line; `www/js/legal.js` says |
| 795 | // so in its own header, naming "the beta passcode's consent line" as the one |
| 796 | // caller of `link()` that is not built; the published Privacy Policy enumerates |
| 797 | // what the gateway holds without telemetry among it and states "We use no |
| 798 | // analytics, advertising or cross-site tracking cookies". |
| 799 | // |
| 800 | // This section used to assert those two absences flat -- nothing grants consent, |
| 801 | // nothing loads the module -- which is a check that goes red on the FIRST |
| 802 | // correct step towards building the feature and says nothing about whether the |
| 803 | // step was safe. What it should have been asserting is what has to be true |
| 804 | // ALONGSIDE a grant, and the most important of those was never written down at |
| 805 | // all: |
| 806 | // |
| 807 | // A GRANTED RECORDER CANNOT BE STOPPED FROM OUTSIDE `telemetry.js`. |
| 808 | // |
| 809 | // Measured, not reasoned: with the flush interval shortened and a consenting |
| 810 | // session left to its own timer, replacing `window.DaimondTelemetry` wholesale |
| 811 | // with a stub -- the most a module that may not edit `telemetry.js` can do -- |
| 812 | // stops nothing. `armed()` reads FALSE while the queued batch goes anyway |
| 813 | // ({"v":1,"b":2536699389,...,"e":[[1,1,830],[4,1,2]]} left a stubbed page on |
| 814 | // 2026-08-14). A settings pane built on that would show a tester "off" while |
| 815 | // their events were in flight, which is the exact lie this whole file exists to |
| 816 | // make impossible. The recorder holds its buffer, its timer and its `fetch` in |
| 817 | // one closure and exports no way back out: `consent()` mints, and nothing |
| 818 | // un-mints. A withdrawal belongs in that closure, beside `consent()`, and |
| 819 | // nowhere else. |
| 820 | // |
| 821 | // So the three implications below. All three now BITE rather than hold |
| 822 | // vacuously -- consent is granted from `www/js/passcode.js`, the client is |
| 823 | // loaded by `index.html`, and the policy carries the section it links to -- and |
| 824 | // each is proved red by a break: `halfway`, `eager` and `forget` on the first, |
| 825 | // `inert` on the second, `undisclosed` on the third. The vacuous branches are |
| 826 | // kept because they are what a build that UNWIRES this would take, and a check |
| 827 | // that cannot describe that state would simply go quiet. |
| 828 | |
| 829 | /// The files under `www/js` -- `telemetry.js` itself excepted, since a module |
| 830 | /// mentioning its own name proves nothing -- that match a pattern. |
| 831 | function scanFor(what) { |
| 832 | return fs.readdirSync(path.join(ROOT, 'www/js')) |
| 833 | .filter((f) => f.endsWith('.js') && f !== 'telemetry.js') |
| 834 | .filter((f) => what.test(fs.readFileSync(path.join(ROOT, 'www/js', f), 'utf8'))); |
| 835 | } |
| 836 | const callers = scanFor(/DaimondTelemetry\s*\.\s*consent/); |
| 837 | const emitters = BREAK === 'inert' ? [] : scanFor(/DaimondTelemetry\s*\.\s*emit/); |
| 838 | |
| 839 | const indexHtml = fs.readFileSync(path.join(ROOT, 'www/index.html'), 'utf8'); |
| 840 | // Paired with its own presence check: an absence proved against a file that was |
| 841 | // never read is the way both halves of this feature stayed invisible. |
| 842 | check('index.html was actually read, and does load the sibling modules', |
| 843 | indexHtml.indexOf('js/chunks.js') !== -1, `${indexHtml.length} bytes`); |
| 844 | const loaded = /<script[^>]+telemetry\.js/.test(indexHtml); |
| 845 | |
| 846 | // What the published policy would have to carry before the client may be loaded. |
| 847 | // An id and not a sentence: prose is edited and an anchor is not, and this is |
| 848 | // the anchor the consent line links to. |
| 849 | const POLICY = path.join(ROOT, 'www/guide/legal/privacy.html'); |
| 850 | const policyHtml = fs.readFileSync(POLICY, 'utf8'); |
| 851 | check('the policy the consent line would link to was actually read', |
| 852 | policyHtml.indexOf('id="cookies"') !== -1, `${policyHtml.length} bytes`); |
| 853 | const disclosed = BREAK !== 'undisclosed' && /id="beta-telemetry"/.test(policyHtml); |
| 854 | |
| 855 | // ── 1. Consent granted ⟹ consent can be taken back ────────────────── |
| 856 | // |
| 857 | // The property that matters most, and the only one worth a live session: not |
| 858 | // "checked at startup" but checked against a batch that is already queued. |
| 859 | // STANDING FROM THE MOMENT THE MODULE EXPORTS ONE, rather than from the moment |
| 860 | // something calls `consent()`. Stopping a queued batch is a property of the |
| 861 | // module, and a property nobody has watched hold is a property that stops |
| 862 | // holding: waiting for a caller would have left it unwatched over exactly the |
| 863 | // stretch of work where the withdrawal is written. |
| 864 | if (callers.length === 0 && withdrawalNames().length === 0) { |
| 865 | check('nothing grants consent, so nothing can be left unable to withdraw it', |
| 866 | true, 'vacuous: nothing grants consent and the module offers no way back'); |
| 867 | } else { |
| 868 | const wd = await withdrawal(); |
| 869 | check('a consenting session that is left alone DOES send, so the check below is not vacuous', |
| 870 | wd.control > 0, `${wd.control} batch(es) from the control session`); |
| 871 | check('the module exports a way to take consent back', wd.names.length > 0, |
| 872 | wd.names.length ? wd.names.join(', ') |
| 873 | : `granted by ${callers.join(', ') || 'something'}, and telemetry.js exports ` |
| 874 | + 'none of it — a recorder is minted by consent() and nothing un-mints it'); |
| 875 | check('AND WITHDRAWING STOPS A BATCH THAT IS ALREADY QUEUED', wd.after === 0, |
| 876 | wd.after < 0 ? 'never ran: there is nothing to withdraw with' |
| 877 | : `${wd.after} batch(es) left AFTER consent was withdrawn`); |
| 878 | |
| 879 | // ── And both answers survive the reload that used to lose them ── |
| 880 | // |
| 881 | // The pair is the check. A build that never resumed would pass the |
| 882 | // withdrawal half while covering one sitting per tester, and a build that |
| 883 | // resumed unconditionally would pass the consent half while sending for |
| 884 | // somebody who had said stop. Only one build passes both. |
| 885 | const kept = await reloadSession({ label: 'wd-kept', withdrawFirst: false }); |
| 886 | check('an agreement survives a reload, so the test is not one sitting per tester', |
| 887 | kept.resumed === true && kept.armed === true && kept.carried === true, |
| 888 | `resumed ${kept.resumed}, armed ${kept.armed}, the new page's own event ` |
| 889 | + `${kept.carried ? 'arrived' : 'DID NOT arrive'} (${kept.batches} batch(es))`); |
| 890 | |
| 891 | // ── And the card itself: three ways to say no, one to say yes ── |
| 892 | // |
| 893 | // The property the whole design rests on is that silence is a NO. It is |
| 894 | // structurally true -- nothing but a pressed button calls `consent()` -- and |
| 895 | // structural truths are exactly the ones that stop being true quietly, so |
| 896 | // all three refusals are driven rather than argued. |
| 897 | const said = await cardSession('yes'); |
| 898 | check('the card asks, with both answers and a way to read what is sent', |
| 899 | said.asked === true && said.answers === 2 && said.sameSize === true |
| 900 | && said.href.indexOf('#beta-telemetry') !== -1, |
| 901 | `asked ${said.asked}, ${said.answers} answer(s), same size ${said.sameSize}, link ${said.href}`); |
| 902 | check('nothing is armed while the question is still on screen', |
| 903 | said.armedBefore === false, `armed before answering: ${said.armedBefore}`); |
| 904 | check('and pressing yes is what arms it, so the refusals below are not vacuous', |
| 905 | said.agreed === true, `agreed ${said.agreed}`); |
| 906 | |
| 907 | const nope = await cardSession('no'); |
| 908 | check('DECLINING LEAVES THE SAME STATE AS NEVER HAVING BEEN ASKED', |
| 909 | nope.armed === false && nope.agreed === false && nope.batches === 0, |
| 910 | `armed ${nope.armed}, agreed ${nope.agreed}, ${nope.batches} batch(es)`); |
| 911 | |
| 912 | const shut = await cardSession('escape'); |
| 913 | check('AND CLOSING THE CARD IS A DECLINE — not a question held open', |
| 914 | shut.armed === false && shut.agreed === false && shut.batches === 0 |
| 915 | && shut.cardUp === false, |
| 916 | `armed ${shut.armed}, agreed ${shut.agreed}, card still up ${shut.cardUp}, ` |
| 917 | + `${shut.batches} batch(es)`); |
| 918 | |
| 919 | const gone = await reloadSession({ label: 'wd-gone', withdrawFirst: true }); |
| 920 | check('AND A WITHDRAWAL SURVIVES ONE TOO — reopening the app does not start it again', |
| 921 | gone.resumed === false && gone.armed === false && gone.agreed === false |
| 922 | && gone.carried === false && gone.batches === 0, |
| 923 | `resumed ${gone.resumed}, armed ${gone.armed}, agreed ${gone.agreed}, ` |
| 924 | + `${gone.batches} batch(es) after reopening, event carried ${gone.carried}`); |
| 925 | } |
| 926 | |
| 927 | // ── 2. The client is loaded ⟹ something emits ─────────────────────── |
| 928 | // |
| 929 | // A client on the page with no call sites is a consent moment asking for |
| 930 | // permission to send nothing, which is a worse state than not shipping it: the |
| 931 | // tester has agreed to something and the operator has no data to show for it. |
| 932 | check('telemetry.js is loaded only where something actually emits', |
| 933 | !loaded || emitters.length > 0, |
| 934 | loaded ? `loaded, and ${emitters.length} file(s) name DaimondTelemetry.emit` |
| 935 | : 'vacuous: the client is not loaded'); |
| 936 | |
| 937 | // AND THE CALL SITES ARE DRIVEN, not counted. The check above is satisfied by a |
| 938 | // file that mentions the module; this one uses the app and reads what came out. |
| 939 | // A build whose emits were all deleted passes every other check in this file. |
| 940 | if (emitters.length > 0) { |
| 941 | console.log('\n— a consenting session that is USED, with this file emitting nothing —'); |
| 942 | const real = await realSession('real'); |
| 943 | check('the session consented, so there was somewhere for an event to go', real.armed === true); |
| 944 | const sent = [...real.codes].sort((a, b) => a - b); |
| 945 | // The two the turn itself must produce. Named rather than counted: "some |
| 946 | // events arrived" would pass on a build that reported only its own startup. |
| 947 | check('A REAL TURN REPORTS ITSELF — the app emitted turn.send and turn.done', |
| 948 | real.codes.has(7) && real.codes.has(8), `codes seen: ${sent.join(',') || 'none'}`); |
| 949 | check('and nothing the app did put a word of anybody\'s content on the wire', |
| 950 | leaks(real.requests, toApp).length === 0, leaks(real.requests, toApp).join('; ')); |
| 951 | const realFaults = real.requests.filter(toTelemetry).flatMap((r) => shapeFaults(r.body)); |
| 952 | check('and every batch it sent is whole numbers under the declared field names', |
| 953 | realFaults.length === 0, realFaults.join('; ')); |
| 954 | } |
| 955 | |
| 956 | // ── 3. The client is loaded ⟹ the policy says so ──────────────────── |
| 957 | // |
| 958 | // The order is the point. A build that sends usage counts under a policy that |
| 959 | // says "we use no analytics" has broken a published promise, whatever the |
| 960 | // dialog said. |
| 961 | check('telemetry.js is loaded only where the Privacy Policy describes what it sends', |
| 962 | !loaded || disclosed, |
| 963 | loaded ? `policy section id="beta-telemetry": ${disclosed ? 'present' : 'MISSING'}` |
| 964 | : 'vacuous: the client is not loaded'); |
| 965 | |
| 966 | // ┌───────────────────────────────────────────────────────────────────┐ |
| 967 | // │ 1b. Three ceilings, and two fields that are not counts │ |
| 968 | // └───────────────────────────────────────────────────────────────────┘ |
| 969 | // |
| 970 | // `MAX_N` guarded every field at both ends. It is `i32::MAX`, and `b` is eight |
| 971 | // hex digits of the build id read as a number -- a u32. So every build whose id |
| 972 | // begins 8-f (64 of 127 sealed builds, by the transparency log) was floored to |
| 973 | // zero by the client, and would have had its WHOLE BATCH refused by the gateway |
| 974 | // had it not been. `t` is whole seconds since 1970 and would have stopped in |
| 975 | // 2038. The checks below assert the property rather than the day's build id, |
| 976 | // which is what the previous check did and why it could pass on a coin flip. |
| 977 | |
| 978 | /// A named integer constant out of the Rust, or NaN. |
| 979 | function rustConst(name) { |
| 980 | const m = fs.readFileSync(SRC_RS, 'utf8') |
| 981 | .match(new RegExp('pub const ' + name + ':\\s*i64\\s*=\\s*([0-9_]+)\\s*;')); |
| 982 | return m ? Number(m[1].replace(/_/g, '')) : NaN; |
| 983 | } |
| 984 | const rustN = rustConst('MAX_N'), rustB = rustConst('MAX_BUILD'), rustT = rustConst('MAX_TIME'); |
| 985 | check('the gateway declares all three ceilings', |
| 986 | Number.isFinite(rustN) && Number.isFinite(rustB) && Number.isFinite(rustT), |
| 987 | `MAX_N=${rustN} MAX_BUILD=${rustB} MAX_TIME=${rustT}`); |
| 988 | check('the client and the gateway agree on the count ceiling', client.MAX_N === rustN, |
| 989 | `client ${client.MAX_N}, gateway ${rustN}`); |
| 990 | // The one that matters most: if these two ever differ again, the client either |
| 991 | // zeroes the field or the gateway drops the batch, and both are silent. |
| 992 | check('and on the build ceiling — the pair whose disagreement caused this', |
| 993 | client.MAX_BUILD === rustB, `client ${client.MAX_BUILD}, gateway ${rustB}`); |
| 994 | check('and on the send-stamp ceiling', client.MAX_TIME === rustT, |
| 995 | `client ${client.MAX_TIME}, gateway ${rustT}`); |
| 996 | check('the build ceiling covers every eight-hex-digit id', client.MAX_BUILD >= 0xffffffff, |
| 997 | String(client.MAX_BUILD)); |
| 998 | check('the send stamp outlives 2038', client.MAX_TIME > 2147483648, String(client.MAX_TIME)); |
| 999 | |
| 1000 | // The fixture, and the proof that it is not vacuous: an ordinal BELOW the count |
| 1001 | // ceiling would pass this check under the broken code too. |
| 1002 | const HI_ID = 'f7bd6f814c2a'; |
| 1003 | const HI_ORD = client.buildOrdinal(HI_ID); |
| 1004 | check('the fixture build id really is over the count ceiling, so this can fail', |
| 1005 | HI_ORD > client.MAX_N, `${HI_ID} -> ${HI_ORD}, MAX_N ${client.MAX_N}`); |
| 1006 | check('an eight-hex-digit build id reads as its true ordinal', |
| 1007 | HI_ORD === parseInt(HI_ID.slice(0, 8), 16), String(HI_ORD)); |
| 1008 | |
| 1009 | const hiBatch = client.pack(3, HI_ORD, [[1, 0, 830]], 0); |
| 1010 | check('and pack() carries it whole rather than flooring it to zero', |
| 1011 | hiBatch.b === HI_ORD, `b=${hiBatch.b}, wanted ${HI_ORD}`); |
| 1012 | check('and the module\'s own last gate accepts the batch carrying it', |
| 1013 | client.onlyIntegers(hiBatch) === true); |
| 1014 | check('the stamp is a real clock, not a floored one', |
| 1015 | hiBatch.t > 1750000000 && hiBatch.t < client.MAX_TIME, String(hiBatch.t)); |
| 1016 | |
| 1017 | // The other half of the property: widening two fields must not have widened the |
| 1018 | // one where the capacity actually is. A count is still a count. |
| 1019 | const overBatch = client.pack(3, HI_ORD, [[1, 0, client.MAX_N + 1]], client.MAX_N + 1); |
| 1020 | check('a count above the count ceiling is still floored to zero', |
| 1021 | overBatch.d === 0 && overBatch.e[0][2] === 0, |
| 1022 | `d=${overBatch.d}, n=${overBatch.e[0][2]}`); |
| 1023 | check('and a build id beyond eight hex digits is floored too', |
| 1024 | client.pack(3, client.MAX_BUILD + 1, [], 0).b === 0); |
| 1025 | |
| 1026 | // ┌───────────────────────────────────────────────────────────────────┐ |
| 1027 | // │ 2. Before consent, nothing leaves │ |
| 1028 | // └───────────────────────────────────────────────────────────────────┘ |
| 1029 | |
| 1030 | console.log('\n— a session that never consents —'); |
| 1031 | const quiet = await runSession({ label: 'quiet', |
| 1032 | patch: BREAK === 'consent' ? breakWithSelfConsent : (s) => s, giveConsent: false }); |
| 1033 | |
| 1034 | check('the module loaded into the page', quiet.present.module); |
| 1035 | check('the marker really is in a chat', quiet.present.chat); |
| 1036 | check('the marker really is a Diamond\'s name', quiet.present.diamond); |
| 1037 | check('the marker really is a file in the workspace', quiet.present.file); |
| 1038 | |
| 1039 | check('no recorder exists', quiet.before.armed === false); |
| 1040 | check('emitting does nothing', quiet.before.emitted === false); |
| 1041 | check('and a flush has nothing to send', quiet.before.flushed === false && quiet.sent === false); |
| 1042 | const quietBatches = quiet.requests.filter(toTelemetry); |
| 1043 | check('nothing was sent to the telemetry endpoint at all', quietBatches.length === 0, |
| 1044 | `${quietBatches.length} request(s)`); |
| 1045 | check('and no marker reached our origin by any other route', |
| 1046 | leaks(quiet.requests, toApp).length === 0, leaks(quiet.requests, toApp).join('; ')); |
| 1047 | |
| 1048 | // ┌───────────────────────────────────────────────────────────────────┐ |
| 1049 | // │ 3. After consent, batches leave — carrying numbers only │ |
| 1050 | // └───────────────────────────────────────────────────────────────────┘ |
| 1051 | |
| 1052 | console.log('\n— a session that consents —'); |
| 1053 | const live = await runSession({ label: 'live', |
| 1054 | patch: BREAK === 'note' ? breakWithNote : (s) => s, giveConsent: true }); |
| 1055 | |
| 1056 | check('the marker really is in a chat', live.present.chat); |
| 1057 | check('the marker really is a Diamond\'s name', live.present.diamond); |
| 1058 | check('the marker really is a file in the workspace', live.present.file); |
| 1059 | check('a beta grant mints a recorder', live.after && live.after.granted === true && live.after.armed === true); |
| 1060 | check('and the wave it was granted is the wave it holds', live.after && live.after.wave === 3); |
| 1061 | |
| 1062 | const batches = live.requests.filter(toTelemetry); |
| 1063 | check('a batch actually left the browser', batches.length > 0, `${batches.length} request(s)`); |
| 1064 | if (batches.length) console.log(' the batch, verbatim: ' + batches[0].body.slice(0, 300)); |
| 1065 | check('it was a POST with no query string on the address', |
| 1066 | batches.every((r) => r.method === 'POST' && r.url.indexOf('?') === -1)); |
| 1067 | // Which build a batch came from is the first thing an operator asks of a beta |
| 1068 | // report, and it is read asynchronously -- so the first flush of a session is |
| 1069 | // exactly the one a race would rob of it. Proved against `build.json`, not |
| 1070 | // against "not zero", so a wrong number could not pass. |
| 1071 | const stampedBuild = (() => { try { return JSON.parse(batches[0].body).b; } catch (e) { return -1; } })(); |
| 1072 | const wantBuild = (() => { |
| 1073 | try { return parseInt(JSON.parse(fs.readFileSync(path.join(ROOT, 'www/build.json'), 'utf8')).build.slice(0, 8), 16); } |
| 1074 | catch (e) { return -2; } |
| 1075 | })(); |
| 1076 | check('and it names the build it came from', stampedBuild === wantBuild, |
| 1077 | `sent ${stampedBuild}, build.json says ${wantBuild}`); |
| 1078 | |
| 1079 | // THE check. Every marker, against every body and address that went to our own |
| 1080 | // origin. |
| 1081 | const wire = leaks(live.requests, toTelemetry); |
| 1082 | check('NO WORD OF THE USER\'S CONTENT IS IN WHAT WAS SENT', wire.length === 0, wire.join('; ')); |
| 1083 | check('and none of it reached our origin by any other route', |
| 1084 | leaks(live.requests, toApp).length === 0, leaks(live.requests, toApp).join('; ')); |
| 1085 | |
| 1086 | const faults = batches.flatMap((r) => shapeFaults(r.body)); |
| 1087 | check('every batch is whole numbers under the declared field names', |
| 1088 | faults.length === 0, faults.join('; ')); |
| 1089 | |
| 1090 | // The batch is the one we asked for, not an empty shell that would make the |
| 1091 | // check above true for the wrong reason. |
| 1092 | const got = codesIn(live.requests); |
| 1093 | const want = new Set([1, 3, 6, 7, 14]); |
| 1094 | check('the batch carries exactly the events emitted after consent', |
| 1095 | got.size === want.size && [...want].every((c) => got.has(c)), |
| 1096 | `sent ${[...got].sort((a, b) => a - b).join(',')}`); |
| 1097 | // Emitted before the grant, and never kept: consent is not retroactive. |
| 1098 | check('and nothing emitted before consent was kept and sent later', |
| 1099 | !got.has(4) && !got.has(12), `panel.open=${got.has(4)} tool.run=${got.has(12)}`); |
| 1100 | |
| 1101 | // ┌───────────────────────────────────────────────────────────────────┐ |
| 1102 | // │ 3b. And the same property AT THE NETWORK, on a build id that │ |
| 1103 | // │ the old ceiling would have thrown away │ |
| 1104 | // └───────────────────────────────────────────────────────────────────┘ |
| 1105 | // |
| 1106 | // The check above compares against `www/build.json`, so what it proves depends |
| 1107 | // on the day's build id: `138e6581` fits under the old ceiling and `9732f5fd` |
| 1108 | // does not. A check that passes or fails on one hex digit is a check that will |
| 1109 | // report this defect fixed roughly half the time. Here the id is chosen, so the |
| 1110 | // property holds whatever has been built. |
| 1111 | |
| 1112 | console.log('\n— a session whose build id begins with f —'); |
| 1113 | const hi = await runSession({ label: 'hibuild', quick: true, giveConsent: true, |
| 1114 | buildId: HI_ID, patch: BREAK === 'narrow' ? breakWithNarrowCeiling : (s) => s }); |
| 1115 | |
| 1116 | const hiBatches = hi.requests.filter(toTelemetry); |
| 1117 | check('the high-build session sent a batch, so the check below is not vacuous', |
| 1118 | hiBatches.length > 0, `${hiBatches.length} request(s)`); |
| 1119 | const hiSent = (() => { try { return JSON.parse(hiBatches[0].body).b; } catch (e) { return -1; } })(); |
| 1120 | check('a build id beginning with f arrives whole, not as zero', |
| 1121 | hiSent === HI_ORD, `sent ${hiSent}, wanted ${HI_ORD}`); |
| 1122 | check('and the gateway would take it: it is inside the build ceiling both ends declare', |
| 1123 | hiSent > 0 && hiSent <= rustB && hiSent <= client.MAX_BUILD, |
| 1124 | `${hiSent} vs gateway ${rustB}`); |
| 1125 | const hiStamp = (() => { try { return JSON.parse(hiBatches[0].body).t; } catch (e) { return -1; } })(); |
| 1126 | check('and the send stamp is a plausible clock rather than a floored field', |
| 1127 | hiStamp > 1750000000 && hiStamp <= rustT, String(hiStamp)); |
| 1128 | |
| 1129 | // ┌───────────────────────────────────────────────────────────────────┐ |
| 1130 | // │ 4. The leak check, proved red │ |
| 1131 | // └───────────────────────────────────────────────────────────────────┘ |
| 1132 | |
| 1133 | console.log('\n— the same session, with a "note" field added on purpose —'); |
| 1134 | const leaky = await runSession({ label: 'leaky', patch: breakWithNote, giveConsent: true }); |
| 1135 | |
| 1136 | const leakyBatches = leaky.requests.filter(toTelemetry); |
| 1137 | check('the broken build still sent a batch, so the check below is not vacuous', |
| 1138 | leakyBatches.length > 0, `${leakyBatches.length} request(s)`); |
| 1139 | const caught = leaks(leaky.requests, toTelemetry); |
| 1140 | check('THE LEAK CHECK FIRES on a build that adds one string field', |
| 1141 | caught.length > 0, caught.join('; ')); |
| 1142 | check('and it names the chat the user typed', caught.some((c) => c.indexOf(MARK.chat) === 0), |
| 1143 | caught.join('; ')); |
| 1144 | check('the shape check fires on it too', leakyBatches.flatMap((r) => shapeFaults(r.body)).length > 0, |
| 1145 | leakyBatches.flatMap((r) => shapeFaults(r.body)).join('; ')); |
| 1146 | |
| 1147 | // ┌───────────────────────────────────────────────────────────────────┐ |
| 1148 | // │ 5. The before-consent check, proved red │ |
| 1149 | // └───────────────────────────────────────────────────────────────────┘ |
| 1150 | |
| 1151 | console.log('\n— a build that consents to itself, never having been asked —'); |
| 1152 | const forward = await runSession({ label: 'forward', patch: breakWithSelfConsent, giveConsent: false }); |
| 1153 | |
| 1154 | check('THE BEFORE-CONSENT CHECK FIRES on a build that arms itself', |
| 1155 | forward.requests.filter(toTelemetry).length > 0, |
| 1156 | `${forward.requests.filter(toTelemetry).length} request(s) — the quiet session had ${quietBatches.length}`); |
| 1157 | |
| 1158 | console.log('\n' + ok.length + ' ok, ' + bad.length + ' failed'); |
| 1159 | process.exit(bad.length ? 1 : 0); |