Skip to content

Commit 7cb6d85

Browse files
committed
feat: add optional authentication with a personal access token
When given, the token is sent as an `Authorization` header on every request, raising the rate limit and granting access to private repo the token can see. The forge adapter interface has new optional fields: `tokenStorageKey` and `authHeader`. A dedicated options page renders one token field per auth-capable forge, storing tokens in `storage.local` on the device only. Ref #5
1 parent 311983d commit 7cb6d85

13 files changed

Lines changed: 399 additions & 12 deletions

‎DOCUMENTATION.md‎

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ request question: instead of *"which files does this PR change?"*, it tells you
1010
- [How it works](#how-it-works)
1111
- [Project layout](#project-layout)
1212
- [Usage](#usage)
13+
- [Authentication](#authentication)
1314
- [Forges](#forges)
1415
- [Internationalization](#internationalization)
1516
- [Install](#install)
@@ -36,8 +37,9 @@ Runs on every tab update:
3637

3738
1. **Parse the URL**: extract `projectKey`, `repoSlug` and `filepath`
3839
2. **Detect the forge** by hostname
39-
3. **Promise chain**: list PRs, get files, keep PRs including the file
40-
4. **Render**: set the toolbar badge text and stores `{ tabId, prs }` in
40+
3. **Load the auth headers** for the forge from the stored token (if any)
41+
4. **Promise chain**: list PRs, get files, keep PRs including the file
42+
5. **Render**: set the toolbar badge text and stores `{ tabId, prs }` in
4143
`browser.storage.local`.
4244

4345
### Popup
@@ -60,7 +62,12 @@ On open, the popup:
6062
├── forges.js # per-forge API endpoint config
6163
├── popup/
6264
│ ├── popup.html # popup markup
65+
│ ├── options.css # popup page styles
6366
│ └── popup.js # popup logic (read storage, render)
67+
├── options/
68+
│ ├── options.html # options page markup
69+
│ ├── options.css # options page styles
70+
│ └── options.js # options logic (per-forge token fields)
6471
├── _locales/
6572
│ ├── template.json # empty key template for new locales
6673
│ ├── en/messages.json # English (default locale)
@@ -81,6 +88,28 @@ On open, the popup:
8188
A badge of `err` means a request failed (often the rate limit — see below);
8289
check the service-worker console for details.
8390

91+
## Authentication
92+
93+
Authentication is **optional**. Without a token, the extension calls the forge
94+
API anonymously. Configuring a token raises the API rate limit and lets the
95+
extension see private repositories the token can access.
96+
97+
To set a token, open the extension's **options page** (the *Settings* /
98+
*Preferences* / *Options* entry of the extension in the browser) and paste a
99+
forge token into its field. Tokens are saved in `browser.storage.local`, so
100+
they stay **on the device only** and are never synced or sent anywhere except
101+
as an `Authorization` header to that forge's API.
102+
103+
The options page is forge-agnostic: it renders one token field per forge that
104+
declares authentication support (see `authForges()` in `forges.js`), so a new
105+
authenticated forge needs no options-page change.
106+
107+
For GitHub, create a [personal access token][gh-token] (classic or
108+
fine-grained) — no scope is required for public repositories; grant repository
109+
read access for private ones.
110+
111+
[gh-token]: https://github.com/settings/tokens
112+
84113
## Forges
85114

86115
Forge endpoints are configured in `forges.js`, which defines one **adapter**
@@ -101,6 +130,12 @@ Here is the interface for each forge:
101130
| `prNumber` | identifier of a PR (used in URLs and displayed) |
102131
| `prWebUrl` | web (non-API) URL of a PR, for the popup links |
103132
| `rateLimit` | optional `{ url, header, remaining(data) }`; `null` if unsupported |
133+
| `tokenStorageKey` | optional `storage.local` key holding the user's token; omit for no auth |
134+
| `authHeader` | optional `(token) → headers` sent with API requests; omit for no auth |
135+
136+
A forge that omits `tokenStorageKey`/`authHeader` simply stays unauthenticated;
137+
declaring both opts it into the [Authentication](#authentication) flow and the
138+
options-page token field.
104139

105140
So, to add a new forge:
106141
- in `forges.js`, create a new object implementing this interface
@@ -119,7 +154,7 @@ language if it exists.
119154

120155
Strings are resolved two ways:
121156
- `__MSG_<key>__` placeholders
122-
- `browser.i18n.getMessage("<key>")` (in `popup.js`).
157+
- `browser.i18n.getMessage("<key>")` (in `popup.js` and `options.js`).
123158

124159
To add a locale:
125160
- copy `_locales/template.json` to `_locales/<lang>/messages.json`
@@ -156,4 +191,5 @@ See https://developer.chrome.com/docs/extensions/get-started/tutorial/hello-worl
156191
| --------------------------- | ------------------------------------------------------------ |
157192
| Badge shows `err` | A request failed — often the rate limit. Check the console. |
158193
| Badge/popup never updates | The page URL doesn't match the expected file path. |
159-
| Rate limit reached quickly | Unauthenticated API quota; the repo has many open PRs. |
194+
| Rate limit reached quickly | Low anonymous API quota — set a token (see [Authentication](#authentication)). |
195+
| Private repo shows no PRs | Set a token whose access covers that repository. |

‎README.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ When you browse a file on a supported forge, the extension:
1616
This helps you spot pending changes before editing a file and avoid conflicting
1717
with work already in review.
1818

19+
Optionally, set a personal access token in the extension's options to raise the
20+
API rate limit and list pull requests from private repositories.
21+
1922
## Supported forges
2023

2124
- GitHub

‎_locales/en/messages.json‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,5 +18,33 @@
1818
"popupErrorMessage": {
1919
"message": "Failed to get data",
2020
"description": "Error message in the popup"
21+
},
22+
"optionsTitle": {
23+
"message": "Settings",
24+
"description": "Title of the options page"
25+
},
26+
"optionsTokenLabel": {
27+
"message": "Personal access token",
28+
"description": "Label for a forge token input on the options page"
29+
},
30+
"optionsTokenHint": {
31+
"message": "Optional. A token raises the API rate limit and lets the extension see private repositories. It is stored only on this device.",
32+
"description": "Hint explaining a forge token input on the options page"
33+
},
34+
"optionsSave": {
35+
"message": "Save",
36+
"description": "Label of the save button on the options page"
37+
},
38+
"optionsClear": {
39+
"message": "Clear",
40+
"description": "Label of the clear button on the options page"
41+
},
42+
"optionsSaved": {
43+
"message": "Token saved",
44+
"description": "Status message shown after saving the token"
45+
},
46+
"optionsCleared": {
47+
"message": "Token cleared",
48+
"description": "Status message shown after clearing the token"
2149
}
2250
}

‎_locales/fr/messages.json‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,5 +18,33 @@
1818
"popupErrorMessage": {
1919
"message": "Échec lors de la récupération des données",
2020
"description": "Message d'erreur dans la popup"
21+
},
22+
"optionsTitle": {
23+
"message": "Paramètres",
24+
"description": "Titre de la page d'options"
25+
},
26+
"optionsTokenLabel": {
27+
"message": "Jeton d'accès personnel",
28+
"description": "Libellé d'un champ de jeton de forge sur la page d'options"
29+
},
30+
"optionsTokenHint": {
31+
"message": "Facultatif. Un jeton augmente la limite de l'API et permet à l'extension de voir les dépôts privés. Il est stocké uniquement sur cet appareil.",
32+
"description": "Aide expliquant un champ de jeton de forge sur la page d'options"
33+
},
34+
"optionsSave": {
35+
"message": "Enregistrer",
36+
"description": "Libellé du bouton d'enregistrement sur la page d'options"
37+
},
38+
"optionsClear": {
39+
"message": "Effacer",
40+
"description": "Libellé du bouton d'effacement sur la page d'options"
41+
},
42+
"optionsSaved": {
43+
"message": "Jeton enregistré",
44+
"description": "Message affiché après l'enregistrement du jeton"
45+
},
46+
"optionsCleared": {
47+
"message": "Jeton effacé",
48+
"description": "Message affiché après l'effacement du jeton"
2149
}
2250
}

‎_locales/template.json‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,5 +18,33 @@
1818
"popupErrorMessage": {
1919
"message": "",
2020
"description": ""
21+
},
22+
"optionsTitle": {
23+
"message": "",
24+
"description": ""
25+
},
26+
"optionsTokenLabel": {
27+
"message": "",
28+
"description": ""
29+
},
30+
"optionsTokenHint": {
31+
"message": "",
32+
"description": ""
33+
},
34+
"optionsSave": {
35+
"message": "",
36+
"description": ""
37+
},
38+
"optionsClear": {
39+
"message": "",
40+
"description": ""
41+
},
42+
"optionsSaved": {
43+
"message": "",
44+
"description": ""
45+
},
46+
"optionsCleared": {
47+
"message": "",
48+
"description": ""
2149
}
2250
}

‎background.js‎

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ import { forgeForHostname } from './forges.js'
66
//
77
let forge = new Object()
88
let urlInfo = new Object()
9+
// headers sent with every forge API request; populated from the stored token
10+
// (empty when the forge supports no auth or the user configured no token)
11+
let requestHeaders = {}
912

1013
// register the extension against the browser (skipped when this module is
1114
// imported outside a WebExtension context, e.g. by the test runner, where the
@@ -22,12 +25,37 @@ if (browser) {
2225
//
2326
// Functions
2427
//
28+
// build the auth headers for a forge from an already-fetched storage object
29+
// returns the headers object — empty when there is no token or the forge
30+
// supports no authentication
31+
export function authHeadersFor(forge, stored) {
32+
// forges without authentication need no token
33+
if (!forge.authHeader || !forge.tokenStorageKey) {
34+
return {}
35+
}
36+
37+
const token = stored[forge.tokenStorageKey]
38+
return token ? forge.authHeader(token) : {}
39+
}
40+
41+
// load the auth headers for a forge from its stored token
42+
// returns a promise resolving to the headers object
43+
function loadAuthHeaders(forge) {
44+
// forges without authentication need no token (and no storage read)
45+
if (!forge.authHeader || !forge.tokenStorageKey) {
46+
return Promise.resolve({})
47+
}
48+
49+
return browser.storage.local.get(forge.tokenStorageKey)
50+
.then(stored => authHeadersFor(forge, stored))
51+
}
52+
2553
// fetch all pull requests
2654
// returns a promise with the list of pull requests
2755
function listAllPRs() {
2856
console.log("List all pull requests")
2957

30-
const request = new Request(forge.listPRsUrl(urlInfo))
58+
const request = new Request(forge.listPRsUrl(urlInfo), { headers: requestHeaders })
3159

3260
return fetch(request).then(response => {
3361
//if (!response.ok) {
@@ -41,7 +69,7 @@ function listAllPRs() {
4169
// returns a promise with the list of modified files for a pull request
4270
function getModifiedFiles(pr) {
4371
// fetch the API
44-
const request = new Request(forge.filesUrl(urlInfo, pr))
72+
const request = new Request(forge.filesUrl(urlInfo, pr), { headers: requestHeaders })
4573

4674
return fetch(request).then(async response => {
4775
const data = await response.json()
@@ -93,7 +121,7 @@ function checkRateLimit() {
93121
console.log("Check the rate limit")
94122

95123
// fetch the rate limit endpoint
96-
return fetch(forge.rateLimit.url)
124+
return fetch(forge.rateLimit.url, { headers: requestHeaders })
97125
.then(async response => {
98126
const data = await response.json()
99127

@@ -167,8 +195,10 @@ function main(tab) {
167195
}
168196
urlInfo = info
169197

170-
// check the forge rate limit
171-
checkRateLimit()
198+
// load the stored API token, then check the forge rate limit
199+
loadAuthHeaders(forge)
200+
.then(headers => { requestHeaders = headers })
201+
.then(checkRateLimit)
172202
// list pull requests
173203
.then(listAllPRs)
174204
.then(checkRateLimitFromPRs)

‎forges.js‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
// unchanged.
55
//
66
// Adapter interface:
7-
// label human-readable forge name
7+
// label human-readable forge name, shown in the options page UI
88
// hostnames array of substrings matched against the page hostname (static
99
// forges only; self-hosted forges are matched against the user's
1010
// configured instance list instead)
@@ -16,6 +16,10 @@
1616
// prWebUrl (info, prNumber) -> web (non-API) URL of a PR, for popup links
1717
// rateLimit optional { url, header, remaining(data) }; null if the forge
1818
// exposes no rate-limit endpoint
19+
// tokenStorageKey optional storage.local key holding the user's API token;
20+
// omit if the forge supports no authentication
21+
// authHeader optional (token) -> headers object sent with API requests when
22+
// a token is configured; omit if the forge supports no auth
1923

2024
// API docs: https://docs.github.com/en/rest/pulls
2125
export const github = {
@@ -55,6 +59,15 @@ export const github = {
5559
header: "x-ratelimit-remaining",
5660
remaining: (data) => data.resources.core.remaining,
5761
},
62+
63+
// GitHub accepts classic and fine-grained personal access tokens as a
64+
// Bearer credential. Authenticating raises the rate limit and grants
65+
// access to private repositories the token can see.
66+
tokenStorageKey: "githubToken",
67+
68+
authHeader(token) {
69+
return { Authorization: `Bearer ${token}` }
70+
},
5871
}
5972

6073
// registry of forges matched by a static hostname
@@ -64,3 +77,9 @@ const staticForges = [github]
6477
export function forgeForHostname(hostname) {
6578
return staticForges.find(f => f.hostnames.some(h => hostname.includes(h))) || null
6679
}
80+
81+
// forges that support authentication (declare both a token storage key and a
82+
// header builder); the options page renders one token field per such forge.
83+
export function authForges() {
84+
return staticForges.filter(f => f.tokenStorageKey && f.authHeader)
85+
}

‎manifest.json‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@
1818
"id": "git-pr-reverse@webext.com",
1919
"strict_min_version": "140.0",
2020
"data_collection_permissions": {
21-
"required": ["none"]
21+
"required": ["none"],
22+
"optional": ["authenticationInfo"]
2223
}
2324
},
2425
"gecko_android": {
@@ -41,6 +42,10 @@
4142
"default_title": "Git PR reverse",
4243
"default_popup": "popup/popup.html"
4344
},
45+
"options_ui": {
46+
"page": "options/options.html",
47+
"open_in_tab": true
48+
},
4449

4550
"permissions": [
4651
"storage",

‎options/options.css‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
:root {
2+
--bg: #fff; --fg: #1b1b1b; --muted: #6b6b6b;
3+
--link: #0060df; --border: #e0e0e0;
4+
}
5+
@media (prefers-color-scheme: dark) {
6+
:root { --bg: #1c1b22; --fg: #fbfbfe; --muted: #b1b1bd;
7+
--link: #80b3ff; --border: #38383d; }
8+
}
9+
10+
body { background: var(--bg); color: var(--fg); }
11+
.options { max-width: 32rem; margin: 0 auto; padding: 1rem; }
12+
.options__form { display: flex; flex-direction: column; gap: 0.5rem; }
13+
.options__forge {
14+
display: flex; flex-direction: column; gap: 0.5rem;
15+
border: 1px solid var(--border); border-radius: 4px; padding: 0.75rem;
16+
}
17+
.options__forge legend { font-weight: 600; padding: 0 0.25rem; }
18+
.options__hint { color: var(--muted); margin: 0; font-size: 0.85rem; }
19+
.options__input {
20+
padding: 0.4rem; border: 1px solid var(--border); border-radius: 4px;
21+
background: var(--bg); color: var(--fg);
22+
}
23+
.options__actions { display: flex; gap: 0.5rem; margin-top: 0.5rem; }
24+
.options__status { color: var(--muted); min-height: 1.2em; }

0 commit comments

Comments
 (0)