@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 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
- # Temporary Holding Version
1
+ # foxlink
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
+ <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 &amp; 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>;
@@ -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
+ }
@@ -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) : "";
@@ -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);
@@ -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 {};