@chendesheng/comm100-auto-login 0.0.0-stage → 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,691 @@
1
- # Temporary Holding Version
1
+ # Comm100 Auto Login
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ An unpacked Chrome (MV3) extension that signs in to Comm100 test dashboards **in the background**,
4
+ using the login HTTP API rather than the sign-in form. You get the dashboard, not a form.
5
+
6
+ It remembers multiple accounts — **one per dashboard** — and always uses the **most recently used**
7
+ one for the site you are on.
8
+
9
+ ## Terminal account tool
10
+
11
+ Requires Node.js 22 or later. Install the CLI and bundled Chrome extension from npm:
12
+
13
+ ```bash
14
+ npm install --global --foreground-scripts @chendesheng/comm100-auto-login
15
+ ```
16
+
17
+ The install message prints the absolute extension folder inside the installed package:
18
+ `node_modules/@chendesheng/comm100-auto-login/extension`. Open `chrome://extensions`, enable
19
+ **Developer mode**, click **Load unpacked**, and select that folder. You can print its path again:
20
+
21
+ ```bash
22
+ comm100-accounts extension-path
23
+ ```
24
+
25
+ `--foreground-scripts` makes npm show the install message. If install scripts are disabled or their
26
+ output is hidden, the extension files are still included and `extension-path` works normally.
27
+ After updating the npm package, click **Reload** for the extension in Chrome to load the new code.
28
+
29
+ For development from a source checkout, install dependencies and link the executable instead:
30
+
31
+ ```bash
32
+ npm install
33
+ npm link
34
+ ```
35
+
36
+ Reload the updated extension at `chrome://extensions`, then run the one-time bridge setup:
37
+
38
+ ```bash
39
+ comm100-accounts setup
40
+ # Reload the Comm100 Auto Login extension once more to connect the installed native host.
41
+ ```
42
+
43
+ Setup registers a [Chrome native messaging host](https://developer.chrome.com/docs/extensions/develop/concepts/native-messaging)
44
+ for this extension and profile. It uses one temporary LevelDB snapshot to discover the profile's
45
+ connection ID; it never opens Chrome's original database. The launcher uses this installation's
46
+ absolute Node.js path, so Chrome does not need Node on its PATH. Setup currently supports Chrome on
47
+ macOS and Linux. Chrome must be running, with the extension enabled, for subsequent CLI commands.
48
+
49
+ All normal reads and writes go through the extension service worker over a private local socket.
50
+ Accounts and JWTs share the extension's `chrome.storage.local` state; no database snapshots are
51
+ made for `list`, `login`, or `tokens`, and there is no separate JWT cache file. Bridge configuration
52
+ contains only profile/connection metadata, not credentials or tokens.
53
+
54
+ ```bash
55
+ comm100-accounts list
56
+ comm100-accounts list --domain=livechat6dash.testing.comm100dev.io --siteId=10010
57
+ comm100-accounts list --siteId=10010
58
+ comm100-accounts list --show-password
59
+
60
+ comm100-accounts login agent@example.com
61
+ comm100-accounts login agent@example.com --siteId=10010
62
+ comm100-accounts login agent@example.com --domain=livechat6dash.testing.comm100dev.io
63
+ comm100-accounts login agent@example.com --cookie
64
+
65
+ comm100-accounts tokens
66
+ comm100-accounts tokens --domain=livechat6dash.testing.comm100dev.io --siteId=10010
67
+ ```
68
+
69
+ `list` prints unique rows ordered by dashboard, then site id, then email, in `siteId`, email, domain
70
+ columns. Site IDs are padded to 12 characters and emails to 30 characters, separated by a space;
71
+ longer values are preserved. Site ids compare as numbers (site 9999 before site 10002). Unknown sites
72
+ or domains appear as `-` and sort last. Domain and site filters are optional. `login` prints only
73
+ the JWT, so it can be captured with `token="$(comm100-accounts login agent@example.com)"`. Errors go
74
+ to stderr with a nonzero exit status.
75
+
76
+ `login --cookie` prints a single cookie string that can be passed directly to curl's `-b` option:
77
+
78
+ ```bash
79
+ curl -b "$(comm100-accounts login agent@example.com --siteId=10010 --cookie)" \
80
+ 'https://livechat6dash.testing.comm100dev.io/api/global/siteActivity:active'
81
+ ```
82
+
83
+ The output is `token_<siteId>=<jwt>; tokenExpire_<siteId>=<encoded-expiry>; siteList=<siteId>`.
84
+ The two token cookies hold the JWT and its URL-encoded expiry; `siteList` identifies the selected
85
+ site, matching the extension's session cookies. The expiry comes from the login response, or the
86
+ JWT's `exp` claim when no server expiry was supplied. No `Cookie:` prefix or cookie attributes are
87
+ printed. This option reuses the same JWT cache and leaves browser cookies untouched.
88
+
89
+ `list --show-password` inserts the saved password as the third column, after email, padded to
90
+ 20 characters. It uses the same password as `login`: verified password first, otherwise the pending
91
+ password. Missing passwords appear as `-`; longer passwords are printed in full.
92
+
93
+ An email or explicit site ID is usually enough to select an account. If the email matches multiple
94
+ site IDs across different dashboards, the default domain is `${NAMESPACE}dash.testing.comm100dev.io`.
95
+ For example, `NAMESPACE=livechat6 comm100-accounts login agent@example.com` selects the livechat6
96
+ dashboard. A unique match does not require `NAMESPACE`. Explicit `--domain` always takes precedence;
97
+ multiple sites remaining on the same dashboard require `--siteId`. Among records for the same site,
98
+ the most recently used account is preferred.
99
+
100
+ Setup discovers this extension in Chrome's `Default` profile. Use
101
+ `comm100-accounts setup --profile="/path/to/Profile 1"` to configure another profile, or
102
+ `--db="/path/to/Local Extension Settings/EXTENSION_ID"` to select its LevelDB directory for setup.
103
+ Use the same `--profile` or `--db` on subsequent commands to select that configured connection.
104
+ The service worker owns all storage writes and serializes them with account updates, avoiding
105
+ Chrome's LevelDB lock and stale in-memory data.
106
+
107
+ CLI login reuses a fresh JWT from the shared cache. Otherwise it attempts credential login once and
108
+ asks the service worker to save the result. Browser login can reuse that same JWT to restore session
109
+ cookies; successful browser sign-ins also update the shared cache. Both the JWT's `exp` and the
110
+ server's expiry time are checked, tokens too close to expiry are renewed, and a server-rejected JWT
111
+ is removed from the cache. Two-factor accounts require browser sign-in. CLI login does not change
112
+ browser cookies, and failed credentials are never automatically retried.
113
+
114
+ `tokens` prints all shared cached JWTs, including expired tokens, as a formatted JSON array ordered
115
+ by dashboard, site id, and email. Each entry includes the full JWT, account scope, cache save time,
116
+ server expiry, `fresh` status, decoded `header` and `payload`, and `claimTimes` with UTC dates for
117
+ numeric `iat`, `nbf`, and `exp` claims. Nested payload data is preserved. Decoding does not verify the
118
+ signature; `fresh` uses the same local expiry checks as `login`. A malformed JWT is listed with a
119
+ `decodeError` so other entries can still be inspected. Use `--domain` and/or `--siteId` to filter.
120
+ An empty cache prints `[]`. The command only reads from the worker and never signs in or renews tokens.
121
+
122
+ ### Publishing npm releases
123
+
124
+ `npm pack` and `npm publish` build `extension/` from the current browser source files. The npm
125
+ package includes that ready-to-load extension, CLI runtime modules and the install message.
126
+ Tests, development tools, local account data and `node_modules` are excluded from the archive.
127
+
128
+ The first release is published with `npm login --auth-type=web` followed by `npm publish`.
129
+ Then configure **Trusted publishing** in the npm package settings with:
130
+
131
+ - Provider: GitHub Actions
132
+ - Repository owner: `chendesheng`
133
+ - Repository: `Comm100Login`
134
+ - Workflow filename: `publish-npm.yml`
135
+ - Allowed action: publish
136
+
137
+ The workflow uses GitHub's OIDC identity and requires no npm token secret. Future releases run
138
+ automatically when a version tag is pushed:
139
+
140
+ ```bash
141
+ npm version patch
142
+ git push origin main --follow-tags
143
+ ```
144
+
145
+ The tag must match the package version, such as `v0.0.2`. CI installs dependencies, runs the test
146
+ suite, builds the extension during packing, and publishes the package with public access.
147
+
148
+ ## How it works
149
+
150
+ Sign-in happens in the service worker. Three requests, verified live against
151
+ `livechat6dash.testing.comm100dev.io`:
152
+
153
+ | # | Request | Purpose |
154
+ |---|---------|---------|
155
+ | 1 | `GET https://{branch}route.testing.comm100dev.io/api/v1/router/agents/getByEmail?email=…` | which `siteId` / `portalUrl` this email belongs to |
156
+ | 2 | `POST {portalUrl}/api/login/jwtToken/login` — `{ email, password, siteId }` | the JWT |
157
+ | 3 | `POST {portalUrl}/api/login/accounts/CrossPlatformLogin` — `{ token, siteId }` | turns the JWT into the session cookie |
158
+
159
+ Step 3 responds with `Set-Cookie`, and the extension **then re-reads the cookie and plants it
160
+ explicitly** (`chrome.cookies.set`) if it is missing — extension-initiated `fetch` is not guaranteed
161
+ to persist `Set-Cookie`, so the outcome is verified rather than assumed.
162
+
163
+ Once the cookie exists, the normal navigation you asked for simply renders authenticated. The sign-in
164
+ page is never opened, filled, or clicked.
165
+
166
+ ### Intercepting API requests
167
+
168
+ At `document_start`, a script in the page's main JavaScript world wraps `fetch` and asynchronous
169
+ `XMLHttpRequest`. For same-origin `/api/*` requests, it waits for the service worker to obtain a
170
+ usable JWT before sending, then replaces (or adds) `Authorization: Bearer …`. This also covers a
171
+ page holding an expired token in memory, or a cookie deleted immediately after the last login.
172
+ Concurrent requests for the same dashboard and site share a login attempt.
173
+
174
+ If the server returns HTTP 401 despite a locally valid token, the hook renews it and retries once
175
+ before delivering the response to the app. It preserves the method, body and other request headers;
176
+ 403 responses are passed through. Login endpoints, other origins and synchronous XHR are excluded.
177
+ Without a usable saved credential, the original request proceeds so the app can handle sign-in.
178
+ Explicit sign-out suppression, temporary retry cooldowns and renewal limits still apply.
179
+
180
+ The content script relays these requests to the worker; passwords are never sent to the page hook.
181
+ Background cookie monitoring and header rules remain as backstops, but cannot themselves pause a
182
+ page request while signing in. After changing the extension, reload it in `chrome://extensions`
183
+ and refresh existing dashboard tabs so they load the request hook.
184
+
185
+ Run `npm test` for the offline background and request-hook checks. These checks stub Chrome and
186
+ network boundaries; live dashboard behavior still needs verification after reloading the extension.
187
+
188
+ ### Static files rejected because the Cookie header is too large
189
+
190
+ HTML pages, scripts, stylesheets, images, fonts and media load outside the app's API hooks. The
191
+ worker watches their GET responses. An HTML 400 response with a Cookie header over a conservative
192
+ 4 KiB budget, or HTTP 431, triggers cleanup: expired JWT sessions first, then the oldest JWTs, along
193
+ with their `tokenExpire_*` cookies. The current page's site is kept; without a known site, the newest
194
+ token is kept. Saved accounts and unrelated cookies are preserved.
195
+
196
+ After successful cleanup, the extension reloads the affected tab so Chrome fetches the static files
197
+ again. A persistent guard allows at most one automatic reload per tab and dashboard per minute,
198
+ including when several assets fail together or the worker restarts. Watchdog and keep-alive tasks
199
+ do not restore evicted sessions; opening an evicted site can sign it in again normally.
200
+ SPA route and fragment changes within the same document do not prevent recovery. Navigating to
201
+ another document skips the reload without consuming the retry, and a rejected reload releases its
202
+ retry guard. The activity log distinguishes reload requests, skipped reloads and reload failures.
203
+
204
+ The worker cannot read response bodies, so it uses status, content type and measured cookie size
205
+ instead of matching Nginx's error text. The actual ingress limit is server-specific. If the current
206
+ JWT or unrelated cookies alone exceed that limit, removing older JWTs cannot solve it. Cleanup and
207
+ byte counts appear as `cookie-budget` entries in the popup's activity log. Reloading the page can
208
+ discard unsaved state.
209
+
210
+ ### Credentials belong to a dashboard
211
+
212
+ Every Comm100 test branch shares the `comm100dev.io` cookie domain, but they are **separate
213
+ deployments with separate databases**. An account that exists on `livechat3dash` usually does not
214
+ exist on `livechat6dash` (and when the same address does exist, it is a different `siteId`). Firing
215
+ one dashboard's password at another therefore does nothing except spend attempts against the
216
+ **lockout threshold**.
217
+
218
+ So an account is identified by **email *and* dashboard**, and `pickAccount()` never crosses one:
219
+
220
+ - `src/shared/platforms.js` holds the dashboard list (`wanTestPlatformsPrefixes`,
221
+ `lanTestPlatformsPrefixes`, `productionPlatformsPrefixes` — the same names the web client uses).
222
+ The "domain" of an account is that entry's host, **including the port** where there is one, so
223
+ `http://localhost:34500` and `https://localhost:3000` are different dashboards.
224
+ - The popup has no dashboard picker. A credential is recorded on the dashboard it was typed into (the
225
+ capture path), and the account list's dashboard filter is built from the dashboards that actually
226
+ have credentials — so `livechat3dash.testing.comm100dev.io` and `livechat3dash.comm100dev.io` list as
227
+ separate entries there.
228
+ - `livechat3dash.testing.comm100dev.io` (WAN) and `livechat3dash.comm100dev.io` (LAN) are *different*
229
+ entries on purpose: same name, different deployments.
230
+ - Signing in is a property of the account, not of the tab you are looking at, so a row's **Login
231
+ now** works from any page — it signs in to *that account's* dashboard and opens it in a new tab.
232
+ - Accounts saved before this existed are pinned to the first host they were ever used on
233
+ (`hosts[0]`) — that is where the credential was introduced, and anything appended later was a
234
+ successful sign-in. An account with no host history stays deliberately unscoped (a wildcard), which
235
+ is what an imported file expects; the picker asks for a dashboard on the next edit.
236
+
237
+ ### Which site? (`getByEmail`, never the URL)
238
+
239
+ A dashboard can serve **several sites** — `livechat6dash.testing.comm100dev.io` serves site 10010 to
240
+ one agent and 10012 to another — and a URL can name any of them. The panel's URL is therefore a
241
+ *claim*, not a fact, and it is never trusted:
242
+
243
+ - Which site an email uses on a dashboard comes from the route service (`getByEmail`) — the same call
244
+ that resolves `portalUrl`. Nobody types a site id anywhere; the popup has no field for it.
245
+ - A site learned from a successful sign-in is cached on the account and shown in the list as
246
+ `site 10010`. That cache makes the common case free: no route call when the URL agrees with it.
247
+ - When the URL names a site the credential disagrees with, the route service is asked. Owns it → sign
248
+ in for it. Does not own it → **nothing is attempted**; the dashboard shows its own sign-in page and
249
+ you sign in by hand (that credential is remembered, and its site comes from the same call).
250
+ - A captured credential records **no** site. A claim from the address bar is not evidence, and a
251
+ wrong site remembered as fact would skip exactly the check that keeps a rejected session from being
252
+ planted.
253
+
254
+ That distinction is the whole fix for "it keeps jumping to login": the sign-in API will happily issue
255
+ a token for a site the account does not belong to, and the panel then rejects it on every call and
256
+ reloads itself. Refusing to guess is what makes it stop.
257
+
258
+ A dashboard that has no route service of its own — `dash11.comm100staging.com`, and the local
259
+ dashboard — is routed to itself (`getByEmail` on its own origin). That is a best-effort fallback,
260
+ not a verified convention like `{branch}dash` → `{branch}route`. **Neither has been verified live.**
261
+
262
+ ### The session cookie
263
+
264
+ Discovered from the `Set-Cookie` headers of step 3:
265
+
266
+ ```
267
+ siteList=10012; Domain=comm100dev.io; Path=/; Secure; SameSite=Strict; HttpOnly
268
+ token_10012=<jwt>; Domain=comm100dev.io; Path=/; Secure; SameSite=Strict; HttpOnly
269
+ tokenExpire_10012=<url-encoded ISO>; Domain=comm100dev.io; Path=/; Secure; SameSite=Strict
270
+ ```
271
+
272
+ `token_{siteId}` **is the JWT cookie**. `tokenExpire_{siteId}` is a session cookie and is not HttpOnly.
273
+
274
+ ### Which site is this tab? (important)
275
+
276
+ Every Comm100 test branch shares the **`comm100dev.io`** cookie domain, so a `token_*` cookie for
277
+ one branch says *nothing* about another. A leftover `token_10012` from `livechat6dash` must never be
278
+ read as "livechat3dash is signed in".
279
+
280
+ So the extension takes the site from the URL instead:
281
+
282
+ ```
283
+ /ui/10008/livechat/dashboard/ -> site 10008, and only token_10008 counts
284
+ /login/?retUrl=…%2Fui%2F10008%2F… -> site 10008, read out of retUrl
285
+ ```
286
+
287
+ With the site known, the route service is not consulted at all — one fewer round trip, and no chance
288
+ of confusing branches. The route lookup only runs when the URL does not name a site.
289
+
290
+ ### Never showing the sign-in page
291
+
292
+ The control panel is a SPA that decides whether it is signed in only *after* loading its bundle.
293
+ Three things keep the form off the screen:
294
+
295
+ 1. **Keep-alive.** An alarm every 15 minutes renews any session with less than 30 minutes left
296
+ (the JWT lives ~2 h). In normal browsing the cookie is therefore always valid and the app never
297
+ considers routing to `/login` at all.
298
+ 2. **Replenish on deletion.** A `cookies.onChanged` listener puts `token_{siteId}` straight back
299
+ when it disappears — so deleting the cookie by hand is a non-event rather than a countdown to
300
+ the next bounce. A deliberate sign-out is respected: the worker ignores removals that follow a
301
+ trip to `/login`, and a `logout` message from the popup (no longer wired to a button, but still
302
+ handled) suppresses it entirely.
303
+ 3. **Start before the document.** `webNavigation.onBeforeNavigate` kicks off the sign-in as soon as
304
+ the navigation is known, and the cookie is planted the moment `jwtToken/login` returns —
305
+ `CrossPlatformLogin` is fire-and-forget, off the critical path. The control panel has to fetch and
306
+ parse its bundle before it checks, so this usually wins outright.
307
+ 4. **Watch the SPA's own routing.** The control panel can move to `/login` without loading a
308
+ document, so `pushState`/`replaceState`/`popstate` are watched too; otherwise a session that
309
+ vanishes mid-session leaves the sign-in page sitting there.
310
+ 5. **Cover the form** as a last resort. If the tab does land on `/login`, the content script first
311
+ asks a local-only pre-flight whether a sign-in is genuinely about to happen, and only then hides
312
+ `#login` behind a short "Signing in…" notice — removed the instant the attempt resolves, so a
313
+ refusal reveals the form and a success navigates away.
314
+
315
+ The pre-flight deliberately mirrors *every* gate the real attempt applies (no account, suppressed
316
+ after sign-out, still cooling down, site unresolvable). If it disagrees with
317
+ the attempt, the form would be hidden for nothing — a worse flicker than the one being prevented.
318
+
319
+ ### Acting as middleware on a rejected request
320
+
321
+ The wanted behaviour is "catch the 401, sign in again, update the cookie and resend". In MV3 the
322
+ first three are possible and the fourth is not:
323
+
324
+ | Step | Possible? |
325
+ |---|---|
326
+ | Read the token out of the request **before it is sent** | ✅ `webRequest.onBeforeSendHeaders` |
327
+ | Decide whether that token is still valid | ✅ decode the JWT's own `exp` claim |
328
+ | Sign in again and plant a fresh cookie | ✅ |
329
+ | Rewrite the outgoing `Cookie` header, or resend the request | ❌ a worker cannot modify or replay a page's request — blocking `webRequest` is enterprise-force-install only, and `declarativeNetRequest` has no cookie action |
330
+
331
+ Two hooks cover the gap:
332
+
333
+ **Pre-flight (before the request).** `onBeforeSendHeaders` sees the actual outgoing `Cookie` header
334
+ for every `/api/` call. The token is decoded and checked against its own `exp` claim — the JWT is the
335
+ authority, not the cookie expiry, because the cookie's copy can outlive the token it carries.
336
+ `hasSession` applies the same rule, so a live cookie holding an expired JWT no longer counts as a
337
+ session. If the token is missing or within 60 s of expiry, the sign-in starts **there** — a full
338
+ round trip before the 401 could tell us. The sign-in endpoints (`/api/login/…`) are excluded, since
339
+ they carry no session and must not be read as evidence of a bad one.
340
+
341
+ **Response (after the request).** The offending request cannot be replayed, and the obvious next
342
+ move — reloading the page — is deliberately **not** taken: it throws away whatever the user was
343
+ doing. Instead the worker re-authenticates and then does nothing to the tab. The fresh cookie is in
344
+ the jar, so the panel's *next* request simply succeeds, and the user retries when they are ready
345
+ rather than when we decide.
346
+
347
+ Bounds on that re-authentication:
348
+
349
+ - a budget of 3 per host per minute, so an endpoint rejecting for a reason signing in cannot fix
350
+ gives up rather than looping;
351
+ - 2 s coalescing, so a burst of failing calls causes one sign-in;
352
+ - nothing at all for a keep-alive renewal, where the page was never broken.
353
+
354
+ **Nothing in this extension reloads a working page.** The *only* remaining way anything moves a tab
355
+ is the content script leaving `/login` for `retUrl` — the recovery from a bounce, not a reload. An
356
+ earlier revision reloaded the tab that produced a 401; that was removed, because yanking the page
357
+ away throws out whatever the user was doing, and with the CDP path the token is attached before the
358
+ request leaves so a 401 should not arise in the first place.
359
+
360
+ This is a backstop, not the main defence — the keep-alive renews long before the JWT lapses, so a
361
+ 401 should mean the server rejected a cookie that had not yet expired (revocation, a password
362
+ change, or the JWT's `thumbprint` binding) rather than plain expiry.
363
+
364
+ ### Dead end: force-installing for webRequestBlocking
365
+
366
+ Tried, and closed by the platform. Force-installing was meant to grant `webRequestBlocking` so the
367
+ worker could hold a navigation while signing in. **Chrome refuses this on a Mac that is not
368
+ MDM-enrolled:**
369
+
370
+ ```
371
+ ExtensionSettings [BLOCKED]gpfpnldaoihmfpjcefhbfmkbdccngebd
372
+ Warning: This computer is not detected as enterprise managed so policy can only
373
+ automatically install extensions hosted on the Chrome Webstore.
374
+ Warning: Schema validation error: Unknown property: [BLOCKED]<id>
375
+ ```
376
+
377
+ That is a built-in Chromium string, not a misconfiguration — off-store automatic installs require a
378
+ genuinely managed device, because otherwise anything that can write that policy file could silently
379
+ install an extension with full network interception. The artifacts were correct (the derived
380
+ extension ID matched the key inside the CRX3 header and the policy passed `plutil -lint`); the
381
+ platform simply would not accept them. The packaging script and the `webRequestBlocking` code path
382
+ have since been deleted rather than left behind, since neither could ever run.
383
+
384
+ A `chrome.debugger` + CDP `Fetch` implementation was written next, on the reasoning that a paused
385
+ request can be resumed with rewritten headers without any policy. It was also removed: it cannot
386
+ attach while **DevTools is open** — which is exactly how a cookie gets cleared by hand — and when it
387
+ failed it said so only on a console nobody was reading. The `declarativeNetRequest` approach below
388
+ needs no attach, no permission prompt worth worrying about, and no policy.
389
+
390
+ ### Attaching the session to the panel's API calls
391
+
392
+ The control panel authenticates with **`Authorization: Bearer <token>`** — its repositories call
393
+ `sendHttpRequest({ auth: this.token })`. That is why re-planting the cookie alone does nothing: the
394
+ panel goes on sending whatever token it already had, every call 401s, and *that* is what makes it
395
+ reload itself. Cookie state is irrelevant to those calls.
396
+
397
+ So a `declarativeNetRequest` rule substitutes the header on the way out:
398
+
399
+ ```js
400
+ { id, priority: 1, action: { type: 'modifyHeaders', requestHeaders: [
401
+ { header: 'authorization', operation: 'set', value: `Bearer ${jwt}` } ] },
402
+ condition: { urlFilter: `||${host}/api/`, resourceTypes: ['xmlhttprequest'] } }
403
+ ```
404
+
405
+ Plus a higher-priority `allow` rule for `||{host}/api/login/`, so the sign-in calls are never handed
406
+ a stale bearer. The rule is republished whenever a session is established or renewed, and dropped on
407
+ install/startup (dynamic rules outlive the service worker, the bookkeeping that owns them does not).
408
+
409
+ Only `/api/` XHRs are touched. Navigations are never paused, held, or rewritten — that was tried and
410
+ removed, because it was the one change capable of making Chrome or the server respond unexpectedly.
411
+
412
+ **Two earlier attempts, both dead ends worth recording:**
413
+
414
+ - *Policy-installed extension for `webRequestBlocking`* — Chrome refuses off-store force-install on
415
+ a machine that is not MDM-enrolled.
416
+ - *`chrome.debugger` + CDP `Fetch`* — cannot attach while **DevTools is open**, which is exactly how
417
+ a cookie gets cleared by hand, and it failed silently. DNR needs no attach, shows no infobar, and
418
+ works whether or not DevTools is running.
419
+
420
+ ### Activity log
421
+
422
+ The worker keeps a rolling log of what it decided and why — `cookie-removed`, `sign-in ->
423
+ ok`/`-> bad-credentials`/`-> api-error`, `header-rule -> Authorization attached to …`, `401`, and `redirect: login page -> …`
424
+ when the content script leaves `/login`.
425
+
426
+ It exists because "it still reloads" was being diagnosed by guesswork across several rounds. That log
427
+ is what finally showed the truth: dozens of `/api/` calls returning 401 with **no interception
428
+ happening at all** — the panel reloading itself in reaction to them, not the extension reloading
429
+ anything. Capped at 40 entries in `chrome.storage.local`; it never influences behaviour.
430
+
431
+ The popup no longer renders the log — **Export activity** in the popup footer writes it to
432
+ `comm100-auto-login-activity.json` instead.
433
+
434
+ ### Route host mapping
435
+
436
+ ```
437
+ livechat6dash.testing.comm100dev.io -> livechat6route.testing.comm100dev.io
438
+ *.comm100.io -> route.comm100.io
439
+ any other listed dashboard -> itself (staging, http://localhost:34500)
440
+ anything else -> not supported, nothing happens
441
+ ```
442
+
443
+ ## Install
444
+
445
+ 1. Open `chrome://extensions`.
446
+ 2. Turn on **Developer mode**.
447
+ 3. **Load unpacked** → select this folder.
448
+ 4. Reload any already-open Comm100 tab once so the content script is injected.
449
+ 5. After pulling a change to `host_permissions` or `content_scripts`, hit **Reload** on the extension
450
+ card there as well — the service worker keeps the old manifest until you do.
451
+
452
+ The extension itself has no build step and no runtime dependencies: load the folder unpacked and it
453
+ works. The one generated file is the popup's stylesheet — see **Styling** below.
454
+
455
+ ### Styling
456
+
457
+ The popup is styled with Tailwind. Tailwind's browser build (the `cdn.jsdelivr.net` `<script>` from
458
+ the Tailwind docs) **cannot** be used here: an MV3 extension page is served with `script-src 'self'`
459
+ and Chrome rejects remote script origins, so the CDN script would be blocked and the popup would
460
+ render unstyled. The stylesheet is therefore compiled at development time and committed:
461
+
462
+ ```
463
+ npm install
464
+ npm run build:css # src/popup/tailwind.input.css -> src/popup/tailwind.css
465
+ npm run watch:css # same, rebuilding on change
466
+ ```
467
+
468
+ `tailwind.css` is a static file the popup links directly, so nothing about this is needed to *use* the
469
+ extension — only to change its styling. Class names are read out of `src/popup/popup.html` and
470
+ `src/popup/popup.js` (see the `@source` lines), so a class the script builds at runtime must appear
471
+ literally in one of those files, or Tailwind will never generate it.
472
+
473
+ ### Icons
474
+
475
+ `src/icons/icon.png` is the source artwork; the three sizes the manifest references are rendered from
476
+ it and committed:
477
+
478
+ ```
479
+ pip install pillow
480
+ python3 tools/make-icons.py # -> src/icons/icon16.png, icon48.png, icon128.png
481
+ python3 tools/make-icons.py --keep-background
482
+ ```
483
+
484
+ The render is a single LANCZOS pass over the square source — no cropping, no staged downscale, because
485
+ LANCZOS already scales its kernel to the reduction ratio.
486
+
487
+ A toolbar icon needs a transparent surround, and exports sometimes come out flattened onto an opaque
488
+ near-white page instead. So when the source is fully opaque the script locates the rounded tile drawn
489
+ in the artwork and knocks out everything outside it; when the source already has alpha it is used
490
+ exactly as drawn and nothing is inferred. `--keep-background` opts out for artwork that is genuinely a
491
+ full square.
492
+
493
+ Two caveats on that knockout, since it is a reconstruction rather than something read from the file:
494
+
495
+ - it clips the tile's soft drop shadow along with the page, and assumes all four corners share one
496
+ radius, inferred from where the outline stops curving;
497
+ - the edge threshold (`EDGE_THRESHOLD` in the script) is tuned to this artwork — the faintest run of
498
+ the tile outline reads ~22 against the page while the shadow just outside reaches ~16, so there is
499
+ not much room. If a future export has a softer edge, check the result on a dark background before
500
+ trusting it.
501
+
502
+ At 16 px the three elements (C, figure, padlock) are near the limit of what that size can carry
503
+ regardless; a simplified small variant would be a change to the artwork, not to this script.
504
+
505
+ ## Use
506
+
507
+ There is no "sign in automatically" switch. Signing in *is* the extension, so the off switch is
508
+ disabling the extension in `chrome://extensions`. A deliberate sign-out suppresses sign-in for
509
+ 5 minutes and shows a **Re-enable auto-login** button. Failed sign-ins leave the login form available
510
+ and retry automatically after the cooldown; they never require manual re-enabling. Permanent pause
511
+ flags saved by older versions are ignored and removed by the next normal storage write.
512
+
513
+ - **Login now** (per account) — signs in with that credential and opens
514
+ `https://{dashboard}/ui/{siteId}/entry/?fromlogin=1` — the panel's own post-sign-in path, which then
515
+ hands off to the site's landing page — in a **new tab**. The current tab is left alone: it is
516
+ usually the `/login` page (or another site's dashboard) that prompted the click, and taking it away
517
+ loses that context. The site is the one the route service gives for that email, so the button is
518
+ unaffected by which site the current URL happens to name. If the sign-in fails, no tab is opened
519
+ and the reason is shown in the popup's status line instead.
520
+ - **Open a Comm100 dashboard** — you are signed in before the page needs the session.
521
+ - **Show / Edit / Delete** per account. The account list is ordered by dashboard, then site id, then
522
+ email — the same order `comm100-accounts list` prints, so the newest sign-in no longer reshuffles
523
+ it — and each row shows the dashboard it belongs to and the site id it resolved to.
524
+ - **Edit** turns the row into its address and password fields, and *Save & verify* calls the login API
525
+ so a replacement password is only trusted once it is proven — a typo is caught immediately rather
526
+ than at the next sign-in. There is no dashboard or site field: an account is identified by
527
+ (address, dashboard), so signing in on another dashboard records that credential on its own, and the
528
+ site is always the route service's answer.
529
+ - **Dashboard / site filters** above the account list. Once more than a handful of credentials are
530
+ saved, the question is usually "which of these is the one for site 10010 on livechat6dash?", and
531
+ the two selects answer it without reading every row. The site choices follow the dashboard choice
532
+ (a site id only means something inside one dashboard), `any dashboard` / `any site` are filterable
533
+ values for credentials that have not been scoped yet, and anything the filters hide is stated as
534
+ `showing N of M` rather than left to look like an empty list. The filters live in the popup's
535
+ memory only: reopening the popup always shows every account again.
536
+ - **Export / Import** — the whole saved state, or **Export activity** for just the worker's rolling
537
+ log (`comm100-auto-login-activity.json`), which the popup itself no longer renders.
538
+
539
+ There is deliberately no **Add account** form. A credential is recorded by *using* it: sign in through
540
+ the panel's own form (for example when a second factor is required) and the typed credentials are
541
+ recorded **against the dashboard you are on**. They are stored as *pending* and promoted to a trusted
542
+ password as soon as something proves they work — a live session, or a later successful sign-in.
543
+ **Capture never calls the login API**, precisely so it cannot burn sign-in attempts (see *Lockout
544
+ safety*). *Edit* is the way back to a credential that was recorded wrong; *Import* is the way to
545
+ restore a saved set.
546
+
547
+ ## How logout really works
548
+
549
+ Removing the cookies **is** the logout. The control panel decides whether it is signed in purely by
550
+ looking for `token_{siteId}`, so once the cookies are gone the next navigation lands on `/login`.
551
+
552
+ `POST /api/login/accounts/logout` exists, but it belongs to the login service and rejects a
553
+ JWT-cookie session with `401 AuthenticationFailed` — verified against a live session, with and
554
+ without `Origin`/`Referer` headers. The extension therefore attempts it best-effort and **never
555
+ depends on it**; cookie removal is what does the work.
556
+
557
+ One consequence worth knowing: the JWT itself stays valid until its `exp` claim (roughly 2 hours)
558
+ even after sign-out, so this is a client-side sign-out, not server-side revocation.
559
+
560
+ ## Lockout safety
561
+
562
+ Comm100 locks an account once failed sign-ins exceed the site's password policy, and per
563
+ [Comm100's own troubleshooting article](https://help.comm100.com/why-am-i-receiving-account-locked-error-message-when-i-login)
564
+ the lock clears after **30 minutes** or when a site administrator unlocks it. The lockout arrives as
565
+ `errorCode 114`, not a wrong-password error.
566
+
567
+ Because of that the extension is deliberately conservative:
568
+
569
+ - a **60 s** cooldown after a failed attempt, including attempts triggered by outgoing requests;
570
+ - **form capture does not verify** — the user has just spent one sign-in attempt, and duplicating
571
+ every attempt would halve the budget;
572
+ - `errorCode 114` is reported as-is rather than retried.
573
+
574
+ There is no failure threshold that disables auto-login. The cooldown expires automatically. An
575
+ explicit popup action can retry sooner; deliberate sign-out suppression remains independent.
576
+
577
+ ## Testing
578
+
579
+ `test/harness.mjs` stubs the `chrome.*` APIs the extension uses and exercises the real modules.
580
+ Failure paths (bad password, MFA) are answered by a fetch stub so they can never lock a real
581
+ account; the happy path talks to the real API when credentials are supplied.
582
+
583
+ ```bash
584
+ # offline: pure logic, no credentials, no network
585
+ node test/harness.mjs
586
+
587
+ # full run including a live background sign-in
588
+ C100_EMAIL='you@example.com' C100_PASSWORD='…' node test/harness.mjs
589
+ ```
590
+
591
+ It asserts the planted cookie's exact shape (name, domain, `HttpOnly`, `Secure`, `SameSite`,
592
+ session-vs-persistent), the already-signed-in short-circuit, failure accounting, the MFA handoff,
593
+ cooldown/logout suppression, recency selection, the dashboard list itself (every entry parses to a
594
+ unique scope, WAN and LAN variants of one name stay distinct), that credentials never cross
595
+ dashboards, that a legacy record is pinned to the right one, that a site a credential does not own is
596
+ refused without spending a sign-in attempt, that a live session is recognised *before* any such
597
+ check, and that "nothing to do" outcomes stay out of the activity log. No credentials are stored in
598
+ the file.
599
+
600
+ ## Behaviour details
601
+
602
+ - Auto-login backs off for 60 s after a failed attempt per host, then becomes eligible automatically.
603
+ There is no permanent pause after repeated failures. An explicit popup action may retry sooner.
604
+ - A dashboard with no saved account, or an account that has none for the site the URL names, is
605
+ reported as such — the popup says which dashboard and site it *does* have, and the sign-in page is
606
+ left alone for you. Suspicious silences are shown in the popup, not just logged.
607
+ - Repeated 401s cannot re-sign-in without bound: at most 3 in a rolling minute per dashboard, and the
608
+ cap is not reset by its own successes (a loop where every sign-in "works" is the one that needs
609
+ bounding). Leaving the sign-in page is separately capped at 3 bounces per tab per minute, counted in
610
+ `sessionStorage` so it survives the navigation it is counting.
611
+ - Two-factor / forced-2FA accounts cannot be completed headlessly. The extension detects this,
612
+ does **not** count it as a credential failure, and pre-fills the form so you only type the code.
613
+ - Auto-login stays out of the way on `/login/forgotpassword`, `/login/sso`, and reset/2FA links.
614
+ - When a `/login` bounce sends you on, it follows the app's own `retUrl` if there is one; otherwise it
615
+ lands on the same `/ui/{siteId}/entry/?fromlogin=1` path the panel uses after a manual sign-in.
616
+
617
+ ## Security
618
+
619
+ Passwords are stored **unencrypted** in `chrome.storage.local` — local to this browser profile,
620
+ never synced, and erasable with **Clear all data**. This is the same trust model as Chrome's own
621
+ password manager storing them on disk. The only network requests the extension makes are the three
622
+ endpoints above plus the logout call, all over HTTPS to the Comm100 host you are already visiting.
623
+
624
+ ## Troubleshooting
625
+
626
+ | Symptom | Check |
627
+ |---|---|
628
+ | Nothing happens | The host must map to a route host (see above). Check the service worker console. |
629
+ | "no-route" | The email is unknown to the route service for that branch. |
630
+ | "No saved account for &lt;dashboard&gt;" | The credential is saved under a different dashboard, or its picker entry was never set. Edit the account and pick the right dashboard. |
631
+ | "No saved credential for site N on …" | Something is saved for that dashboard, but for a different site. The route service says which site this address uses here; sign in by hand for the other one and it will be remembered. |
632
+ | "… has X on site N, not site M" | The URL asks for a site this credential does not belong to — typically a site id copied from another branch. Open the site's own URL, or sign in by hand with the account that owns it. |
633
+ | Account exists but is never used | It is scoped to another dashboard on purpose. A dashboard never borrows another's credential. |
634
+ | Session not stored | `chrome://settings/cookies` → the `comm100dev.io` cookies `token_*`, `tokenExpire_*`, `siteList`. |
635
+ | `errorCode 114` | Account locked by password policy — wait 30 minutes or ask a site admin. |
636
+ | Signed out unexpectedly | Another tab or the app itself called logout; auto-login is suppressed for 5 minutes. |
637
+ | Service worker logs | `chrome://extensions` → Details → *Inspect views: service worker*. Logs are prefixed `[Comm100 Auto Login]`. |
638
+
639
+ ## Layout
640
+
641
+ ```
642
+ manifest.json
643
+ package.json dev-only: the Tailwind build script
644
+ src/shared/api.js login API + host / route mapping
645
+ src/shared/platforms.js the dashboard list credentials are scoped to
646
+ src/shared/cookies.js session cookie read / plant / clear
647
+ src/shared/storage.js chrome.storage.local schema + account lifecycle
648
+ src/background/service-worker.js
649
+ src/content/content.js thin: trigger, redirect, capture, prefill fallback
650
+ src/popup/popup.html account manager UI
651
+ src/popup/popup.js
652
+ src/popup/tailwind.input.css Tailwind source (not loaded by the extension)
653
+ src/popup/tailwind.css built stylesheet (the one the popup links)
654
+ src/icons/icon.png source artwork for the toolbar icons
655
+ src/icons/icon{16,48,128}.png rendered from it by tools/make-icons.py
656
+ tools/make-icons.py renders the icon sizes (dev-only, needs Pillow)
657
+ test/harness.mjs headless verification
658
+ ```
659
+
660
+ ## Verification status
661
+
662
+ Steps 1–3, every cookie flag, and the already-signed-in short-circuit were executed against the
663
+ live test environment, as were the `errorCode 100/101/114` failure shapes and the `logout` 401. The
664
+ harness additionally proved the planted cookies are genuinely accepted: an authenticated endpoint
665
+ (`/api/global/siteActivity:active`) returns `401` without them and gets past authentication with
666
+ them.
667
+
668
+ End-to-end in Chrome was confirmed too: with `voiceagenttest@comm100.com` saved for
669
+ `livechat3dash.testing.comm100dev.io`, deleting the session cookie and reloading a
670
+ `/login/?retUrl=…%2Fui%2F10008%2Fentry%2F` page landed on the Live Chat dashboard for site 10008
671
+ with no manual interaction, the popup reported `Signed in (token_10008, …)`, and the account's use
672
+ count incremented — a real background sign-in, not a lucky existing session.
673
+
674
+ The `*.comm100.io` production route host is inferred from the same login bundle and was checked for
675
+ reachability only, so if it behaves differently the extension falls back to doing nothing rather
676
+ than guessing.
677
+
678
+ Per-dashboard credential scoping was verified against the real browser, not just the harness: the two
679
+ accounts already saved in Chrome (`voiceagenttest@comm100.com`, `jackapi@livechat6.com`) were written
680
+ before accounts had a dashboard, and both were pinned to the right one by the `hosts[0]` rule and
681
+ picked up their site ids (`10008`, `10010`) from the route cache — visible in the popup with no
682
+ re-entry.
683
+
684
+ The loop itself was diagnosed from the activity log of a real occurrence, not from theory:
685
+ `/ui/10012/ai/dashboard/` on `livechat6dash` (site 10012 belongs to a different agent there) produced
686
+ `401` → `sign-in -> ok` → `header-rule` → `401` on repeat, because the extension was signing in for
687
+ the site *in the URL* and the panel rejected every call made with that token. `getByEmail` confirms
688
+ the account has exactly one site there (`10010`), which is what the fixed code checks before it
689
+ attempts anything. The `dash11.comm100staging.com` and `http://localhost:34500` entries are the local
690
+ full-stack dashboards; their self-routing has **not** been exercised, only their appearance in the
691
+ picker.