@byokit/connect 0.1.0 → 0.3.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/CHANGELOG.md +12 -0
- package/README.md +135 -9
- package/SECURITY.md +7 -3
- package/dist/connect.d.ts +6 -1
- package/dist/connect.js +57 -9
- package/dist/errors.d.ts +9 -2
- package/dist/errors.js +23 -3
- package/dist/history.d.ts +40 -0
- package/dist/history.js +82 -0
- package/dist/index.d.ts +6 -2
- package/dist/index.js +2 -0
- package/dist/mail.d.ts +108 -0
- package/dist/mail.js +263 -0
- package/dist/node.d.ts +4 -1
- package/dist/node.js +37 -0
- package/dist/types.d.ts +23 -0
- package/dist/words.json +9 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.3.0 (2026-10-06)
|
|
6
|
+
|
|
7
|
+
- Add approval-gated Gmail send (`MailSender`): one plain-text message per call, sent only after the host's approval returns `true` for that exact message.
|
|
8
|
+
|
|
9
|
+
- Add typed Gmail read and search (`MailReader`) on a connection: envelopes, one bounded body, opaque page tokens.
|
|
10
|
+
- Add owner-bound mail history and export (`MailHistory`): cross-page envelopes and `json`/`csv` export, foreign principals denied before any fetch.
|
|
11
|
+
|
|
12
|
+
## 0.2.0 (2026-10-01)
|
|
13
|
+
|
|
14
|
+
- FIX: Preserve provider refresh-token lifetimes and sanitized OAuth error causes for setup guidance.
|
|
15
|
+
- Add a client-details check without a user grant, with plain outcomes for accepted, rejected and uncertain replies.
|
|
16
|
+
|
|
5
17
|
## 0.1.0 (2026-10-01)
|
|
6
18
|
|
|
7
19
|
|
package/README.md
CHANGED
|
@@ -24,7 +24,7 @@ import type { Keystore } from '@byokit/secrets';
|
|
|
24
24
|
declare const store: Keystore;
|
|
25
25
|
const drive = connect('drive', {
|
|
26
26
|
store,
|
|
27
|
-
person: '
|
|
27
|
+
person: 'Umer',
|
|
28
28
|
redirectUri: 'https://your-app.example/connect/callback',
|
|
29
29
|
client: { id: 'your-google-client-id', secret: 'your-google-client-secret' },
|
|
30
30
|
});
|
|
@@ -48,7 +48,7 @@ older flow; callbacks have a 15-minute deadline and can be used only once.
|
|
|
48
48
|
Google requires the host's registered client. Use a Desktop client with loopback,
|
|
49
49
|
or register the exact HTTPS/native redirect for that client. Presets keep Google's
|
|
50
50
|
offline/consent parameters and request one service at a time: `google` (identity),
|
|
51
|
-
`drive` (`drive.file`), `gmail` (read-only) and `calendar` (events). The host owns
|
|
51
|
+
`drive` (`drive.file`), `gmail` (read-only; pass `scopes` to send) and `calendar` (events). The host owns
|
|
52
52
|
Google consent-screen publishing and API enablement. The kit does not probe
|
|
53
53
|
undocumented provider pages or impose a product-specific approval policy.
|
|
54
54
|
|
|
@@ -60,6 +60,75 @@ parameters. Options `scopes` override the preset/discovered scopes; security
|
|
|
60
60
|
parameters (`state`, PKCE, response type, client and redirect) cannot be overridden
|
|
61
61
|
through `extra`. A sign-in with explicitly incomplete granted scopes is not saved.
|
|
62
62
|
|
|
63
|
+
## The house Google client
|
|
64
|
+
|
|
65
|
+
One Google OAuth client serves every Google route the house runs: this kit's Gmail
|
|
66
|
+
reader and sender, and Crewhouse's Google connections.
|
|
67
|
+
|
|
68
|
+
- **Client:** type **Desktop app**, in the house's own Google Cloud project, with the
|
|
69
|
+
Gmail, Calendar and Drive APIs enabled. Desktop clients accept any loopback port, so
|
|
70
|
+
`connectLoopback` needs no redirect registration.
|
|
71
|
+
- **Consent:** audience External, published (*In production*; *Testing* grants stop
|
|
72
|
+
after 7 days). The consent screen lists `gmail.readonly`, `gmail.send`,
|
|
73
|
+
`calendar.events` and `drive.file`. Each connection still asks for one service;
|
|
74
|
+
sending adds `gmail.send` through `scopes`.
|
|
75
|
+
- **Secret:** the JSON Google offers to download, unchanged, at
|
|
76
|
+
`~/.config/byokit/google-oauth-client.json`, mode 600. Never in a repository, a log
|
|
77
|
+
or a test HOME.
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
import { homedir } from 'node:os';
|
|
81
|
+
import { join } from 'node:path';
|
|
82
|
+
import { connectLoopback, googleClientFile } from '@byokit/connect/node';
|
|
83
|
+
import type { Keystore } from '@byokit/secrets';
|
|
84
|
+
|
|
85
|
+
declare const store: Keystore;
|
|
86
|
+
declare const yourApp: { openExternal(url: string): Promise<void>; setUpGoogle(): never };
|
|
87
|
+
|
|
88
|
+
const client = await googleClientFile(join(homedir(), '.config', 'byokit', 'google-oauth-client.json'));
|
|
89
|
+
if (!client) yourApp.setUpGoogle(); // No file yet: Google is not set up on this computer.
|
|
90
|
+
const attempt = await connectLoopback('gmail', { store, person: 'Umer', client,
|
|
91
|
+
scopes: ['https://www.googleapis.com/auth/gmail.readonly', 'https://www.googleapis.com/auth/gmail.send'],
|
|
92
|
+
open: url => yourApp.openExternal(url) });
|
|
93
|
+
await attempt.done; // Then MailReader/MailSender on attempt.connection.
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
`googleClientFile(path)` reads only the path the host passes. It returns `null` when
|
|
97
|
+
the file is absent, and refuses a file other users can read, a non-regular file
|
|
98
|
+
at the path, or any client type other than Desktop app, with a plain message
|
|
99
|
+
that never repeats the file's contents.
|
|
100
|
+
|
|
101
|
+
## Check app details and inspect grant lifetime
|
|
102
|
+
|
|
103
|
+
```ts
|
|
104
|
+
import type { Connection } from '@byokit/connect';
|
|
105
|
+
|
|
106
|
+
declare const drive: Connection;
|
|
107
|
+
declare const enteredClientId: string;
|
|
108
|
+
declare const enteredClientSecret: string;
|
|
109
|
+
|
|
110
|
+
const check = await drive.verifyClient(); // Uses options.client.
|
|
111
|
+
// Or check newly entered details without changing the saved connection:
|
|
112
|
+
const entered = await drive.verifyClient({ id: enteredClientId, secret: enteredClientSecret });
|
|
113
|
+
const grant = await drive.grant(); // Trusted app code only; contains credentials.
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
`grant()` returns a snapshot or `null` and makes no provider call. Times are Unix
|
|
117
|
+
milliseconds. `refreshTokenExpiresIn` preserves the provider's
|
|
118
|
+
`refresh_token_expires_in` seconds; `refreshTokenExpiresAt` is its absolute deadline.
|
|
119
|
+
Sign-in/refresh responses calculate the deadline when received, and omitted refresh
|
|
120
|
+
lifetime fields retain the previous deadline. Expired refresh grants require
|
|
121
|
+
sign-in without sending another refresh request.
|
|
122
|
+
|
|
123
|
+
`verifyClient(client?)` uses the selected token endpoint with a deliberately
|
|
124
|
+
nonexistent refresh grant, without reading or changing the person's grant. For
|
|
125
|
+
Google, `invalid_client` yields `outcome: 'invalid'`; a 400 `invalid_grant` yields
|
|
126
|
+
`'valid'`; network failures and all other replies yield `'inconclusive'`. Each
|
|
127
|
+
outcome includes a plain `message`; failed provider replies may carry the same
|
|
128
|
+
sanitized diagnostic `cause` as `ConnectError`. A secret is required. This checks
|
|
129
|
+
client authentication only; it does not check consent-screen publishing, enabled
|
|
130
|
+
APIs, redirect registration or scopes. Tests use injected fake endpoints only.
|
|
131
|
+
|
|
63
132
|
## Loopback on a computer
|
|
64
133
|
|
|
65
134
|
```ts
|
|
@@ -70,7 +139,7 @@ declare const store: Keystore;
|
|
|
70
139
|
declare const yourApp: { openExternal(url: string): Promise<void> };
|
|
71
140
|
|
|
72
141
|
const attempt = await connectLoopback('notion', {
|
|
73
|
-
store, person: '
|
|
142
|
+
store, person: 'Umer',
|
|
74
143
|
open: url => yourApp.openExternal(url),
|
|
75
144
|
});
|
|
76
145
|
await attempt.done;
|
|
@@ -91,7 +160,7 @@ import type { Keystore } from '@byokit/secrets';
|
|
|
91
160
|
declare const store: Keystore;
|
|
92
161
|
|
|
93
162
|
const notion = connect('notion', {
|
|
94
|
-
store, person: '
|
|
163
|
+
store, person: 'Umer', redirectUri: 'https://your-app.example/connect/callback',
|
|
95
164
|
});
|
|
96
165
|
// Sign in once, then reconstruct this handle across app restarts.
|
|
97
166
|
const mcp = await notion.mcp({
|
|
@@ -120,6 +189,60 @@ A 401 triggers one refresh/retry; redirects are refused. Close clients before
|
|
|
120
189
|
`disconnect()` to release their streams. Arbitrary unauthenticated MCP servers and
|
|
121
190
|
legacy HTTP+SSE transports are outside this sign-in kit's initial scope.
|
|
122
191
|
|
|
192
|
+
## Read and search Gmail
|
|
193
|
+
|
|
194
|
+
```ts
|
|
195
|
+
import { connect, MailReader } from '@byokit/connect';
|
|
196
|
+
import type { Keystore } from '@byokit/secrets';
|
|
197
|
+
declare const store: Keystore;
|
|
198
|
+
const gmail = connect('gmail', { store, person: 'Umer', redirectUri: 'https://your-app.example/connect/callback' });
|
|
199
|
+
const mail = new MailReader(gmail, { maxBodyChars: 20_000 });
|
|
200
|
+
const page = await mail.search('invoice'); // Gmail syntax; follow page.nextPageToken.
|
|
201
|
+
const message = await mail.get(page.messages[0].id); // Envelope plus bounded body.
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
`search`/`list` return one page of envelopes; `get` adds the decoded body, cut at
|
|
205
|
+
`maxBodyChars` (`body.truncated` says so). A 401 retries once, then reads as
|
|
206
|
+
signed-out; rate limits carry `until` from `Retry-After`.
|
|
207
|
+
|
|
208
|
+
## Send Gmail, one approval per message
|
|
209
|
+
|
|
210
|
+
```ts
|
|
211
|
+
import { connect, MailSender, type OutgoingMail } from '@byokit/connect';
|
|
212
|
+
import type { Keystore } from '@byokit/secrets';
|
|
213
|
+
declare const store: Keystore;
|
|
214
|
+
declare function askPerson(mail: OutgoingMail): Promise<boolean>; // The host's own confirm step.
|
|
215
|
+
const gmail = connect('gmail', { store, person: 'Umer', redirectUri: 'https://your-app.example/connect/callback',
|
|
216
|
+
scopes: ['https://www.googleapis.com/auth/gmail.send'] });
|
|
217
|
+
const sender = new MailSender(gmail, mail => askPerson(mail));
|
|
218
|
+
const sent = await sender.send({ to: 'crew@example.test', subject: 'Invoice paid', body: 'Thanks, Umer' });
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
Sending needs the `gmail.send` scope, requested through `scopes` (a grant without it
|
|
222
|
+
fails sign-in with `scope`). Every `send` asks the approval once with the frozen
|
|
223
|
+
message it will encode; only `true` sends, and anything else, including a throw,
|
|
224
|
+
is `MailError` code `denied` before any token or provider call. One message per
|
|
225
|
+
call: no batching, queue, retry or background send. A network failure leaves the
|
|
226
|
+
outcome unknown, so check Sent before asking to send again. Plain-text bodies to
|
|
227
|
+
bare addresses only: no cc/bcc, display names, HTML, attachments, replies in a
|
|
228
|
+
thread or labels.
|
|
229
|
+
|
|
230
|
+
## Owner-bound history and export
|
|
231
|
+
|
|
232
|
+
```ts
|
|
233
|
+
import { MailHistory, type Connection } from '@byokit/connect';
|
|
234
|
+
declare const gmail: Connection; // Signed in for Umer.
|
|
235
|
+
const history = new MailHistory(gmail, { owner: 'Umer' });
|
|
236
|
+
const rows = await history.messages({ principal: 'Umer', query: 'invoice' });
|
|
237
|
+
const csv = await history.export({ principal: 'Umer', query: 'invoice', format: 'csv' }); // Or 'json'.
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
The host binds `owner` once; every call names its `principal` and anything else
|
|
241
|
+
is denied (`HistoryError` code `denied`) before any provider fetch. Retrieval
|
|
242
|
+
pages through `search`/`list` up to `maxMessages` (default 100, at most 500).
|
|
243
|
+
Export writes envelopes only — message bodies are never exported. Provider
|
|
244
|
+
failures keep their `MailError` code, `status` and `until` under `HistoryError`.
|
|
245
|
+
|
|
123
246
|
## Refresh, errors and storage
|
|
124
247
|
|
|
125
248
|
Refresh runs when the token is within one minute of expiry, or after a 401. It is
|
|
@@ -131,7 +254,10 @@ failure; an expired token is never returned. Tokens without `expires_in` remain
|
|
|
131
254
|
valid until rejected, then refresh if possible. No background timer is needed.
|
|
132
255
|
|
|
133
256
|
`ConnectError.code` is a typed app diagnostic and `message` is a plain sentence.
|
|
134
|
-
OAuth errors
|
|
257
|
+
OAuth errors expose an optional typed `cause` with `error` and
|
|
258
|
+
`error_description`, sanitized against supplied/returned credentials and URLs.
|
|
259
|
+
The message remains a plain sentence. Treat provider descriptions as untrusted
|
|
260
|
+
text; they are diagnostics for the host, not ready-to-render UI. No errors or
|
|
135
261
|
tokens are logged. MCP results/errors are private application content and should
|
|
136
262
|
be treated accordingly. `fetch`, `now`, request and flow timeouts are injectable.
|
|
137
263
|
|
|
@@ -187,13 +313,13 @@ the connection handle; never persist its returned access token separately.
|
|
|
187
313
|
## Crewhouse adoption
|
|
188
314
|
|
|
189
315
|
Replace the OAuth, token-file and `RemoteMcp` portions of `src/connections.ts` with
|
|
190
|
-
one handle per member/app. Pass the member's keystore and member ID, the
|
|
191
|
-
|
|
316
|
+
one handle per member/app. Pass the member's keystore and member ID, the house Google
|
|
317
|
+
client from `googleClientFile` when applicable, and Crewhouse's callback address. Keep
|
|
192
318
|
flow handles in the host's callback routing table until completion/cancellation.
|
|
193
319
|
Crewhouse retains its own connection screen, Google setup guidance and tool gates.
|
|
194
320
|
For remote apps, call `mcp()` and use `listTools`/`callTool` with their full results;
|
|
195
|
-
for Gmail
|
|
196
|
-
No raw OAuth, refresh or MCP transport needs to remain in the consumer.
|
|
321
|
+
for Gmail use `MailReader`/`MailSender` below; for Calendar use `token()` until its typed kit
|
|
322
|
+
sits on this connection. No raw OAuth, refresh or MCP transport needs to remain in the consumer.
|
|
197
323
|
|
|
198
324
|
## Verification
|
|
199
325
|
|
package/SECURITY.md
CHANGED
|
@@ -2,13 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
The host supplies a keystore and person identifier. Scope it to the person and app on
|
|
4
4
|
this device. Tokens and dynamic client credentials are written only to that store;
|
|
5
|
-
the kit never reads ambient accounts, environment keys or credential files.
|
|
5
|
+
the kit never reads ambient accounts, environment keys or credential files. The only
|
|
6
|
+
file it reads is the Google client file at the path the host passes to `googleClientFile`. No
|
|
6
7
|
server pool or account sharing is provided. Connection keys are fixed-length SHA-256 names over person, provider,
|
|
7
8
|
resource, issuer/token endpoint and configured client ID. Long identities therefore
|
|
8
9
|
fit every keystore backend without exposing those identities in keyring labels.
|
|
9
10
|
|
|
10
|
-
Treat `token()` as trusted app code: never display or log
|
|
11
|
-
not log credentials or include provider response bodies in OAuth
|
|
11
|
+
Treat `token()` and `grant()` as trusted app code: never display or log their results.
|
|
12
|
+
The kit does not log credentials or include raw provider response bodies in OAuth
|
|
13
|
+
errors. OAuth causes retain only `error` and `error_description`, with supplied and
|
|
14
|
+
returned credentials (including URL/form encodings), URLs and authorization headers
|
|
15
|
+
redacted. Treat descriptions as untrusted diagnostic text, never as HTML. The full
|
|
12
16
|
MCP client can return private content and server error messages; handle those as
|
|
13
17
|
private app data. Close each MCP client when finished.
|
|
14
18
|
|
package/dist/connect.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
2
2
|
import { type ProviderId } from './providers.ts';
|
|
3
|
-
import type { ConnectOptions, Provider, SignIn, McpOptions } from './types.ts';
|
|
3
|
+
import type { ConnectOptions, OAuthClient, Provider, SignIn, McpOptions, Grant, ClientVerification } from './types.ts';
|
|
4
4
|
/** One person's connection on one device; the host owns the store and browser. */
|
|
5
5
|
export declare class Connection {
|
|
6
6
|
readonly provider: Readonly<Provider>;
|
|
@@ -14,6 +14,11 @@ export declare class Connection {
|
|
|
14
14
|
private get timeout();
|
|
15
15
|
private get now();
|
|
16
16
|
private saved;
|
|
17
|
+
/** Snapshot for trusted app code, including the provider's refresh lifetime. */
|
|
18
|
+
grant(): Promise<Grant | null>;
|
|
19
|
+
/** Google checks app credentials before rejecting this deliberately nonexistent refresh grant.
|
|
20
|
+
* Only invalid_grant confirms the details; other responses cannot prove validity. */
|
|
21
|
+
verifyClient(input?: OAuthClient): Promise<ClientVerification>;
|
|
17
22
|
connected(): Promise<boolean>;
|
|
18
23
|
disconnect(): Promise<void>;
|
|
19
24
|
/** Starts a fresh browser sign-in. No opt-in flag or server-held account is required. */
|
package/dist/connect.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import WORDS from './words.json' with { type: 'json' };
|
|
1
2
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
2
3
|
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
|
3
|
-
import { ConnectError } from "./errors.js";
|
|
4
|
+
import { ConnectError, providerCause } from "./errors.js";
|
|
4
5
|
import { discover } from "./discovery.js";
|
|
5
6
|
import { endpoint, redirect, request, json } from "./http.js";
|
|
6
7
|
import { providers } from "./providers.js";
|
|
@@ -26,6 +27,15 @@ function serial(slot, action) {
|
|
|
26
27
|
}
|
|
27
28
|
function base64url(bytes) { return btoa(String.fromCharCode(...bytes)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); }
|
|
28
29
|
function random() { return base64url(crypto.getRandomValues(new Uint8Array(32))); }
|
|
30
|
+
function validClient(client) {
|
|
31
|
+
if (!client || typeof client.id !== 'string' || !client.id.trim() ||
|
|
32
|
+
(client.secret !== undefined && (typeof client.secret !== 'string' || !client.secret)) ||
|
|
33
|
+
(client.authMethod !== undefined && !['none', 'client_secret_post', 'client_secret_basic'].includes(client.authMethod)) ||
|
|
34
|
+
(client.authMethod?.startsWith('client_secret_') && !client.secret))
|
|
35
|
+
throw new ConnectError('configuration');
|
|
36
|
+
return { id: client.id, secret: client.secret, authMethod: client.authMethod };
|
|
37
|
+
}
|
|
38
|
+
function validTime(value) { return value === undefined || (typeof value === 'number' && Number.isFinite(value) && value >= 0); }
|
|
29
39
|
/** One person's connection on one device; the host owns the store and browser. */
|
|
30
40
|
export class Connection {
|
|
31
41
|
provider;
|
|
@@ -73,6 +83,36 @@ export class Connection {
|
|
|
73
83
|
throw new ConnectError('token');
|
|
74
84
|
}
|
|
75
85
|
}
|
|
86
|
+
/** Snapshot for trusted app code, including the provider's refresh lifetime. */
|
|
87
|
+
async grant() {
|
|
88
|
+
const saved = await this.saved();
|
|
89
|
+
if (!saved)
|
|
90
|
+
return null;
|
|
91
|
+
const t = saved.tokens;
|
|
92
|
+
return { accessToken: t.access, refreshToken: t.refresh, expiresAt: t.expires, scope: t.scope,
|
|
93
|
+
refreshTokenExpiresIn: t.refreshExpiresIn, refreshTokenExpiresAt: t.refreshExpiresAt };
|
|
94
|
+
}
|
|
95
|
+
/** Google checks app credentials before rejecting this deliberately nonexistent refresh grant.
|
|
96
|
+
* Only invalid_grant confirms the details; other responses cannot prove validity. */
|
|
97
|
+
async verifyClient(input) {
|
|
98
|
+
const client = validClient((input ?? this.options.client));
|
|
99
|
+
if (!client.secret || client.authMethod === 'none')
|
|
100
|
+
throw new ConnectError('configuration');
|
|
101
|
+
try {
|
|
102
|
+
const info = await discover(this.provider, this.fetcher, this.timeout);
|
|
103
|
+
await this.exchange(info, client, { grant_type: 'refresh_token', refresh_token: 'byokit-client-check-not-a-grant' });
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
if (!(error instanceof ConnectError))
|
|
107
|
+
throw error;
|
|
108
|
+
if (error.cause?.error === 'invalid_client' && (error.status === 400 || error.status === 401))
|
|
109
|
+
return { outcome: 'invalid', message: WORDS.verification.invalid, cause: error.cause };
|
|
110
|
+
if (error.cause?.error === 'invalid_grant' && error.status === 400)
|
|
111
|
+
return { outcome: 'valid', message: WORDS.verification.valid };
|
|
112
|
+
return { outcome: 'inconclusive', message: WORDS.verification.inconclusive, cause: error.cause };
|
|
113
|
+
}
|
|
114
|
+
return { outcome: 'inconclusive', message: WORDS.verification.inconclusive };
|
|
115
|
+
}
|
|
76
116
|
async connected() { return !!await this.saved(); }
|
|
77
117
|
async disconnect() {
|
|
78
118
|
++this.slot.epoch;
|
|
@@ -122,7 +162,7 @@ export class Connection {
|
|
|
122
162
|
throw new ConnectError('callback');
|
|
123
163
|
consumed = true;
|
|
124
164
|
if (returned.searchParams.has('error'))
|
|
125
|
-
throw new ConnectError('declined');
|
|
165
|
+
throw new ConnectError('declined', undefined, providerCause(Object.fromEntries(returned.searchParams), [state, verifier, client.id, client.secret ?? '', ...returned.searchParams.getAll('code')]));
|
|
126
166
|
if (returned.searchParams.getAll('code').length !== 1 || !returned.searchParams.get('code'))
|
|
127
167
|
throw new ConnectError('callback');
|
|
128
168
|
await serial(this.slot, async () => {
|
|
@@ -148,11 +188,9 @@ export class Connection {
|
|
|
148
188
|
method: 'POST', headers: { 'content-type': 'application/json', accept: 'application/json' },
|
|
149
189
|
body: JSON.stringify({ client_name: this.options.clientName ?? 'BYOKit', redirect_uris: [this.options.redirectUri], grant_types: ['authorization_code', 'refresh_token'], response_types: ['code'], token_endpoint_auth_method: 'none' }),
|
|
150
190
|
}, this.timeout);
|
|
151
|
-
if (!response.ok) {
|
|
152
|
-
await response.body?.cancel();
|
|
153
|
-
throw new ConnectError('registration', response.status);
|
|
154
|
-
}
|
|
155
191
|
const m = await json(response, 'registration');
|
|
192
|
+
if (!response.ok || m.error)
|
|
193
|
+
throw new ConnectError('registration', response.status, providerCause(m, [this.options.clientName ?? 'BYOKit', this.options.redirectUri]));
|
|
156
194
|
if (typeof m.client_id !== 'string' || !m.client_id || (m.token_endpoint_auth_method !== undefined && m.token_endpoint_auth_method !== 'none'))
|
|
157
195
|
throw new ConnectError('registration');
|
|
158
196
|
return { id: m.client_id, authMethod: 'none' };
|
|
@@ -172,15 +210,20 @@ export class Connection {
|
|
|
172
210
|
const m = await json(response, 'token');
|
|
173
211
|
if (!response.ok || m.error) {
|
|
174
212
|
// Only an explicit invalid grant proves revocation. Transient failures preserve the sign-in.
|
|
213
|
+
const cause = providerCause(m, [client.id, client.secret ?? '', previous?.access ?? '', previous?.refresh ?? '', ...Object.values(fields)]);
|
|
175
214
|
if (m.error === 'invalid_grant' && previous)
|
|
176
|
-
throw new ConnectError('signin', response.status);
|
|
177
|
-
throw new ConnectError('token', response.status);
|
|
215
|
+
throw new ConnectError('signin', response.status, cause);
|
|
216
|
+
throw new ConnectError('token', response.status, cause);
|
|
178
217
|
}
|
|
179
218
|
if (typeof m.access_token !== 'string' || !m.access_token || typeof m.token_type !== 'string' || m.token_type.toLowerCase() !== 'bearer' || (m.expires_in !== undefined && (!Number.isFinite(Number(m.expires_in)) || Number(m.expires_in) < 0)))
|
|
180
219
|
throw new ConnectError('token');
|
|
220
|
+
if (m.refresh_token_expires_in !== undefined && (!validTime(m.refresh_token_expires_in) || !Number.isFinite(this.now + Number(m.refresh_token_expires_in) * 1000)))
|
|
221
|
+
throw new ConnectError('token');
|
|
181
222
|
return { access: m.access_token, refresh: typeof m.refresh_token === 'string' ? m.refresh_token : previous?.refresh,
|
|
182
223
|
expires: m.expires_in === undefined ? undefined : this.now + Number(m.expires_in) * 1000,
|
|
183
|
-
scope: typeof m.scope === 'string' ? m.scope : previous?.scope
|
|
224
|
+
scope: typeof m.scope === 'string' ? m.scope : previous?.scope,
|
|
225
|
+
refreshExpiresIn: typeof m.refresh_token_expires_in === 'number' ? m.refresh_token_expires_in : previous?.refreshExpiresIn,
|
|
226
|
+
refreshExpiresAt: typeof m.refresh_token_expires_in === 'number' ? this.now + m.refresh_token_expires_in * 1000 : previous?.refreshExpiresAt };
|
|
184
227
|
}
|
|
185
228
|
/** For trusted app code (mail/calendar/API calls). Never display or log this value. */
|
|
186
229
|
async token(rejectedAccessToken) {
|
|
@@ -202,6 +245,11 @@ export class Connection {
|
|
|
202
245
|
return old.access;
|
|
203
246
|
throw new ConnectError('signin');
|
|
204
247
|
}
|
|
248
|
+
if (old.refreshExpiresAt !== undefined && old.refreshExpiresAt <= this.now) {
|
|
249
|
+
if (!force && old.expires !== undefined && old.expires > this.now)
|
|
250
|
+
return old.access;
|
|
251
|
+
throw new ConnectError('signin');
|
|
252
|
+
}
|
|
205
253
|
try {
|
|
206
254
|
const tokens = await this.exchange(saved, saved.client, { grant_type: 'refresh_token', refresh_token: old.refresh }, old);
|
|
207
255
|
if (epoch !== this.slot.epoch)
|
package/dist/errors.d.ts
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
export type ConnectErrorCode = 'configuration' | 'discovery' | 'registration' | 'callback' | 'expired' | 'declined' | 'scope' | 'token' | 'signin' | 'network';
|
|
2
|
-
|
|
2
|
+
export interface ProviderErrorCause {
|
|
3
|
+
readonly error: string;
|
|
4
|
+
readonly error_description?: string;
|
|
5
|
+
}
|
|
6
|
+
/** Preserve OAuth diagnostics, removing credentials supplied to or returned by the provider. */
|
|
7
|
+
export declare function providerCause(body: Record<string, unknown>, secrets: readonly string[]): ProviderErrorCause | undefined;
|
|
8
|
+
/** Plain message and optional sanitized OAuth cause; never includes raw response bodies. */
|
|
3
9
|
export declare class ConnectError extends Error {
|
|
4
10
|
readonly code: ConnectErrorCode;
|
|
5
11
|
readonly status?: number;
|
|
6
|
-
|
|
12
|
+
readonly cause?: ProviderErrorCause;
|
|
13
|
+
constructor(code: ConnectErrorCode, status?: number, cause?: ProviderErrorCause);
|
|
7
14
|
}
|
package/dist/errors.js
CHANGED
|
@@ -1,11 +1,31 @@
|
|
|
1
1
|
import WORDS from './words.json' with { type: 'json' };
|
|
2
2
|
const words = WORDS.errors;
|
|
3
|
-
/**
|
|
3
|
+
/** Preserve OAuth diagnostics, removing credentials supplied to or returned by the provider. */
|
|
4
|
+
export function providerCause(body, secrets) {
|
|
5
|
+
if (typeof body.error !== 'string')
|
|
6
|
+
return undefined;
|
|
7
|
+
const values = [...secrets, ...['access_token', 'refresh_token', 'id_token', 'client_secret', 'client_id'].map(k => body[k]).filter((v) => typeof v === 'string')];
|
|
8
|
+
const redact = (text) => {
|
|
9
|
+
for (const value of values.filter(Boolean).sort((a, b) => b.length - a.length)) {
|
|
10
|
+
const variants = new Set([value, new URLSearchParams({ v: value }).toString().slice(2)]);
|
|
11
|
+
try {
|
|
12
|
+
variants.add(encodeURIComponent(value));
|
|
13
|
+
}
|
|
14
|
+
catch { /* Malformed Unicode still gets literal/form redaction. */ }
|
|
15
|
+
for (const encoded of variants)
|
|
16
|
+
text = text.split(encoded).join('[redacted]');
|
|
17
|
+
}
|
|
18
|
+
return text.replace(/https?:\/\/[^\s<>"']+/gi, '[redacted]').replace(/(?:Bearer|Basic)\s+[^\s,;]+/gi, '[redacted]');
|
|
19
|
+
};
|
|
20
|
+
const standard = ['invalid_request', 'invalid_client', 'invalid_grant', 'unauthorized_client', 'unsupported_grant_type', 'invalid_scope', 'access_denied', 'server_error', 'temporarily_unavailable', 'invalid_client_metadata', 'invalid_redirect_uri'];
|
|
21
|
+
return Object.freeze({ error: standard.includes(body.error) && !values.includes(body.error) ? body.error : redact(body.error), ...(typeof body.error_description === 'string' ? { error_description: redact(body.error_description) } : {}) });
|
|
22
|
+
}
|
|
23
|
+
/** Plain message and optional sanitized OAuth cause; never includes raw response bodies. */
|
|
4
24
|
export class ConnectError extends Error {
|
|
5
25
|
code;
|
|
6
26
|
status;
|
|
7
|
-
constructor(code, status) {
|
|
8
|
-
super(words[code]);
|
|
27
|
+
constructor(code, status, cause) {
|
|
28
|
+
super(words[code], cause ? { cause } : undefined);
|
|
9
29
|
this.name = 'ConnectError';
|
|
10
30
|
this.code = code;
|
|
11
31
|
this.status = status;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { type MailCredential, type MailEnvelope, type MailReaderOptions } from './mail.ts';
|
|
2
|
+
export type HistoryFormat = 'json' | 'csv';
|
|
3
|
+
export type HistoryErrorCode = 'denied' | 'invalid' | 'network' | 'signed-out' | 'rate-limited';
|
|
4
|
+
export declare class HistoryError extends Error {
|
|
5
|
+
readonly code: HistoryErrorCode;
|
|
6
|
+
readonly status?: number;
|
|
7
|
+
readonly until?: number;
|
|
8
|
+
constructor(code: HistoryErrorCode, message: string, o?: {
|
|
9
|
+
status?: number;
|
|
10
|
+
until?: number;
|
|
11
|
+
cause?: unknown;
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
export interface MailHistoryOptions extends MailReaderOptions {
|
|
15
|
+
/** Person the host seated this credential for; all other principals are denied. */
|
|
16
|
+
owner: string;
|
|
17
|
+
}
|
|
18
|
+
export interface HistoryQuery {
|
|
19
|
+
principal: string;
|
|
20
|
+
/** Gmail search syntax; absent lists recent mail. */
|
|
21
|
+
query?: string;
|
|
22
|
+
/** Envelopes collected across pages, default 100, at most 500. */
|
|
23
|
+
maxMessages?: number;
|
|
24
|
+
/** Provider page size, 1..500, default 20. */
|
|
25
|
+
pageSize?: number;
|
|
26
|
+
signal?: AbortSignal;
|
|
27
|
+
}
|
|
28
|
+
export interface HistoryExportQuery extends HistoryQuery {
|
|
29
|
+
format: HistoryFormat;
|
|
30
|
+
}
|
|
31
|
+
export declare class MailHistory {
|
|
32
|
+
private reader;
|
|
33
|
+
private owner;
|
|
34
|
+
constructor(credential: MailCredential, options: MailHistoryOptions);
|
|
35
|
+
private allow;
|
|
36
|
+
/** Envelopes across pages, oldest page token first, cut at `maxMessages`. */
|
|
37
|
+
messages(query: HistoryQuery): Promise<MailEnvelope[]>;
|
|
38
|
+
/** `json` array or `csv` rows of the same envelopes; bodies are never exported. */
|
|
39
|
+
export(query: HistoryExportQuery): Promise<string>;
|
|
40
|
+
}
|
package/dist/history.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// Owner-bound mail history and export. The credential (a Connection) stays the
|
|
2
|
+
// only authentication; every call names its principal and anything but the bound
|
|
3
|
+
// owner is denied before any provider fetch. This module never sends.
|
|
4
|
+
import { MailReader, MailError } from "./mail.js";
|
|
5
|
+
export class HistoryError extends Error {
|
|
6
|
+
code;
|
|
7
|
+
status;
|
|
8
|
+
until;
|
|
9
|
+
constructor(code, message, o = {}) {
|
|
10
|
+
super(message, o.cause === undefined ? undefined : { cause: o.cause });
|
|
11
|
+
this.name = 'HistoryError';
|
|
12
|
+
this.code = code;
|
|
13
|
+
if (o.status !== undefined)
|
|
14
|
+
this.status = o.status;
|
|
15
|
+
if (o.until !== undefined)
|
|
16
|
+
this.until = o.until;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
const MAX_MESSAGES = 500;
|
|
20
|
+
const int = (value, fallback, what) => {
|
|
21
|
+
const n = value ?? fallback;
|
|
22
|
+
if (!Number.isInteger(n) || n < 1 || n > MAX_MESSAGES)
|
|
23
|
+
throw new RangeError(`mail history ${what} is 1..500`);
|
|
24
|
+
return n;
|
|
25
|
+
};
|
|
26
|
+
const cell = (value) => /[",\n]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value;
|
|
27
|
+
function toCsv(rows) {
|
|
28
|
+
const lines = ['id,threadId,subject,from,to,date,snippet,labelIds'];
|
|
29
|
+
for (const m of rows)
|
|
30
|
+
lines.push([m.id, m.threadId, m.subject, m.from, m.to, m.date, m.snippet, m.labelIds.join('|')].map(cell).join(','));
|
|
31
|
+
return `${lines.join('\n')}\n`;
|
|
32
|
+
}
|
|
33
|
+
export class MailHistory {
|
|
34
|
+
reader;
|
|
35
|
+
owner;
|
|
36
|
+
constructor(credential, options) {
|
|
37
|
+
if (!credential || typeof credential.token !== 'function')
|
|
38
|
+
throw new TypeError('mail history needs a credential with token()');
|
|
39
|
+
if (!options || typeof options.owner !== 'string' || !options.owner.trim())
|
|
40
|
+
throw new TypeError('mail history needs a non-empty owner');
|
|
41
|
+
this.owner = options.owner;
|
|
42
|
+
this.reader = new MailReader(credential, options);
|
|
43
|
+
}
|
|
44
|
+
allow(principal) {
|
|
45
|
+
if (typeof principal !== 'string' || !principal)
|
|
46
|
+
throw new TypeError('mail history needs a non-empty principal');
|
|
47
|
+
if (principal !== this.owner)
|
|
48
|
+
throw new HistoryError('denied', 'mail history is not shared with this principal');
|
|
49
|
+
return principal;
|
|
50
|
+
}
|
|
51
|
+
/** Envelopes across pages, oldest page token first, cut at `maxMessages`. */
|
|
52
|
+
async messages(query) {
|
|
53
|
+
this.allow(query?.principal);
|
|
54
|
+
const { query: q, signal } = query ?? {};
|
|
55
|
+
if (q !== undefined && (typeof q !== 'string' || !q.trim()))
|
|
56
|
+
throw new TypeError('mail history needs a non-empty query');
|
|
57
|
+
const max = int(query?.maxMessages, 100, 'maxMessages'), size = int(query?.pageSize, 20, 'pageSize');
|
|
58
|
+
const rows = [];
|
|
59
|
+
let pageToken;
|
|
60
|
+
try {
|
|
61
|
+
do {
|
|
62
|
+
const page = q ? await this.reader.search(q.trim(), { maxResults: size, ...(pageToken ? { pageToken } : {}), ...(signal ? { signal } : {}) })
|
|
63
|
+
: await this.reader.list({ maxResults: size, ...(pageToken ? { pageToken } : {}), ...(signal ? { signal } : {}) });
|
|
64
|
+
rows.push(...page.messages);
|
|
65
|
+
pageToken = rows.length < max ? page.nextPageToken : undefined;
|
|
66
|
+
} while (pageToken);
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
if (error instanceof MailError)
|
|
70
|
+
throw new HistoryError(error.code, `mail history ${error.code === 'signed-out' ? 'sign-in is gone or refused' : 'provider failed'}`, { ...(error.status !== undefined ? { status: error.status } : {}), ...(error.until !== undefined ? { until: error.until } : {}) });
|
|
71
|
+
throw error;
|
|
72
|
+
}
|
|
73
|
+
return rows.slice(0, max);
|
|
74
|
+
}
|
|
75
|
+
/** `json` array or `csv` rows of the same envelopes; bodies are never exported. */
|
|
76
|
+
async export(query) {
|
|
77
|
+
if (query?.format !== 'json' && query?.format !== 'csv')
|
|
78
|
+
throw new TypeError('mail history export format is json or csv');
|
|
79
|
+
const rows = await this.messages(query);
|
|
80
|
+
return query.format === 'json' ? JSON.stringify(rows, null, 2) : toCsv(rows);
|
|
81
|
+
}
|
|
82
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -2,7 +2,11 @@ export { connect, Connection } from './connect.ts';
|
|
|
2
2
|
export { providers } from './providers.ts';
|
|
3
3
|
export type { ProviderId } from './providers.ts';
|
|
4
4
|
export { ConnectError } from './errors.ts';
|
|
5
|
-
export
|
|
6
|
-
export
|
|
5
|
+
export { MailReader, MailSender, MailError } from './mail.ts';
|
|
6
|
+
export { MailHistory, HistoryError } from './history.ts';
|
|
7
|
+
export type { HistoryFormat, HistoryErrorCode, MailHistoryOptions, HistoryQuery, HistoryExportQuery } from './history.ts';
|
|
8
|
+
export type { MailErrorCode, MailCredential, MailReaderOptions, MailEnvelope, MailBody, MailMessage, MailPage, MailListOptions, MailGetOptions, MailSenderOptions, MailDraft, OutgoingMail, MailApproval, MailSendOptions, SentMail } from './mail.ts';
|
|
9
|
+
export type { ConnectErrorCode, ProviderErrorCause } from './errors.ts';
|
|
10
|
+
export type { Provider, OAuthClient, OAuthEndpoints, ConnectOptions, SignIn, McpOptions, Grant, ClientVerification } from './types.ts';
|
|
7
11
|
export { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
8
12
|
export * from '@modelcontextprotocol/sdk/types.js';
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
export { connect, Connection } from "./connect.js";
|
|
2
2
|
export { providers } from "./providers.js";
|
|
3
3
|
export { ConnectError } from "./errors.js";
|
|
4
|
+
export { MailReader, MailSender, MailError } from "./mail.js";
|
|
5
|
+
export { MailHistory, HistoryError } from "./history.js";
|
|
4
6
|
// Expose the pinned engine for typed protocol pass-through without a second SDK pin.
|
|
5
7
|
export { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
6
8
|
export * from '@modelcontextprotocol/sdk/types.js';
|
package/dist/mail.d.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/** Why a read, search or send failed. `message` is for logs; never show it to a person. */
|
|
2
|
+
export type MailErrorCode = 'invalid' | 'network' | 'signed-out' | 'rate-limited' | 'denied';
|
|
3
|
+
export declare class MailError extends Error {
|
|
4
|
+
readonly code: MailErrorCode;
|
|
5
|
+
readonly status?: number;
|
|
6
|
+
readonly until?: number;
|
|
7
|
+
constructor(code: MailErrorCode, message: string, o?: {
|
|
8
|
+
status?: number;
|
|
9
|
+
until?: number;
|
|
10
|
+
cause?: unknown;
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
/** The only credential a reader or sender needs. `Connection` satisfies this structurally. */
|
|
14
|
+
export interface MailCredential {
|
|
15
|
+
token(rejectedAccessToken?: string): Promise<string>;
|
|
16
|
+
}
|
|
17
|
+
export interface MailSenderOptions {
|
|
18
|
+
userId?: string;
|
|
19
|
+
fetch?: typeof fetch;
|
|
20
|
+
now?: () => number;
|
|
21
|
+
}
|
|
22
|
+
export interface MailReaderOptions extends MailSenderOptions {
|
|
23
|
+
maxBodyChars?: number;
|
|
24
|
+
}
|
|
25
|
+
/** One message. `to` is one or more bare addresses (`name@host`); the sender is the signed-in mailbox. */
|
|
26
|
+
export interface MailDraft {
|
|
27
|
+
to: string | readonly string[];
|
|
28
|
+
subject: string;
|
|
29
|
+
body: string;
|
|
30
|
+
}
|
|
31
|
+
/** Exactly what will be sent, frozen: the approval sees the same object the send encodes. */
|
|
32
|
+
export interface OutgoingMail {
|
|
33
|
+
readonly to: readonly string[];
|
|
34
|
+
readonly subject: string;
|
|
35
|
+
readonly body: string;
|
|
36
|
+
}
|
|
37
|
+
/** Asked once per message, before any token or provider call. Only `true` sends; anything else, or a throw, denies. */
|
|
38
|
+
export type MailApproval = (mail: OutgoingMail) => boolean | Promise<boolean>;
|
|
39
|
+
export interface MailSendOptions {
|
|
40
|
+
signal?: AbortSignal;
|
|
41
|
+
}
|
|
42
|
+
export interface SentMail {
|
|
43
|
+
id: string;
|
|
44
|
+
threadId: string;
|
|
45
|
+
labelIds: readonly string[];
|
|
46
|
+
}
|
|
47
|
+
/** Headers plus snippet. Absent headers read as `''`. */
|
|
48
|
+
export interface MailEnvelope {
|
|
49
|
+
id: string;
|
|
50
|
+
threadId: string;
|
|
51
|
+
subject: string;
|
|
52
|
+
from: string;
|
|
53
|
+
to: string;
|
|
54
|
+
date: string;
|
|
55
|
+
snippet: string;
|
|
56
|
+
labelIds: readonly string[];
|
|
57
|
+
}
|
|
58
|
+
/** Decoded `text/plain` body. `text/html`-only mail reads as `''`; the snippet still shows. */
|
|
59
|
+
export interface MailBody {
|
|
60
|
+
text: string;
|
|
61
|
+
truncated: boolean;
|
|
62
|
+
}
|
|
63
|
+
export interface MailMessage extends MailEnvelope {
|
|
64
|
+
body: MailBody;
|
|
65
|
+
}
|
|
66
|
+
/** One page. `nextPageToken` is absent on the last page. */
|
|
67
|
+
export interface MailPage {
|
|
68
|
+
messages: MailEnvelope[];
|
|
69
|
+
nextPageToken?: string;
|
|
70
|
+
resultSizeEstimate?: number;
|
|
71
|
+
}
|
|
72
|
+
export interface MailListOptions {
|
|
73
|
+
maxResults?: number;
|
|
74
|
+
pageToken?: string;
|
|
75
|
+
signal?: AbortSignal;
|
|
76
|
+
}
|
|
77
|
+
export interface MailGetOptions {
|
|
78
|
+
signal?: AbortSignal;
|
|
79
|
+
}
|
|
80
|
+
declare class Gmail {
|
|
81
|
+
private credential;
|
|
82
|
+
private userId;
|
|
83
|
+
private fetcher;
|
|
84
|
+
private now;
|
|
85
|
+
constructor(kind: string, credential: MailCredential, options: MailSenderOptions);
|
|
86
|
+
/** GET with `params`, or POST `body` as JSON. A 401 retries once with a fresh token. */
|
|
87
|
+
protected call(path: string, params: Record<string, string | readonly string[]>, signal?: AbortSignal, body?: unknown): Promise<unknown>;
|
|
88
|
+
}
|
|
89
|
+
export declare class MailReader extends Gmail {
|
|
90
|
+
private maxBodyChars;
|
|
91
|
+
constructor(credential: MailCredential, options?: MailReaderOptions);
|
|
92
|
+
/** `q` is Gmail search syntax. */
|
|
93
|
+
search(query: string, options?: MailListOptions): Promise<MailPage>;
|
|
94
|
+
list(options?: MailListOptions): Promise<MailPage>;
|
|
95
|
+
/** Envelope plus the body, decoded and cut at `maxBodyChars`. */
|
|
96
|
+
get(id: string, options?: MailGetOptions): Promise<MailMessage>;
|
|
97
|
+
private page;
|
|
98
|
+
private envelope;
|
|
99
|
+
private bodyText;
|
|
100
|
+
}
|
|
101
|
+
/** Sends one message at a time from the signed-in Gmail, each only after its own approval. Needs `gmail.send`. */
|
|
102
|
+
export declare class MailSender extends Gmail {
|
|
103
|
+
private approve;
|
|
104
|
+
constructor(credential: MailCredential, approve: MailApproval, options?: MailSenderOptions);
|
|
105
|
+
/** Asks the approval with the frozen message, then sends it once. Never retried: a failed send asks again. */
|
|
106
|
+
send(draft: MailDraft, options?: MailSendOptions): Promise<SentMail>;
|
|
107
|
+
}
|
|
108
|
+
export {};
|
package/dist/mail.js
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
export class MailError extends Error {
|
|
2
|
+
code;
|
|
3
|
+
status;
|
|
4
|
+
until;
|
|
5
|
+
constructor(code, message, o = {}) {
|
|
6
|
+
super(message, o.cause === undefined ? undefined : { cause: o.cause });
|
|
7
|
+
this.name = 'MailError';
|
|
8
|
+
this.code = code;
|
|
9
|
+
if (o.status !== undefined)
|
|
10
|
+
this.status = o.status;
|
|
11
|
+
if (o.until !== undefined)
|
|
12
|
+
this.until = o.until;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
const API = 'https://gmail.googleapis.com/gmail/v1';
|
|
16
|
+
const MAX_RESULTS = 500;
|
|
17
|
+
const HEADERS = ['Subject', 'From', 'To', 'Date'];
|
|
18
|
+
function isRecord(value) {
|
|
19
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
20
|
+
}
|
|
21
|
+
function mailShape(what) { return new MailError('invalid', `mail provider answered outside its shape (${what})`); }
|
|
22
|
+
function field(value, what) {
|
|
23
|
+
if (typeof value !== 'string')
|
|
24
|
+
throw mailShape(what);
|
|
25
|
+
return value;
|
|
26
|
+
}
|
|
27
|
+
/** Base64url without `Buffer`, so the reader stays portable. */
|
|
28
|
+
function decodeBase64Url(data) {
|
|
29
|
+
const abc = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
|
|
30
|
+
const clean = data.replace(/=+$/, '');
|
|
31
|
+
const out = new Uint8Array(Math.floor(clean.length * 3 / 4) + 1);
|
|
32
|
+
let n = 0, bits = 0, acc = 0;
|
|
33
|
+
for (const ch of clean) {
|
|
34
|
+
const v = abc.indexOf(ch);
|
|
35
|
+
if (v < 0)
|
|
36
|
+
throw mailShape('body data');
|
|
37
|
+
acc = (acc << 6) | v;
|
|
38
|
+
bits += 6;
|
|
39
|
+
if (bits >= 8) {
|
|
40
|
+
bits -= 8;
|
|
41
|
+
out[n++] = (acc >> bits) & 255;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return new TextDecoder().decode(out.subarray(0, n));
|
|
45
|
+
}
|
|
46
|
+
class Gmail {
|
|
47
|
+
credential;
|
|
48
|
+
userId;
|
|
49
|
+
fetcher;
|
|
50
|
+
now;
|
|
51
|
+
constructor(kind, credential, options) {
|
|
52
|
+
if (!credential || typeof credential.token !== 'function')
|
|
53
|
+
throw new TypeError(`${kind} needs a credential with token()`);
|
|
54
|
+
const { userId = 'me', fetch, now = Date.now } = options ?? {};
|
|
55
|
+
if (typeof userId !== 'string' || !userId.trim())
|
|
56
|
+
throw new TypeError(`${kind} needs a non-empty userId`);
|
|
57
|
+
this.credential = credential;
|
|
58
|
+
this.userId = userId;
|
|
59
|
+
this.fetcher = fetch ?? globalThis.fetch;
|
|
60
|
+
this.now = now;
|
|
61
|
+
}
|
|
62
|
+
/** GET with `params`, or POST `body` as JSON. A 401 retries once with a fresh token. */
|
|
63
|
+
async call(path, params, signal, body) {
|
|
64
|
+
signal?.throwIfAborted();
|
|
65
|
+
const refused = (cause) => new MailError('signed-out', 'mail credential refused', { cause });
|
|
66
|
+
const send = async (access) => {
|
|
67
|
+
const url = new URL(`${API}/users/${encodeURIComponent(this.userId)}${path}`);
|
|
68
|
+
for (const [key, value] of Object.entries(params))
|
|
69
|
+
for (const v of Array.isArray(value) ? value : [value])
|
|
70
|
+
url.searchParams.append(key, v);
|
|
71
|
+
try {
|
|
72
|
+
return await this.fetcher(url, body === undefined ? { headers: { authorization: `Bearer ${access}` }, signal }
|
|
73
|
+
: { method: 'POST', headers: { authorization: `Bearer ${access}`, 'content-type': 'application/json' }, body: JSON.stringify(body), signal });
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
signal?.throwIfAborted();
|
|
77
|
+
throw new MailError('network', 'mail provider could not be reached', { cause: error });
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
const access = await this.credential.token().catch(cause => { throw refused(cause); });
|
|
81
|
+
let res = await send(access);
|
|
82
|
+
if (res.status === 401)
|
|
83
|
+
res = await send(await this.credential.token(access).catch(cause => { throw refused(cause); }));
|
|
84
|
+
if (res.status === 401 || res.status === 403)
|
|
85
|
+
throw new MailError('signed-out', 'mail sign-in is gone or refused', { status: res.status });
|
|
86
|
+
if (res.status === 429) {
|
|
87
|
+
const after = Number(res.headers.get('retry-after'));
|
|
88
|
+
const until = Number.isFinite(after) && after >= 0 ? this.now() + after * 1000 : undefined;
|
|
89
|
+
throw new MailError('rate-limited', 'mail provider is rate limited', { status: 429, ...(until === undefined ? {} : { until }) });
|
|
90
|
+
}
|
|
91
|
+
if (!res.ok)
|
|
92
|
+
throw new MailError('network', `mail provider answered HTTP ${res.status}`, { status: res.status });
|
|
93
|
+
try {
|
|
94
|
+
return await res.json();
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
throw new MailError('invalid', 'mail provider answered outside its shape (json)', { cause: error });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
export class MailReader extends Gmail {
|
|
102
|
+
maxBodyChars;
|
|
103
|
+
constructor(credential, options = {}) {
|
|
104
|
+
super('MailReader', credential, options);
|
|
105
|
+
const { maxBodyChars = 20_000 } = options ?? {};
|
|
106
|
+
if (!Number.isInteger(maxBodyChars) || maxBodyChars <= 0)
|
|
107
|
+
throw new RangeError('MailReader maxBodyChars must be a positive integer');
|
|
108
|
+
this.maxBodyChars = maxBodyChars;
|
|
109
|
+
}
|
|
110
|
+
/** `q` is Gmail search syntax. */
|
|
111
|
+
async search(query, options = {}) {
|
|
112
|
+
if (typeof query !== 'string' || !query.trim())
|
|
113
|
+
throw new TypeError('mail search needs a non-empty query');
|
|
114
|
+
return this.page({ q: query.trim() }, options);
|
|
115
|
+
}
|
|
116
|
+
async list(options = {}) {
|
|
117
|
+
return this.page({}, options);
|
|
118
|
+
}
|
|
119
|
+
/** Envelope plus the body, decoded and cut at `maxBodyChars`. */
|
|
120
|
+
async get(id, options = {}) {
|
|
121
|
+
if (typeof id !== 'string' || !id)
|
|
122
|
+
throw new TypeError('mail message id must be a non-empty string');
|
|
123
|
+
const json = await this.call(`/messages/${encodeURIComponent(id)}`, { format: 'full' }, options.signal);
|
|
124
|
+
const text = this.bodyText(isRecord(json) && isRecord(json.payload) ? json.payload : {});
|
|
125
|
+
const truncated = text.length > this.maxBodyChars;
|
|
126
|
+
return { ...this.envelope(json), body: { text: truncated ? text.slice(0, this.maxBodyChars) : text, truncated } };
|
|
127
|
+
}
|
|
128
|
+
async page(extra, options) {
|
|
129
|
+
const { maxResults = 20, pageToken, signal } = options ?? {};
|
|
130
|
+
if (!Number.isInteger(maxResults) || maxResults < 1 || maxResults > MAX_RESULTS)
|
|
131
|
+
throw new RangeError('mail maxResults is 1..500');
|
|
132
|
+
if (pageToken !== undefined && typeof pageToken !== 'string')
|
|
133
|
+
throw new TypeError('mail pageToken must be a string');
|
|
134
|
+
const params = { maxResults: String(maxResults), ...extra };
|
|
135
|
+
if (pageToken)
|
|
136
|
+
params.pageToken = pageToken;
|
|
137
|
+
const json = await this.call('/messages', params, signal);
|
|
138
|
+
if (!isRecord(json))
|
|
139
|
+
throw mailShape('list');
|
|
140
|
+
if (json.messages !== undefined && !Array.isArray(json.messages))
|
|
141
|
+
throw mailShape('list');
|
|
142
|
+
const messages = [];
|
|
143
|
+
for (const item of json.messages ?? []) {
|
|
144
|
+
if (!isRecord(item))
|
|
145
|
+
throw mailShape('list');
|
|
146
|
+
messages.push(this.envelope(await this.call(`/messages/${encodeURIComponent(field(item.id, 'message id'))}`, { format: 'metadata', metadataHeaders: HEADERS }, signal)));
|
|
147
|
+
}
|
|
148
|
+
const token = json.nextPageToken, size = json.resultSizeEstimate;
|
|
149
|
+
if (token !== undefined && (typeof token !== 'string' || !token))
|
|
150
|
+
throw mailShape('list');
|
|
151
|
+
if (size !== undefined && typeof size !== 'number')
|
|
152
|
+
throw mailShape('list');
|
|
153
|
+
return { messages, ...(typeof token === 'string' && token ? { nextPageToken: token } : {}),
|
|
154
|
+
...(typeof size === 'number' ? { resultSizeEstimate: size } : {}) };
|
|
155
|
+
}
|
|
156
|
+
envelope(json) {
|
|
157
|
+
if (!isRecord(json))
|
|
158
|
+
throw mailShape('message');
|
|
159
|
+
const headers = isRecord(json.payload) && Array.isArray(json.payload.headers) ? json.payload.headers : [];
|
|
160
|
+
const find = (name) => {
|
|
161
|
+
for (const h of headers)
|
|
162
|
+
if (isRecord(h) && typeof h.name === 'string' && h.name.toLowerCase() === name.toLowerCase())
|
|
163
|
+
return typeof h.value === 'string' ? h.value : '';
|
|
164
|
+
return '';
|
|
165
|
+
};
|
|
166
|
+
const rawLabels = json.labelIds;
|
|
167
|
+
const labelIds = Array.isArray(rawLabels) && rawLabels.every(v => typeof v === 'string') ? [...rawLabels] : [];
|
|
168
|
+
return { id: field(json.id, 'id'), threadId: field(json.threadId, 'threadId'),
|
|
169
|
+
subject: find('Subject'), from: find('From'), to: find('To'), date: find('Date'),
|
|
170
|
+
snippet: typeof json.snippet === 'string' ? json.snippet : '', labelIds };
|
|
171
|
+
}
|
|
172
|
+
bodyText(part) {
|
|
173
|
+
if (!isRecord(part))
|
|
174
|
+
return '';
|
|
175
|
+
const texts = [];
|
|
176
|
+
if (part.mimeType === 'text/plain' && isRecord(part.body) && typeof part.body.data === 'string' && part.body.data)
|
|
177
|
+
texts.push(decodeBase64Url(part.body.data));
|
|
178
|
+
if (Array.isArray(part.parts))
|
|
179
|
+
for (const child of part.parts)
|
|
180
|
+
texts.push(this.bodyText(child));
|
|
181
|
+
return texts.join('\n');
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
|
185
|
+
/** Base64 without `Buffer`, so the sender stays portable. */
|
|
186
|
+
function encodeBase64(bytes) {
|
|
187
|
+
let out = '';
|
|
188
|
+
for (let i = 0; i < bytes.length; i += 3) {
|
|
189
|
+
const n = bytes[i] << 16 | (bytes[i + 1] ?? 0) << 8 | (bytes[i + 2] ?? 0);
|
|
190
|
+
out += B64[n >> 18 & 63] + B64[n >> 12 & 63] + (i + 1 < bytes.length ? B64[n >> 6 & 63] : '=') + (i + 2 < bytes.length ? B64[n & 63] : '=');
|
|
191
|
+
}
|
|
192
|
+
return out;
|
|
193
|
+
}
|
|
194
|
+
const utf8 = (text) => new TextEncoder().encode(text);
|
|
195
|
+
const ADDRESS = /^[^\s@<>()[\]\\,;:"]+@[^\s@<>()[\]\\,;:"]+\.[^\s@<>()[\]\\,;:".]+$/;
|
|
196
|
+
/** Validates and freezes one draft; rejects header injection and anything but bare addresses. */
|
|
197
|
+
function outgoing(draft) {
|
|
198
|
+
if (!isRecord(draft))
|
|
199
|
+
throw new TypeError('mail send takes one draft object');
|
|
200
|
+
const { to: rawTo, subject, body } = draft;
|
|
201
|
+
// Copy first, then check the copy: the approval and the send see exactly what was checked.
|
|
202
|
+
const to = typeof rawTo === 'string' ? [rawTo] : Array.isArray(rawTo) ? Array.from(rawTo) : [];
|
|
203
|
+
if (!to.length || !to.every(a => typeof a === 'string' && ADDRESS.test(a)))
|
|
204
|
+
throw new TypeError('mail to must be one or more bare addresses');
|
|
205
|
+
if (typeof subject !== 'string' || /[\r\n]/.test(subject))
|
|
206
|
+
throw new TypeError('mail subject must be one line of text');
|
|
207
|
+
if (typeof body !== 'string')
|
|
208
|
+
throw new TypeError('mail body must be text');
|
|
209
|
+
return Object.freeze({ to: Object.freeze(to), subject, body });
|
|
210
|
+
}
|
|
211
|
+
/** RFC 2047 encoded words of at most 45 bytes each, so no word splits a character. Plain only when
|
|
212
|
+
* a reader cannot decode it differently (`=?`) and the line stays short. */
|
|
213
|
+
function headerText(text) {
|
|
214
|
+
if (/^[\x20-\x7e]*$/.test(text) && !text.includes('=?') && text.length <= 900)
|
|
215
|
+
return text;
|
|
216
|
+
const words = [];
|
|
217
|
+
let chunk = '';
|
|
218
|
+
for (const ch of text) {
|
|
219
|
+
if (utf8(chunk + ch).length > 45) {
|
|
220
|
+
words.push(chunk);
|
|
221
|
+
chunk = '';
|
|
222
|
+
}
|
|
223
|
+
chunk += ch;
|
|
224
|
+
}
|
|
225
|
+
words.push(chunk);
|
|
226
|
+
return words.map(w => `=?UTF-8?B?${encodeBase64(utf8(w))}?=`).join('\r\n ');
|
|
227
|
+
}
|
|
228
|
+
function mime(mail) {
|
|
229
|
+
const body = encodeBase64(utf8(mail.body.replace(/\r?\n/g, '\r\n'))).replace(/.{76}/g, '$&\r\n');
|
|
230
|
+
return [`To: ${mail.to.join(', ')}`, `Subject: ${headerText(mail.subject)}`, 'MIME-Version: 1.0',
|
|
231
|
+
'Content-Type: text/plain; charset=UTF-8', 'Content-Transfer-Encoding: base64', '', body].join('\r\n');
|
|
232
|
+
}
|
|
233
|
+
/** Sends one message at a time from the signed-in Gmail, each only after its own approval. Needs `gmail.send`. */
|
|
234
|
+
export class MailSender extends Gmail {
|
|
235
|
+
approve;
|
|
236
|
+
constructor(credential, approve, options = {}) {
|
|
237
|
+
super('MailSender', credential, options);
|
|
238
|
+
if (typeof approve !== 'function')
|
|
239
|
+
throw new TypeError('MailSender needs an approval function');
|
|
240
|
+
this.approve = approve;
|
|
241
|
+
}
|
|
242
|
+
/** Asks the approval with the frozen message, then sends it once. Never retried: a failed send asks again. */
|
|
243
|
+
async send(draft, options = {}) {
|
|
244
|
+
const mail = outgoing(draft);
|
|
245
|
+
options.signal?.throwIfAborted();
|
|
246
|
+
let approved;
|
|
247
|
+
try {
|
|
248
|
+
approved = await this.approve(mail);
|
|
249
|
+
}
|
|
250
|
+
catch (cause) {
|
|
251
|
+
throw new MailError('denied', 'mail send was not approved', { cause });
|
|
252
|
+
}
|
|
253
|
+
if (approved !== true)
|
|
254
|
+
throw new MailError('denied', 'mail send was not approved');
|
|
255
|
+
const raw = encodeBase64(utf8(mime(mail))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
256
|
+
const json = await this.call('/messages/send', {}, options.signal, { raw });
|
|
257
|
+
if (!isRecord(json))
|
|
258
|
+
throw mailShape('sent');
|
|
259
|
+
const labels = json.labelIds;
|
|
260
|
+
return { id: field(json.id, 'id'), threadId: field(json.threadId, 'threadId'),
|
|
261
|
+
labelIds: Array.isArray(labels) && labels.every(v => typeof v === 'string') ? [...labels] : [] };
|
|
262
|
+
}
|
|
263
|
+
}
|
package/dist/node.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type Connection } from './connect.ts';
|
|
2
|
-
import type { ConnectOptions, Provider } from './types.ts';
|
|
2
|
+
import type { ConnectOptions, OAuthClient, Provider } from './types.ts';
|
|
3
3
|
import type { ProviderId } from './providers.ts';
|
|
4
4
|
export interface LoopbackOptions extends Omit<ConnectOptions, 'redirectUri'> {
|
|
5
5
|
/** Host opens the browser; BYOKit never spawns the person's browser or CLI. */
|
|
@@ -11,3 +11,6 @@ export declare function connectLoopback(target: ProviderId | Provider | string,
|
|
|
11
11
|
done: Promise<void>;
|
|
12
12
|
cancel(): void;
|
|
13
13
|
}>;
|
|
14
|
+
/** Reads the house Google client: the Desktop-app JSON downloaded from Google, at the path the host names.
|
|
15
|
+
* Absent file is `null` (Google not set up); a file other users can read, a non-regular file, or any other client type, is refused. */
|
|
16
|
+
export declare function googleClientFile(path: string): Promise<OAuthClient | null>;
|
package/dist/node.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import WORDS from './words.json' with { type: 'json' };
|
|
2
2
|
import { createServer } from 'node:http';
|
|
3
|
+
import { constants } from 'node:fs';
|
|
4
|
+
import { open } from 'node:fs/promises';
|
|
3
5
|
import { connect } from "./connect.js";
|
|
4
6
|
import { ConnectError } from "./errors.js";
|
|
5
7
|
/** One-shot loopback listener on an OS-assigned port; closes on completion, cancel or timeout. */
|
|
@@ -87,3 +89,38 @@ export async function connectLoopback(target, options) {
|
|
|
87
89
|
throw error;
|
|
88
90
|
}
|
|
89
91
|
}
|
|
92
|
+
/** Reads the house Google client: the Desktop-app JSON downloaded from Google, at the path the host names.
|
|
93
|
+
* Absent file is `null` (Google not set up); a file other users can read, a non-regular file, or any other client type, is refused. */
|
|
94
|
+
export async function googleClientFile(path) {
|
|
95
|
+
let file;
|
|
96
|
+
try {
|
|
97
|
+
file = await open(path, constants.O_RDONLY | constants.O_NONBLOCK);
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
if (error.code === 'ENOENT')
|
|
101
|
+
return null;
|
|
102
|
+
throw error;
|
|
103
|
+
}
|
|
104
|
+
try {
|
|
105
|
+
const info = await file.stat();
|
|
106
|
+
// Non-blocking open: a FIFO at the path is refused here instead of hanging.
|
|
107
|
+
if (!info.isFile())
|
|
108
|
+
throw new Error(WORDS.clientFile.shape);
|
|
109
|
+
if (process.platform !== 'win32' && info.mode & 0o077)
|
|
110
|
+
throw new Error(WORDS.clientFile.mode);
|
|
111
|
+
let installed;
|
|
112
|
+
try {
|
|
113
|
+
installed = JSON.parse(await file.readFile('utf8'))?.installed;
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
throw new Error(WORDS.clientFile.shape);
|
|
117
|
+
}
|
|
118
|
+
const id = installed?.client_id, secret = installed?.client_secret;
|
|
119
|
+
if (typeof id !== 'string' || !/^[\w-]+\.apps\.googleusercontent\.com$/.test(id) || typeof secret !== 'string' || !secret)
|
|
120
|
+
throw new Error(WORDS.clientFile.shape);
|
|
121
|
+
return { id, secret };
|
|
122
|
+
}
|
|
123
|
+
finally {
|
|
124
|
+
await file.close();
|
|
125
|
+
}
|
|
126
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -53,3 +53,26 @@ export interface McpOptions {
|
|
|
53
53
|
/** Register sampling/elicitation/request/notification handlers before connecting. */
|
|
54
54
|
configure?: (client: import('@modelcontextprotocol/sdk/client/index.js').Client) => void;
|
|
55
55
|
}
|
|
56
|
+
/** Trusted app data only: never display or log credentials. Times are Unix milliseconds. */
|
|
57
|
+
export interface Grant {
|
|
58
|
+
accessToken: string;
|
|
59
|
+
refreshToken?: string;
|
|
60
|
+
expiresAt?: number;
|
|
61
|
+
scope?: string;
|
|
62
|
+
/** The provider's refresh_token_expires_in value, in seconds. */
|
|
63
|
+
refreshTokenExpiresIn?: number;
|
|
64
|
+
/** Absolute deadline; unlike the duration, this does not restart on restore. */
|
|
65
|
+
refreshTokenExpiresAt?: number;
|
|
66
|
+
}
|
|
67
|
+
export type ClientVerification = {
|
|
68
|
+
outcome: 'valid';
|
|
69
|
+
message: string;
|
|
70
|
+
} | {
|
|
71
|
+
outcome: 'invalid';
|
|
72
|
+
message: string;
|
|
73
|
+
cause?: import('./errors.ts').ProviderErrorCause;
|
|
74
|
+
} | {
|
|
75
|
+
outcome: 'inconclusive';
|
|
76
|
+
message: string;
|
|
77
|
+
cause?: import('./errors.ts').ProviderErrorCause;
|
|
78
|
+
};
|
package/dist/words.json
CHANGED
|
@@ -17,5 +17,14 @@
|
|
|
17
17
|
"finishing": "Sign-in is already finishing. Return to the app.",
|
|
18
18
|
"connected": "Connected. You can return to the app.",
|
|
19
19
|
"failed": "Sign-in did not finish. Return to the app and try again."
|
|
20
|
+
},
|
|
21
|
+
"clientFile": {
|
|
22
|
+
"mode": "Your Google app file can be read by other people on this computer. Make it private to you.",
|
|
23
|
+
"shape": "Your Google app file is not a Desktop app downloaded from Google. Download it again."
|
|
24
|
+
},
|
|
25
|
+
"verification": {
|
|
26
|
+
"valid": "Your app details work.",
|
|
27
|
+
"invalid": "Check your app details and try again.",
|
|
28
|
+
"inconclusive": "Your app details could not be checked. Try again later."
|
|
20
29
|
}
|
|
21
30
|
}
|