This is the reviewer’s side. The site’s side and the worker’s are in install.md; which mode you want is in modes.md.
Both extension modes start the same way — install it, switch an origin on — and diverge on one question: does that site embed the widget itself? If it does, that is team mode and you will pair with the worker. If it does not, that is private mode and the extension mounts the widget for you.
The extension is not published to a store yet. Build it from this repository and load it unpacked:
pnpm install
pnpm --filter @fruitback/extension build # or build:firefox
That writes apps/extension/.output/chrome-mv3/. In Chromium, open chrome://extensions, turn on
Developer mode, choose Load unpacked and pick that directory. pnpm --filter
@fruitback/extension dev does the same with a reload on every change, which is what you want if you
are working on the extension rather than using it.
It asks for no host permission at install, and that is deliberate: it can read nothing anywhere until you switch a site on, and it asks then, for that origin. Chromium will say the extension can read and change data on the site you name — that is the grant, and it is what lets a content script run there.
Open the page you want to review and click the extension. The popup names the origin it is about, and asks for three things:
| Field | ||
|---|---|---|
| Mode | Private · the extension mounts the widget |
for a site that embeds nothing |
Team · the site embeds it, we relay |
for a site that ships a dormant widget | |
| Worker endpoint | https://feedback.acme.dev |
your worker, not the site |
| Client id | acme |
private mode only — in team mode the site declares its own |
Then Turn on for this site. The browser asks for permission on that origin; refuse it and nothing
runs. The row afterwards reads On · acme in private mode, and in team mode
On · team mode · the site's own widget — where the client id would be, because there the site
declares its own. Either row carries Turn off here and Change.
A grant reaches the next page load, so the popup injects into the tab you have open. Come back to the page and the widget is there.
If a private-mode site shows no note, open the popup. When the worker reads authenticated, the
popup says This worker answers a signed-in reader only, and private mode carries no session. Notes do not show on this site.,
with a link to the three modes. Private mode carries no session, so the fix is on the
worker’s side: read public for that client, or use team mode. The popup says nothing when the
worker is down or slow, because it did not measure why.
A team-mode endpoint must be https, or loopback for the dev loop — a session is a bearer
credential and it does not cross plain http, so the popup refuses the entry rather than storing one
that would be shown as On and refuse every call. Private mode accepts plain http://, and
that is deliberate: the widget it mounts carries no credential, so there is nothing on the wire to
protect, and an http:// staging site is exactly the thing this mode is for.
Private mode needs nothing more — the widget is mounted, and it reads as an anonymous visitor would. The popup offers no pairing there, because a session would change nothing. If you already hold a session with that worker, the popup still shows it, with Log out. Team mode relays every call through your session, so until you pair the site can reach nothing:
Not paired — this site cannot reach the worker until you do
The worker has to be holding sessions at all, which is not the default: it needs
FRUITBACK_SESSION_PATH — its own SQLite file, whatever FRUITBACK_STORE says — and
FRUITBACK_IDENTITY_SECRET, which signs the access token. A worker with neither mints no codes, and
the command below fails. That is the operator’s side, in
self-hosting.md, which lists the three /session/ routes that exist
only when it is set.
A pairing code is minted by an operator, on the container, never over HTTP:
docker compose exec worker node server.mjs pair --subject alex@acme.dev --name "Alex" \
--endpoint https://feedback.acme.dev
The name in that command is what your notes will be signed with. The worker vouches for it because an operator typed it; the browser never asserts its own identity, which is the whole reason the code comes from a person rather than a form.
With --endpoint, the address the worker answers at, the command prints a link as well as the
code: https://feedback.acme.dev/pair#ABCD-EFGH-JKMN. That is what the operator sends you.
This page is a pairing link for https://feedback.acme.dev. — read the address: it is the
worker you are about to trust with your notes.Paired as Alex, with Log out beside
it. Go back to the site: it reaches the worker now.The code is after the #, and a browser sends nothing after a # to a server, so the worker’s logs
do not hold it. It is still in your browser’s history, and it is spent the moment you pair.
If you were given a code and no link, open the popup on the site, press I have a code, and paste it into the field above Pair with this worker.
On a worker that is not on https, there is nothing to paste into: the button is disabled before you
get that far, under
Pairing needs https (localhost excepted): a session must not cross http.
with Change the worker beside it, which opens the fields of the rule.
Otherwise, what a failed attempt answers. Each message has the one thing to do about it beside it:
| The popup says | Why | Beside it |
|---|---|---|
That code has been used or has expired. Ask for a new one. |
Codes are single-use, and expire 15 minutes after they are minted. | How to get a code, a link to this section |
The worker did not answer. Try again. |
The worker is down or unreachable — the code is still good. | Try again, with the same code |
Fruitback needs permission to reach that worker. |
The prompt for the worker’s origin was refused. It is not the site’s. | Grant access, which asks again |
That worker is on plain http. A session must not cross it. |
The same rule as the disabled button above, answered by session.ts rather than by the popup. You reach it only if something else asks for a pairing — the popup refuses first. |
Change the worker |
Pairing asks for a permission on the worker’s origin, which is a different grant from the site’s. It is asked for at the click, so nothing is awaited before the prompt — a browser drops the gesture otherwise and no prompt ever appears.
Two more messages can show on the popup and on the options page, for a site and not for a pairing:
| The page says | Why | Beside it |
|---|---|---|
Fruitback needs access to those sites to run there. Nothing was saved. |
You refused the browser’s prompt for the site. | Grant access, which asks again |
Fruitback could not confirm that change. Check the list, then try again. |
The change went to the extension and no answer came back. It can be stored anyway. | Check the list: the options page, read again from storage |
A message with no button is one the page cannot act on: a field to correct, which is beside the message, or a rules file that only another file replaces.
The popup switches the site you are on, and names the rule that covers it. On a site that no rule covers, the
popup says No rule covers this origin, so Fruitback does nothing here. All sites and rules opens the
options page, which lists every rule and adds new ones.
A rule names the sites it covers:
| Sites | covers |
|---|---|
https://acme.dev |
that origin only |
https://*.staging.acme.dev |
staging.acme.dev and every subdomain of it, on the default port |
The access the browser grants for a wildcard covers every port of those hosts; the widget mounts only
on the default port, and unmounts on any other. A host with no scheme is read as https://. A wildcard needs a domain with a dot and no IP address, so
*.localhost is refused: add each local origin on its own. When two rules cover a site, the rule for the exact origin
wins, then the longest wildcard. So one preview can go to another client, or be switched off, under a
rule for all of them. A site that no rule covers mounts nothing: there is no default client.
Add rule asks the browser for access to every site the pattern covers, and stores nothing if you
refuse. On a site a wildcard covers, the popup’s switch reads Turn off for every site this rule covers,
because that is what it does.
A team-mode rule names one origin, never a wildcard. The relay spends your session for the page a
team rule covers, and a wildcard such as https://*.vercel.app covers sites that are not yours. The
popup, the options page and an imported file all refuse a wildcard in team mode. A wildcard stays
valid in private mode, which carries no session. A team wildcard that an earlier version stored runs
nowhere now: the options page says so on its row, and offers to remove it.
A wildcard is a convenience in your browser, and grants nothing on the worker. The worker compares
each page’s exact origin with the origins of its client, so pr-12.staging.acme.dev has to be listed
there too, or its notes are refused. That check applies in private mode, where the widget calls from the
page; in team mode the call comes from the extension, which the worker does not hold to origins.
Export rules downloads fruitback-sites.json, and Import a rules file reads one. A rule in the
file replaces the rule with the same pattern, and the other rules stay. An entry that does not parse is
skipped, and the page names it.
The file holds patterns, modes, endpoints and client ids. It holds no session and no access, so an
imported rule reads No access in this browser until you press Grant access, and a team-mode worker
still needs you to pair. The rules stay in the browser that holds them: they do not sync to your other
browsers yet (FRU-72).
Log out revokes the session on the worker first, then clears both tokens here. The refresh token is dead and the chain with it, so nothing can be renewed. The access token already minted is not reachable by a revoke — nothing checks a list when one is presented — and it stops working on its own. That window is the token’s 11 minutes: its 10 minutes of life, plus the 60 seconds of clock skew the verifier allows past the moment it expires. Raised in review, which counted the lifetime and found the document counting one minute less than the code.
If the worker cannot be reached, the tokens are cleared here anyway. A screen saying signed out over a credential this extension still holds would be the worse of the two, and the token on the worker expires within 30 days regardless.
The full list, and what each refusal is for, is in SECURITY.md.