@pooriaarab/foxlink 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/README.md +339 -2
- package/dist/calendar.d.ts +45 -0
- package/dist/calendar.js +83 -0
- package/dist/errors.d.ts +15 -0
- package/dist/errors.js +18 -0
- package/dist/gated.d.ts +20 -0
- package/dist/gated.js +20 -0
- package/dist/gmail.d.ts +68 -0
- package/dist/gmail.js +157 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +7 -0
- package/dist/link.d.ts +67 -0
- package/dist/link.js +299 -0
- package/dist/provider.d.ts +63 -0
- package/dist/provider.js +105 -0
- package/dist/text.d.ts +20 -0
- package/dist/text.js +94 -0
- package/package.json +47 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Pooria Arab
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,340 @@
|
|
|
1
|
-
#
|
|
1
|
+
# foxlink
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
<p align="center">Connect Gmail and Google Calendar to a Firefox extension with OAuth.</p>
|
|
4
|
+
|
|
5
|
+
<p align="center">
|
|
6
|
+
<a href="https://github.com/pooriaarab/foxlink/actions"><img src="https://github.com/pooriaarab/foxlink/actions/workflows/ci.yml/badge.svg" alt="CI"/></a>
|
|
7
|
+
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue" alt="License MIT"/></a>
|
|
8
|
+
</p>
|
|
9
|
+
|
|
10
|
+
foxlink runs the OAuth 2.0 authorization code flow with PKCE through
|
|
11
|
+
`identity.launchWebAuthFlow`. It works with any provider config, and it has
|
|
12
|
+
a Google preset for Gmail and Calendar. The tokens go into
|
|
13
|
+
[foxvault](https://github.com/pooriaarab/foxvault). With the `inject`
|
|
14
|
+
transport, foxvault adds the access token to each API request in Firefox.
|
|
15
|
+
Then your extension code, the page, and the AI model never get the token. Sending an email or adding an event
|
|
16
|
+
waits for a [foxgate](https://github.com/pooriaarab/foxgate) approval of
|
|
17
|
+
the exact action. Email and event text comes back marked as untrusted.
|
|
18
|
+
|
|
19
|
+
You bring your own OAuth client ID. foxlink ships none.
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npm i @pooriaarab/foxlink
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
The npm package is `@pooriaarab/foxlink`: npm refuses the plain name `foxlink` as too similar to an existing package (oxlint and comlink).
|
|
28
|
+
|
|
29
|
+
foxlink needs `foxvault` and `foxgate`. npm installs them with it.
|
|
30
|
+
|
|
31
|
+
## Example
|
|
32
|
+
|
|
33
|
+
This code runs in the background script of a Firefox extension. The
|
|
34
|
+
extension needs the permissions `identity`, `storage`, `webRequest`, and
|
|
35
|
+
`webRequestBlocking`, and host permissions for the Google hosts. The demo in
|
|
36
|
+
`extension/` makes the same calls.
|
|
37
|
+
|
|
38
|
+
```js
|
|
39
|
+
import { storageAreaStore } from "foxgate";
|
|
40
|
+
import { attachHeaderInjection, createVault, indexedDbKeyStore } from "foxvault";
|
|
41
|
+
import { calendar, createLink, gmail, googleProvider } from "@pooriaarab/foxlink";
|
|
42
|
+
|
|
43
|
+
const store = storageAreaStore(browser.storage.local);
|
|
44
|
+
const vault = createVault({ store, keyStore: indexedDbKeyStore() });
|
|
45
|
+
attachHeaderInjection(vault, browser); // at the top level of the event page
|
|
46
|
+
|
|
47
|
+
const provider = googleProvider({ clientId: "YOUR_CLIENT_ID.apps.googleusercontent.com" });
|
|
48
|
+
const link = createLink({ provider, vault, store, identity: browser.identity, transport: "inject" });
|
|
49
|
+
|
|
50
|
+
// Call this from a message handler, for example when the user selects a button.
|
|
51
|
+
async function today() {
|
|
52
|
+
if ((await vault.status()) === "new") await vault.initialize();
|
|
53
|
+
if (!(await link.status()).connected) await link.connect(); // opens the Google sign-in window
|
|
54
|
+
const { events } = await calendar(link).listEvents({ max: 3 });
|
|
55
|
+
const { messages } = await gmail(link).listMessages({ max: 5 });
|
|
56
|
+
return { events: events.map((e) => e.summary), subjects: messages.map((m) => m.subject) };
|
|
57
|
+
}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
The HTML to text function also runs in Node:
|
|
61
|
+
|
|
62
|
+
```js
|
|
63
|
+
import { htmlToText } from "@pooriaarab/foxlink";
|
|
64
|
+
|
|
65
|
+
console.log(htmlToText('<p>Hi & welcome</p><img src="https://t.example/p.gif"><script>steal()</script>'));
|
|
66
|
+
// Hi & welcome
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Use cases
|
|
70
|
+
|
|
71
|
+
| Who | What they build | How foxlink helps |
|
|
72
|
+
|---|---|---|
|
|
73
|
+
| A browser agent author (for example foxmate) | An agent that reads the user's inbox and calendar and drafts replies | The agent gets message text marked `untrusted`, and never a token. A send waits for the user to approve the exact `to`, `subject`, and `body`. |
|
|
74
|
+
| An extension author | A "what is next today" popup or new tab page | `listEvents({ max: 3 })` with the read-only Calendar scope, and refresh on expiry with no code of your own. |
|
|
75
|
+
| A privacy tool author | An inbox cleaner that runs on the user's device only | The mail goes from Google to the extension and nowhere else. `htmlToText` drops scripts and tracking pixels, so opening a message loads nothing. |
|
|
76
|
+
| An author of another OAuth integration | A link to GitHub, Notion, or any provider that issues codes with PKCE | `defineProvider` takes the endpoints, scopes, and API hosts. `connect`, `fetch`, refresh, and `disconnect` work the same. |
|
|
77
|
+
| An MCP server or agent framework author | An email or calendar tool for a model | `gmail` and `calendar` return small typed records. `toPromptText` fences each record so that its text cannot close the fence. |
|
|
78
|
+
| A security reviewer | A check that an extension asks only for what it needs | The Google preset asks for read-only scopes. `connect` refuses scopes outside the config and scopes that the server adds. |
|
|
79
|
+
|
|
80
|
+
## How it works
|
|
81
|
+
|
|
82
|
+
```mermaid
|
|
83
|
+
sequenceDiagram
|
|
84
|
+
participant App as Your background script
|
|
85
|
+
participant L as foxlink
|
|
86
|
+
participant F as Firefox identity
|
|
87
|
+
participant G as Google
|
|
88
|
+
participant V as foxvault
|
|
89
|
+
App->>L: connect()
|
|
90
|
+
L->>L: make verifier, S256 challenge, state, and nonce (openid only)
|
|
91
|
+
L->>F: launchWebAuthFlow(authorize URL with challenge and state)
|
|
92
|
+
F->>G: sign-in and consent window
|
|
93
|
+
G-->>F: redirect to http://127.0.0.1/mozoauth2/<id>?code&state
|
|
94
|
+
F-->>L: redirect URL (Firefox does not load it)
|
|
95
|
+
L->>L: check the redirect URI and the state
|
|
96
|
+
L->>G: POST /token with code, redirect_uri, code_verifier
|
|
97
|
+
G-->>L: access token, refresh token, granted scopes
|
|
98
|
+
L->>L: refuse scopes that it did not ask for (revoke)
|
|
99
|
+
L->>V: store both tokens encrypted, add a header rule for the API hosts
|
|
100
|
+
L-->>App: status: connected, scopes, expiry (no token)
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
1. `connect` makes a 43-character PKCE verifier and sends its SHA-256
|
|
104
|
+
challenge. It sends a random `state`, and a `nonce` when the scopes hold
|
|
105
|
+
`openid`.
|
|
106
|
+
2. The redirect URL must have the same origin and path as the redirect URI,
|
|
107
|
+
and the same `state`. Else foxlink reads no code from it.
|
|
108
|
+
3. When the server grants a scope that foxlink did not ask for, foxlink
|
|
109
|
+
revokes the token and stores nothing.
|
|
110
|
+
4. The access token and the refresh token are foxvault secrets. The store
|
|
111
|
+
holds only the scopes, the issue and expiry times, and whether a refresh
|
|
112
|
+
token exists.
|
|
113
|
+
5. `link.fetch` sends requests to the API hosts only. It refreshes the
|
|
114
|
+
access token 60 seconds before expiry (or at half its life, when that is
|
|
115
|
+
sooner) and one time after a 401. Calls at the same time share one
|
|
116
|
+
refresh request. A refresh that a `connect` or a `disconnect` overtook
|
|
117
|
+
throws its token away.
|
|
118
|
+
6. When Google refuses the refresh token (`invalid_grant`), foxlink forgets
|
|
119
|
+
the tokens and throws `reconnect`.
|
|
120
|
+
|
|
121
|
+
```mermaid
|
|
122
|
+
sequenceDiagram
|
|
123
|
+
participant P as AI planner
|
|
124
|
+
participant M as gmail(link, { gate })
|
|
125
|
+
participant G as foxgate
|
|
126
|
+
participant U as Human
|
|
127
|
+
participant API as Gmail API
|
|
128
|
+
P->>M: sendMessage({ to, subject, body })
|
|
129
|
+
M->>M: check input (no CR or LF) and the send scope
|
|
130
|
+
M->>G: check(foxlink.gmail.send, scope submit)
|
|
131
|
+
G-->>M: ask, requestId
|
|
132
|
+
M-->>P: { status: "ask", requestId }
|
|
133
|
+
U->>G: approve the exact JSON (host side)
|
|
134
|
+
G-->>P: token
|
|
135
|
+
P->>M: sendMessage(same message, { token })
|
|
136
|
+
M->>G: redeem(token, action)
|
|
137
|
+
G-->>M: allow, the judged args
|
|
138
|
+
M->>API: POST messages/send (foxvault adds the token)
|
|
139
|
+
M-->>P: { status: "sent", id }
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
A changed message gets `action-changed`, and a used token gets
|
|
143
|
+
`token-used`. Every failure mode has a test: see
|
|
144
|
+
[docs/failure-modes.md](docs/failure-modes.md).
|
|
145
|
+
|
|
146
|
+
## API
|
|
147
|
+
|
|
148
|
+
This package is a library only. It has no CLI and no MCP server. The tokens
|
|
149
|
+
must stay in one extension, behind foxvault. A CLI or an MCP server would
|
|
150
|
+
have to hold the tokens in another process.
|
|
151
|
+
|
|
152
|
+
### Providers
|
|
153
|
+
|
|
154
|
+
| Export | What it does |
|
|
155
|
+
|---|---|
|
|
156
|
+
| `googleProvider({ clientId, clientSecret?, scopes?, allowedScopes?, endpoints?, allowHttp? })` | The Google preset. Default scopes: `gmail.readonly` and `calendar.readonly`. `allowedScopes` is the most that `connect` may ask for. It uses the loopback redirect and asks for offline access. |
|
|
157
|
+
| `defineProvider(config)` | Any provider. `config` has `id`, `clientId`, `authorizeUrl`, `tokenUrl`, `revokeUrl?`, `scopes`, `allowedScopes?`, `apiHosts`, `authParams?`, `redirect?` (`"extension"` or `"loopback"`), `issuers?`, and `allowHttp?`. |
|
|
158
|
+
| `GOOGLE_SCOPES` | `gmailRead`, `gmailSend`, `calendarRead`, and `calendarEvents`. |
|
|
159
|
+
| `GOOGLE_ENDPOINTS` | The Google authorize, token, revoke, Gmail, and Calendar URLs. |
|
|
160
|
+
| `loopbackRedirectUrl(url)` | `http://127.0.0.1/mozoauth2/<subdomain>` from the value of `identity.getRedirectURL()`. |
|
|
161
|
+
|
|
162
|
+
`allowHttp` is for test servers only. Without it, every endpoint must use
|
|
163
|
+
`https:`.
|
|
164
|
+
|
|
165
|
+
### `createLink(options)`
|
|
166
|
+
|
|
167
|
+
| Option | Default | What it does |
|
|
168
|
+
|---|---|---|
|
|
169
|
+
| `provider` | required | From `googleProvider` or `defineProvider`. |
|
|
170
|
+
| `vault` | required | A foxvault vault. |
|
|
171
|
+
| `store` | `memoryStore()` | Where the token record lives. In Firefox, `storageAreaStore(browser.storage.local)`. |
|
|
172
|
+
| `identity` | none | `browser.identity`. foxlink uses it for the redirect URI and the sign-in window. |
|
|
173
|
+
| `redirectUri` | from `identity` | Set it to use another redirect URI. |
|
|
174
|
+
| `launch` | `identity.launchWebAuthFlow` | Opens the authorize URL and returns the redirect URL. |
|
|
175
|
+
| `transport` | `"use"` | `"inject"`: foxvault adds the header in Firefox, with `attachHeaderInjection`. `"use"`: foxlink adds it in your host code, for example in Node. |
|
|
176
|
+
| `fetch` | global `fetch` | The fetch function. |
|
|
177
|
+
| `now` | `Date.now` | The clock. |
|
|
178
|
+
| `skewMs` | 60,000 | Refresh this long before the access token expires. foxlink uses half of the token life when that is shorter. |
|
|
179
|
+
|
|
180
|
+
| Method | What it does |
|
|
181
|
+
|---|---|
|
|
182
|
+
| `connect({ scopes?, interactive? })` | Runs the sign-in. Returns the status. |
|
|
183
|
+
| `status()` | `{ connected, scopes, expiresAt?, refreshable? }`. It holds no token. |
|
|
184
|
+
| `hasScope(scope)` | True when the user granted the scope. |
|
|
185
|
+
| `fetch(url, init?)` | `fetch` for the API hosts, with the access token. |
|
|
186
|
+
| `disconnect()` | Revokes the grant and forgets the tokens. Returns `{ revoked }`. It forgets them also when the revoke fails. |
|
|
187
|
+
| `redirectUri()` | The redirect URI to register with the provider. |
|
|
188
|
+
|
|
189
|
+
### Gmail and Calendar
|
|
190
|
+
|
|
191
|
+
| Method | What it does |
|
|
192
|
+
|---|---|
|
|
193
|
+
| `gmail(link, { gate? }).listMessages({ query?, max?, pageToken? })` | The newest messages with From, To, Subject, Date, and the snippet. `max` is 1 to 50 (default 10). Returns `{ messages, nextPageToken? }`. |
|
|
194
|
+
| `gmail(link).getMessage(id)` | One message with `text`: the `text/plain` part, or the HTML part as plain text. |
|
|
195
|
+
| `gmail(link, { gate }).sendMessage({ to, subject, body }, { token? })` | A plain text email, after a foxgate approval. A subject with non-ASCII characters or `=?` goes out as RFC 2047 encoded words, so the recipient sees the approved text. Returns `{ status: "sent", id }`, `{ status: "ask", requestId }`, or `{ status: "refused", reason }`. |
|
|
196
|
+
| `calendar(link).listEvents({ timeMin?, timeMax?, max? })` | The next events from `timeMin` (default: now), by start time. Returns `{ events }`. |
|
|
197
|
+
| `calendar(link, { gate }).createEvent({ summary, start, end, description?, location? }, { token? })` | Adds an event, after a foxgate approval. Returns `created`, `ask`, or `refused`. |
|
|
198
|
+
| `FOXLINK_TOOLS` | `{ "foxlink.gmail.send": "submit", "foxlink.calendar.create": "submit" }`. Pass it to `createFoxgate({ tools })`, and add a `submit` grant for the API hosts. |
|
|
199
|
+
|
|
200
|
+
Refused reasons: `bad-input`, `missing-scope`, `no-gate`, and every foxgate
|
|
201
|
+
deny reason, for example `action-changed`, `token-used`, or `rejected`.
|
|
202
|
+
|
|
203
|
+
### Text
|
|
204
|
+
|
|
205
|
+
| Export | What it does |
|
|
206
|
+
|---|---|
|
|
207
|
+
| `htmlToText(html)` | Plain text with no DOM and no network, in one pass. Scripts, styles, comments, and image URLs go away. Only a real close tag ends a script or style block. |
|
|
208
|
+
| `toPromptText(record)` | The record inside `<untrusted source="…" id="…">` tags. Its text cannot close the tag. |
|
|
209
|
+
| `decodeEntities(text)` | Decodes HTML entities one time. |
|
|
210
|
+
| `messageText(payload)` | The text of a Gmail message payload. |
|
|
211
|
+
|
|
212
|
+
Every message and event has `trust: "untrusted"` and a `source`. A tool such
|
|
213
|
+
as foxshield can then check the text before a model reads it.
|
|
214
|
+
|
|
215
|
+
### Errors
|
|
216
|
+
|
|
217
|
+
`FoxlinkError` has a `code`: `bad-config`, `bad-scope`, `busy`,
|
|
218
|
+
`authorize-failed`, `redirect-mismatch`, `state-mismatch`, `access-denied`,
|
|
219
|
+
`token-error`, `scope-creep`, `id-token`, `not-connected`, `reconnect`,
|
|
220
|
+
`unauthorized`, `bad-host`, `http-error`, `missing-scope`, or `bad-input`. It
|
|
221
|
+
can have `status` (HTTP) and `oauthError` (the server error code, cut to
|
|
222
|
+
`a-z 0-9 _`). No message holds a token, a code, or server text. Vault errors,
|
|
223
|
+
for example `locked`, come through as foxvault `VaultError`.
|
|
224
|
+
|
|
225
|
+
## Demo extension
|
|
226
|
+
|
|
227
|
+
`extension/` is a demo for Firefox 153+. Type your OAuth client ID in
|
|
228
|
+
Settings. Then use "Connect Google", "Show my next 3 events", "Show 5 latest
|
|
229
|
+
email subjects", and "Read the latest email". With "Ask for the Gmail send
|
|
230
|
+
scope" on, you can send an email after you approve the exact message.
|
|
231
|
+
|
|
232
|
+
```bash
|
|
233
|
+
pnpm install
|
|
234
|
+
pnpm build:ext # builds dist-ext/; load it from about:debugging
|
|
235
|
+
pnpm e2e # runs the demo in Firefox against the local fake Google
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
### Use your own Google client ID
|
|
239
|
+
|
|
240
|
+
We have not tested foxlink with a real Google account. We tested it against
|
|
241
|
+
the fake Google server in `e2e/fake-google.mjs`, which copies the Google
|
|
242
|
+
endpoints and REST shapes. These are the steps to try the real Google:
|
|
243
|
+
|
|
244
|
+
1. In the [Google Cloud console](https://console.cloud.google.com/), create a
|
|
245
|
+
project or select one.
|
|
246
|
+
2. In **APIs & Services > Library**, enable the **Gmail API** and the
|
|
247
|
+
**Google Calendar API**.
|
|
248
|
+
3. Open **Google Auth Platform** (the OAuth consent screen). Set the
|
|
249
|
+
audience to **External**. Add your own Google account as a test user.
|
|
250
|
+
4. In **Data Access**, add the scopes `gmail.readonly` and
|
|
251
|
+
`calendar.readonly`. Add `gmail.send` only if you want to send.
|
|
252
|
+
5. In **Clients**, create an OAuth client with the application type
|
|
253
|
+
**Desktop app**. Google accepts loopback redirect URIs for this type.
|
|
254
|
+
Copy the client ID and the client secret.
|
|
255
|
+
6. In the demo Settings, paste the client ID and the client secret. Leave
|
|
256
|
+
the test server port empty. Select **Save**, then **Connect Google**.
|
|
257
|
+
|
|
258
|
+
If Google refuses the redirect URI, make a **Web application** client
|
|
259
|
+
instead. Add the redirect URI that the demo Settings show under
|
|
260
|
+
**Authorized redirect URIs**. Google treats the client secret of an
|
|
261
|
+
installed app as not secret. foxlink sends it to the token endpoint only.
|
|
262
|
+
|
|
263
|
+
While the app is in the **Testing** state, Google makes refresh tokens that
|
|
264
|
+
expire in 7 days. Then the demo asks you to connect again. `gmail.readonly`
|
|
265
|
+
is a restricted scope. A public app with it needs a Google review.
|
|
266
|
+
|
|
267
|
+
## Firefox APIs used
|
|
268
|
+
|
|
269
|
+
| API | MDN | Why |
|
|
270
|
+
|---|---|---|
|
|
271
|
+
| `identity.launchWebAuthFlow` (permission `identity`) | [launchWebAuthFlow](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/identity/launchWebAuthFlow) | Open the sign-in window and get the redirect URL with the code. |
|
|
272
|
+
| `identity.getRedirectURL` | [getRedirectURL](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/identity/getRedirectURL) | The stable redirect URL from the add-on ID. The Google preset turns it into the loopback form. |
|
|
273
|
+
| Loopback redirect `http://127.0.0.1/mozoauth2/<subdomain>` (Firefox 86+) | [identity](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/identity) | Google refuses the `extensions.allizom.org` domain, because nobody can verify it. |
|
|
274
|
+
| Web Crypto `subtle.digest` and `crypto.getRandomValues` | [digest](https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/digest) | The PKCE verifier and challenge, the state, and the nonce. |
|
|
275
|
+
| `fetch` | [fetch](https://developer.mozilla.org/en-US/docs/Web/API/Window/fetch) | The token, revoke, Gmail, and Calendar requests. |
|
|
276
|
+
| `webRequest.onBeforeSendHeaders` with `blocking`, through foxvault | [onBeforeSendHeaders](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/webRequest/onBeforeSendHeaders) | foxvault adds the access token to requests from the extension to the API hosts. |
|
|
277
|
+
| IndexedDB, through foxvault | [IndexedDB API](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API) | The non-extractable vault key. |
|
|
278
|
+
| `storage.local` | [storage.local](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/storage/local) | Encrypted tokens, the token record, foxgate state, and demo settings. |
|
|
279
|
+
| `runtime.sendMessage`, `runtime.onMessage` | [runtime](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/runtime) | The popup talks to the background page. Demo only. |
|
|
280
|
+
|
|
281
|
+
## Limits
|
|
282
|
+
|
|
283
|
+
- We have not tested the real Google path. The tests use a fake Google
|
|
284
|
+
server. We do not know yet if Google accepts the redirect URI
|
|
285
|
+
`http://127.0.0.1/mozoauth2/<id>` with no port for a Desktop app client.
|
|
286
|
+
- foxlink does not check the ID token signature. It checks `nonce`, `aud`,
|
|
287
|
+
and `iss` of an ID token that came straight from the token endpoint over
|
|
288
|
+
TLS, as OpenID Connect Core 3.1.3.7 allows.
|
|
289
|
+
- `htmlToText` does not remove hidden text, for example text with
|
|
290
|
+
`display: none`. Check the text with a tool such as foxshield.
|
|
291
|
+
- `getMessage` decodes every body as UTF-8. A body in another character
|
|
292
|
+
set can show wrong characters.
|
|
293
|
+
- `connect` compares the granted scopes with the asked scopes as text. Use
|
|
294
|
+
full scope URLs. A short name such as `email` comes back from Google as
|
|
295
|
+
`https://www.googleapis.com/auth/userinfo.email`, and foxlink refuses it
|
|
296
|
+
as `scope-creep`.
|
|
297
|
+
- The Google preset sends the access token to every request from the
|
|
298
|
+
extension to `gmail.googleapis.com` and `www.googleapis.com`. The token
|
|
299
|
+
scopes limit what those requests can do.
|
|
300
|
+
- `connect` does not revoke the tokens of an earlier connect. It replaces
|
|
301
|
+
them in the vault.
|
|
302
|
+
- `sendMessage` takes bare addresses only, for example `ana@example.com`,
|
|
303
|
+
not `Ana <ana@example.com>`.
|
|
304
|
+
- `sendMessage` sends plain text only. It has no HTML, no attachments, no
|
|
305
|
+
CC or BCC, and no reply threading.
|
|
306
|
+
- `listMessages` gets at most 50 messages for each call. It does not page
|
|
307
|
+
through the whole mailbox by itself.
|
|
308
|
+
- `listEvents` reads one calendar (`primary` by default).
|
|
309
|
+
- One link object for each provider, in one background page. Two link
|
|
310
|
+
objects on the same store do not share a refresh, so they can send two
|
|
311
|
+
refresh requests at the same time.
|
|
312
|
+
- In passphrase mode, a locked vault stops every call with `locked`.
|
|
313
|
+
- The demo has a host permission for `http://*.localhost/*`, for the E2E
|
|
314
|
+
test server.
|
|
315
|
+
|
|
316
|
+
## Part of the fox primitives
|
|
317
|
+
|
|
318
|
+
```mermaid
|
|
319
|
+
flowchart LR
|
|
320
|
+
foxkit[foxkit] -- template --> foxlink[foxlink]
|
|
321
|
+
foxvault[foxvault] --> foxlink
|
|
322
|
+
foxgate[foxgate] --> foxlink
|
|
323
|
+
foxgate --> foxvault
|
|
324
|
+
foxlink --> foxmate[foxmate]
|
|
325
|
+
foxlink -. untrusted text .-> foxshield[foxshield]
|
|
326
|
+
click foxkit "https://github.com/pooriaarab/foxkit"
|
|
327
|
+
click foxvault "https://github.com/pooriaarab/foxvault"
|
|
328
|
+
click foxgate "https://github.com/pooriaarab/foxgate"
|
|
329
|
+
click foxlink "https://github.com/pooriaarab/foxlink"
|
|
330
|
+
click foxmate "https://github.com/pooriaarab/foxmate"
|
|
331
|
+
click foxshield "https://github.com/pooriaarab/foxshield"
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
foxlink depends on foxvault for the tokens and on foxgate for approvals.
|
|
335
|
+
foxshield does not depend on foxlink. An app can pass foxlink text to
|
|
336
|
+
foxshield before a model reads it.
|
|
337
|
+
|
|
338
|
+
## License
|
|
339
|
+
|
|
340
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { Gate } from "foxgate";
|
|
2
|
+
import { type Asked, type Refused } from "./gated.js";
|
|
3
|
+
import type { Link } from "./link.js";
|
|
4
|
+
import { type Untrusted } from "./text.js";
|
|
5
|
+
export interface CalendarEvent extends Untrusted {
|
|
6
|
+
source: "calendar";
|
|
7
|
+
summary: string;
|
|
8
|
+
/** An RFC 3339 time, or a date for an all-day event. */
|
|
9
|
+
start: string;
|
|
10
|
+
end: string;
|
|
11
|
+
location: string;
|
|
12
|
+
/** Plain text. HTML is removed. */
|
|
13
|
+
description: string;
|
|
14
|
+
}
|
|
15
|
+
export interface NewEvent {
|
|
16
|
+
summary: string;
|
|
17
|
+
/** RFC 3339 times, for example `2026-10-12T09:00:00Z`. */
|
|
18
|
+
start: string;
|
|
19
|
+
end: string;
|
|
20
|
+
description?: string;
|
|
21
|
+
location?: string;
|
|
22
|
+
}
|
|
23
|
+
export declare function calendar(link: Link, options?: {
|
|
24
|
+
baseUrl?: string;
|
|
25
|
+
gate?: Gate;
|
|
26
|
+
calendarId?: string;
|
|
27
|
+
}): Readonly<{
|
|
28
|
+
/** The next events from `timeMin` (default: now), by start time. `max` is 1 to 50 (default 10). */
|
|
29
|
+
listEvents(request?: {
|
|
30
|
+
timeMin?: string | Date;
|
|
31
|
+
timeMax?: string | Date;
|
|
32
|
+
max?: number;
|
|
33
|
+
}): Promise<{
|
|
34
|
+
events: CalendarEvent[];
|
|
35
|
+
}>;
|
|
36
|
+
/** Add an event, after a foxgate decision with scope `submit`. The first call returns `ask`. */
|
|
37
|
+
createEvent(event: NewEvent, approval?: {
|
|
38
|
+
token?: string;
|
|
39
|
+
}): Promise<{
|
|
40
|
+
status: "created";
|
|
41
|
+
id: string;
|
|
42
|
+
htmlLink: string;
|
|
43
|
+
} | Asked | Refused>;
|
|
44
|
+
}>;
|
|
45
|
+
export type Calendar = ReturnType<typeof calendar>;
|
package/dist/calendar.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { FoxlinkError } from "./errors.js";
|
|
2
|
+
import { FOXLINK_TOOLS, gated, hasControl } from "./gated.js";
|
|
3
|
+
import { GOOGLE_ENDPOINTS, GOOGLE_SCOPES } from "./provider.js";
|
|
4
|
+
import { htmlToText } from "./text.js";
|
|
5
|
+
const READ_SCOPES = [GOOGLE_SCOPES.calendarRead, GOOGLE_SCOPES.calendarEvents, "https://www.googleapis.com/auth/calendar"];
|
|
6
|
+
const WRITE_SCOPES = [GOOGLE_SCOPES.calendarEvents, "https://www.googleapis.com/auth/calendar"];
|
|
7
|
+
const RFC3339 = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2})$/;
|
|
8
|
+
const toEvent = (e) => ({
|
|
9
|
+
trust: "untrusted",
|
|
10
|
+
source: "calendar",
|
|
11
|
+
id: e.id,
|
|
12
|
+
summary: e.summary ?? "",
|
|
13
|
+
start: e.start?.dateTime ?? e.start?.date ?? "",
|
|
14
|
+
end: e.end?.dateTime ?? e.end?.date ?? "",
|
|
15
|
+
location: e.location ?? "",
|
|
16
|
+
description: htmlToText(e.description ?? ""),
|
|
17
|
+
});
|
|
18
|
+
function validEvent(e) {
|
|
19
|
+
if (!e || typeof e.summary !== "string" || !e.summary.trim() || e.summary.length > 500 || hasControl(e.summary))
|
|
20
|
+
return false;
|
|
21
|
+
if (typeof e.start !== "string" || typeof e.end !== "string" || !RFC3339.test(e.start) || !RFC3339.test(e.end))
|
|
22
|
+
return false;
|
|
23
|
+
if (!(Date.parse(e.end) > Date.parse(e.start)))
|
|
24
|
+
return false;
|
|
25
|
+
return [e.description, e.location].every((v) => v === undefined || (typeof v === "string" && v.length <= 8000));
|
|
26
|
+
}
|
|
27
|
+
const clampMax = (value) => (typeof value === "number" && Number.isFinite(value) ? Math.min(Math.max(Math.trunc(value), 1), 50) : 10);
|
|
28
|
+
const iso = (value) => (value instanceof Date ? value : new Date(value)).toISOString();
|
|
29
|
+
export function calendar(link, options = {}) {
|
|
30
|
+
const base = options.baseUrl ?? link.provider.endpoints?.calendarBase ?? GOOGLE_ENDPOINTS.calendarBase;
|
|
31
|
+
const events = `${base}/calendars/${encodeURIComponent(options.calendarId ?? "primary")}/events`;
|
|
32
|
+
const hasAny = async (scopes) => {
|
|
33
|
+
for (const s of scopes)
|
|
34
|
+
if (await link.hasScope(s))
|
|
35
|
+
return true;
|
|
36
|
+
return false;
|
|
37
|
+
};
|
|
38
|
+
return Object.freeze({
|
|
39
|
+
/** The next events from `timeMin` (default: now), by start time. `max` is 1 to 50 (default 10). */
|
|
40
|
+
async listEvents(request = {}) {
|
|
41
|
+
if (!(await hasAny(READ_SCOPES)))
|
|
42
|
+
throw new FoxlinkError("missing-scope", `This call needs the scope ${GOOGLE_SCOPES.calendarRead}. Connect again and grant it.`);
|
|
43
|
+
const url = new URL(events);
|
|
44
|
+
try {
|
|
45
|
+
url.searchParams.set("timeMin", iso(request.timeMin ?? new Date()));
|
|
46
|
+
if (request.timeMax !== undefined)
|
|
47
|
+
url.searchParams.set("timeMax", iso(request.timeMax));
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
throw new FoxlinkError("bad-input", "timeMin and timeMax must be dates.");
|
|
51
|
+
}
|
|
52
|
+
url.searchParams.set("maxResults", String(clampMax(request.max)));
|
|
53
|
+
url.searchParams.set("singleEvents", "true");
|
|
54
|
+
url.searchParams.set("orderBy", "startTime");
|
|
55
|
+
const res = await link.fetch(url);
|
|
56
|
+
if (!res.ok)
|
|
57
|
+
throw new FoxlinkError("http-error", `Calendar answered HTTP ${res.status}.`, { status: res.status });
|
|
58
|
+
const body = (await res.json());
|
|
59
|
+
return { events: (body.items ?? []).filter((e) => typeof e.id === "string").map(toEvent) };
|
|
60
|
+
},
|
|
61
|
+
/** Add an event, after a foxgate decision with scope `submit`. The first call returns `ask`. */
|
|
62
|
+
async createEvent(event, approval = {}) {
|
|
63
|
+
if (!validEvent(event))
|
|
64
|
+
return { status: "refused", reason: "bad-input" };
|
|
65
|
+
if (!(await hasAny(WRITE_SCOPES)))
|
|
66
|
+
return { status: "refused", reason: "missing-scope" };
|
|
67
|
+
const args = { summary: event.summary, start: event.start, end: event.end };
|
|
68
|
+
if (event.description !== undefined)
|
|
69
|
+
args.description = event.description;
|
|
70
|
+
if (event.location !== undefined)
|
|
71
|
+
args.location = event.location;
|
|
72
|
+
const action = { tool: "foxlink.calendar.create", scope: FOXLINK_TOOLS["foxlink.calendar.create"], domain: new URL(base).hostname, args };
|
|
73
|
+
return gated(options.gate, action, approval.token, async (judged) => {
|
|
74
|
+
const body = { summary: judged.summary, description: judged.description, location: judged.location, start: { dateTime: judged.start }, end: { dateTime: judged.end } };
|
|
75
|
+
const res = await link.fetch(events, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
|
|
76
|
+
if (!res.ok)
|
|
77
|
+
throw new FoxlinkError("http-error", `Calendar answered HTTP ${res.status}.`, { status: res.status });
|
|
78
|
+
const created = (await res.json());
|
|
79
|
+
return { status: "created", id: String(created.id ?? ""), htmlLink: String(created.htmlLink ?? "") };
|
|
80
|
+
});
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
}
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Why foxlink refused. Callers can switch on `code`. Messages never hold a token, a code, or server text. */
|
|
2
|
+
export type FoxlinkErrorCode = "bad-config" | "bad-scope" | "busy" | "authorize-failed" | "redirect-mismatch" | "state-mismatch" | "access-denied" | "token-error" | "scope-creep" | "id-token" | "not-connected" | "reconnect" | "unauthorized" | "bad-host" | "http-error" | "missing-scope" | "bad-input";
|
|
3
|
+
export declare class FoxlinkError extends Error {
|
|
4
|
+
readonly code: FoxlinkErrorCode;
|
|
5
|
+
/** The HTTP status, when a server answered. */
|
|
6
|
+
readonly status?: number;
|
|
7
|
+
/** The OAuth `error` code from the server, cut to `a-z 0-9 _`. */
|
|
8
|
+
readonly oauthError?: string;
|
|
9
|
+
constructor(code: FoxlinkErrorCode, message: string, extra?: {
|
|
10
|
+
status?: number;
|
|
11
|
+
oauthError?: string;
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
/** An OAuth error code from a server, made safe to show: `a-z 0-9 _`, 40 characters at most. */
|
|
15
|
+
export declare const safeCode: (value: unknown) => string;
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export class FoxlinkError extends Error {
|
|
2
|
+
code;
|
|
3
|
+
/** The HTTP status, when a server answered. */
|
|
4
|
+
status;
|
|
5
|
+
/** The OAuth `error` code from the server, cut to `a-z 0-9 _`. */
|
|
6
|
+
oauthError;
|
|
7
|
+
constructor(code, message, extra = {}) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.name = "FoxlinkError";
|
|
10
|
+
this.code = code;
|
|
11
|
+
if (extra.status !== undefined)
|
|
12
|
+
this.status = extra.status;
|
|
13
|
+
if (extra.oauthError)
|
|
14
|
+
this.oauthError = extra.oauthError;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/** An OAuth error code from a server, made safe to show: `a-z 0-9 _`, 40 characters at most. */
|
|
18
|
+
export const safeCode = (value) => typeof value === "string" ? value.toLowerCase().replace(/[^a-z0-9_]/g, "").slice(0, 40) : "";
|
package/dist/gated.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { Action, Gate } from "foxgate";
|
|
2
|
+
/** The foxgate tools that foxlink uses. Register them with `createFoxgate({ tools: FOXLINK_TOOLS })`. */
|
|
3
|
+
export declare const FOXLINK_TOOLS: Readonly<{
|
|
4
|
+
readonly "foxlink.gmail.send": "submit";
|
|
5
|
+
readonly "foxlink.calendar.create": "submit";
|
|
6
|
+
}>;
|
|
7
|
+
export type Refused = {
|
|
8
|
+
status: "refused";
|
|
9
|
+
reason: string;
|
|
10
|
+
};
|
|
11
|
+
export type Asked = {
|
|
12
|
+
status: "ask";
|
|
13
|
+
requestId: string;
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* Ask the gate about one write. With a token, redeem it for the same action.
|
|
17
|
+
* `run` gets the args that the gate judged, never the caller's copy.
|
|
18
|
+
*/
|
|
19
|
+
export declare function gated<T>(gate: Gate | undefined, action: Action, token: string | undefined, run: (args: Record<string, unknown>) => Promise<T>): Promise<T | Asked | Refused>;
|
|
20
|
+
export declare const hasControl: (value: string) => boolean;
|
package/dist/gated.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** The foxgate tools that foxlink uses. Register them with `createFoxgate({ tools: FOXLINK_TOOLS })`. */
|
|
2
|
+
export const FOXLINK_TOOLS = Object.freeze({
|
|
3
|
+
"foxlink.gmail.send": "submit",
|
|
4
|
+
"foxlink.calendar.create": "submit",
|
|
5
|
+
});
|
|
6
|
+
/**
|
|
7
|
+
* Ask the gate about one write. With a token, redeem it for the same action.
|
|
8
|
+
* `run` gets the args that the gate judged, never the caller's copy.
|
|
9
|
+
*/
|
|
10
|
+
export async function gated(gate, action, token, run) {
|
|
11
|
+
if (!gate)
|
|
12
|
+
return { status: "refused", reason: "no-gate" };
|
|
13
|
+
const decision = token === undefined ? await gate.check(action) : await gate.redeem(token, action);
|
|
14
|
+
if (decision.decision === "ask")
|
|
15
|
+
return { status: "ask", requestId: decision.requestId };
|
|
16
|
+
if (decision.decision === "deny")
|
|
17
|
+
return { status: "refused", reason: decision.reason };
|
|
18
|
+
return run(decision.action.args);
|
|
19
|
+
}
|
|
20
|
+
export const hasControl = (value) => /[\r\n\0]/.test(value);
|
package/dist/gmail.d.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { Gate } from "foxgate";
|
|
2
|
+
import { type Asked, type Refused } from "./gated.js";
|
|
3
|
+
import type { Link } from "./link.js";
|
|
4
|
+
import { type Untrusted } from "./text.js";
|
|
5
|
+
/** One message in a list: headers and the Gmail snippet. */
|
|
6
|
+
export interface MessageSummary extends Untrusted {
|
|
7
|
+
source: "gmail";
|
|
8
|
+
threadId: string;
|
|
9
|
+
from: string;
|
|
10
|
+
to: string;
|
|
11
|
+
subject: string;
|
|
12
|
+
date: string;
|
|
13
|
+
snippet: string;
|
|
14
|
+
}
|
|
15
|
+
/** One message with its body as plain text. */
|
|
16
|
+
export interface Message extends MessageSummary {
|
|
17
|
+
text: string;
|
|
18
|
+
}
|
|
19
|
+
interface Part {
|
|
20
|
+
mimeType?: string;
|
|
21
|
+
filename?: string;
|
|
22
|
+
headers?: {
|
|
23
|
+
name: string;
|
|
24
|
+
value: string;
|
|
25
|
+
}[];
|
|
26
|
+
body?: {
|
|
27
|
+
data?: string;
|
|
28
|
+
attachmentId?: string;
|
|
29
|
+
};
|
|
30
|
+
parts?: Part[];
|
|
31
|
+
}
|
|
32
|
+
export interface OutgoingMessage {
|
|
33
|
+
/** One address, or up to 10 addresses with commas between them. */
|
|
34
|
+
to: string;
|
|
35
|
+
subject: string;
|
|
36
|
+
/** Plain text. */
|
|
37
|
+
body: string;
|
|
38
|
+
}
|
|
39
|
+
/** The plain text of a message: the first text/plain part, else the first text/html part as text. */
|
|
40
|
+
export declare function messageText(payload: Part | undefined): string;
|
|
41
|
+
export declare function gmail(link: Link, options?: {
|
|
42
|
+
baseUrl?: string;
|
|
43
|
+
gate?: Gate;
|
|
44
|
+
}): Readonly<{
|
|
45
|
+
/** Newest messages first, with their headers. One list request and one request for each message. */
|
|
46
|
+
listMessages(request?: {
|
|
47
|
+
query?: string;
|
|
48
|
+
max?: number;
|
|
49
|
+
pageToken?: string;
|
|
50
|
+
}): Promise<{
|
|
51
|
+
messages: MessageSummary[];
|
|
52
|
+
nextPageToken?: string;
|
|
53
|
+
}>;
|
|
54
|
+
/** One message, with its body as plain text. HTML loses scripts, styles, and images. */
|
|
55
|
+
getMessage(id: string): Promise<Message>;
|
|
56
|
+
/**
|
|
57
|
+
* Send a plain text message, after a foxgate decision with scope `submit`.
|
|
58
|
+
* The first call returns `ask`. Call again with the approval token.
|
|
59
|
+
*/
|
|
60
|
+
sendMessage(message: OutgoingMessage, approval?: {
|
|
61
|
+
token?: string;
|
|
62
|
+
}): Promise<{
|
|
63
|
+
status: "sent";
|
|
64
|
+
id: string;
|
|
65
|
+
} | Asked | Refused>;
|
|
66
|
+
}>;
|
|
67
|
+
export type Gmail = ReturnType<typeof gmail>;
|
|
68
|
+
export {};
|