Skip to content

Browser Extension Developer Guide

Ed Mozley edited this page Aug 19, 2026 · 1 revision

Browser Extension β€” Developer Guide

How the toolbar extension talks to FreeITSM, what the endpoint does that the in-app one does not, and the trap that comes with having a second consumer of the same data.

Setup and day-to-day use are in Browser Extension.


1. Shape

A Manifest V3 extension, checked into the main repo under browser-extension/, so it ships with the server it talks to and cannot drift out of step with it.

File Role
browser-extension/manifest.json MV3 manifest. Permissions are storage and alarms only
browser-extension/background.js Service worker β€” polls on an alarm, computes the badge
browser-extension/popup.js (with its .html and .css) The dropdown summary
browser-extension/options.js (with its .html) Server URL, API key, poll interval
browser-extension/icons/ 16 / 32 / 48 / 128px

Server side there is exactly one endpoint: api/watchtower/get_dashboard_ext.php.


2. The endpoint, and why it is not the in-app one

Both return the same payload from the same function. They differ entirely in how they decide who is asking.

api/watchtower/get_dashboard.php api/watchtower/get_dashboard_ext.php
Authentication PHP session Authorization: header, matched against apikeys
Rate limited no yes β€” 60/min per key, configurable
CORS n/a handles OPTIONS preflight
Company scoping the signed-in analyst the key's owning analyst

That last row is the one to preserve. The Knowledge card is scoped to what its analyst may see:

$data = getWatchtowerData($conn, (int)($apiKeyRow['analyst_id'] ?? 0));

An external dashboard is not a reason to widen what a key can see.

Rate limiting fails open, deliberately

If the api_rate_limits write throws, the request is allowed through rather than rejected:

} catch (PDOException $e) {
    // If rate limiting fails, allow the request through
    $requestCount = 0;
}

A broken counter should not take a working dashboard down with it. Note this is a throughput control, not an authorisation one β€” the key check above it fails closed.

X-RateLimit-Limit, -Remaining and -Reset are sent on every response, including rejections, and old windows are swept on each request rather than by a cron job.


3. πŸ”΄ The trap: a second consumer of a shared payload

This is the reason the page exists.

Both endpoints call getWatchtowerData(). Change its output shape and you change the extension, which lives in a different folder, is written in a different language, and is not exercised by anything you are likely to be looking at.

It has already happened. A pass that fixed hardcoded status names in Watchtower changed morning_checks.statuses from a map keyed by status name to a list, and removed tickets.open and tasks.todo in favour of total_open and by_status. The dashboard was updated. The extension was not β€” so its ticket figure and its badge silently went to zero.

The check that missed it was a grep for other callers of api/watchtower/get_dashboard.php. The extension uses the _ext endpoint, so the grep came back clean and the conclusion β€” "Watchtower is the only consumer" β€” was wrong.

Before changing the payload, grep for getWatchtowerData β€” not for the endpoint filename. There are two callers and there always have been.

The rule that came out of it

The extension had also inherited the original faults: its badge counted a morning-check status called 'Fail', a name FreeITSM has never shipped, so a morning where every check failed added nothing to the badge and never turned it red. The popup summed tickets called Open, In Progress and On Hold, and tasks called To Do and In Progress.

Three consumers, each deriving the same rule, each getting it wrong the same way.

So the rule is computed once, server-side, and everything reads the answer:

'attention_count' => $mcAttentionCount,   // checks in a status that needs attention

If a rule has more than one consumer, compute it where the data is. Three copies of a rule is how all three came to be counting a status that did not exist.


4. πŸ“ The payload keys the extension reads

Break one of these and the badge or popup goes quiet rather than erroring.

Key Used by Notes
tickets.urgent_high badge, popup
tickets.unassigned badge, popup
tickets.total_open popup Replaced open + in_progress + on_hold
tickets.by_status popup Names the busiest open status
changes.unapproved badge, popup
changes.in_progress_today popup
changes.upcoming_7d popup
service_status.active_incidents badge, popup
contracts.expiring_30d badge
knowledge.overdue_reviews badge
morning_checks.not_started badge, popup
morning_checks.total_checks badge, popup
morning_checks.completed_today popup
morning_checks.attention_count badge, popup Replaced statuses['Fail']
tasks.overdue badge, popup
tasks.due_today badge, popup
tasks.total_open popup Replaced todo + in_progress

A quick assertion of the whole list is worth running after any payload change β€” fetch the endpoint and confirm every path above is present, rather than trusting that the one you edited was the only one.


5. What the popup may and may not claim

The popup shows text as well as numbers, and the same discipline applies as on the dashboard.

  • It says "all completed", never "all passed" β€” nothing in FreeITSM records which morning-check status is a pass.
  • It names your busiest open ticket status rather than assuming one is called In Progress, which may not exist and may not be the interesting one.
  • Status names now reach the popup and are written into innerHTML, so it has an escapeHtml(). They are free text typed by an administrator on a server the extension is pointed at, and are escaped rather than trusted.

6. Testing it without loading an extension

browser-extension/popup.js and browser-extension/background.js are plain scripts, so they can be parse-checked in a browser with a stubbed chrome object:

window.chrome = { runtime:{…}, storage:{ local:{get(){},set(){}} },
                  action:{ setBadgeText(){} }, alarms:{ create(){}, onAlarm:{addListener(){}} } };

Load both with a <script src> and assert onload fires and no SyntaxError reaches window.onerror. That catches the failure that matters most β€” a script that does not parse defines nothing at all, and the badge simply never updates.

For behaviour, call the endpoint directly with a real key and check the payload keys in Β§4. The extension itself adds only polling and rendering on top of that.


Related pages

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally