@byokit/connect 0.2.0 → 0.4.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 CHANGED
@@ -2,6 +2,19 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.4.0 (2026-10-12)
6
+
7
+ - Dependency update: pins @byokit/secrets 0.5.1.
8
+
9
+ - FEAT: One documented house Google client route: `googleClientFile(path)` in `@byokit/connect/node` reads Google's downloaded Desktop-app JSON (mode 600) for Gmail and every other Google preset, returns `null` when it is absent, and refuses a file other users can read, a non-regular file, or another client type.
10
+
11
+ ## 0.3.0 (2026-10-06)
12
+
13
+ - 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.
14
+
15
+ - Add typed Gmail read and search (`MailReader`) on a connection: envelopes, one bounded body, opaque page tokens.
16
+ - Add owner-bound mail history and export (`MailHistory`): cross-page envelopes and `json`/`csv` export, foreign principals denied before any fetch.
17
+
5
18
  ## 0.2.0 (2026-10-01)
6
19
 
7
20
  - FIX: Preserve provider refresh-token lifetimes and sanitized OAuth error causes for setup guidance.
package/README.md CHANGED
@@ -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,44 @@ 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
+
63
101
  ## Check app details and inspect grant lifetime
64
102
 
65
103
  ```ts
@@ -151,6 +189,60 @@ A 401 triggers one refresh/retry; redirects are refused. Close clients before
151
189
  `disconnect()` to release their streams. Arbitrary unauthenticated MCP servers and
152
190
  legacy HTTP+SSE transports are outside this sign-in kit's initial scope.
153
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
+
154
246
  ## Refresh, errors and storage
155
247
 
156
248
  Refresh runs when the token is within one minute of expiry, or after a 401. It is
@@ -221,13 +313,13 @@ the connection handle; never persist its returned access token separately.
221
313
  ## Crewhouse adoption
222
314
 
223
315
  Replace the OAuth, token-file and `RemoteMcp` portions of `src/connections.ts` with
224
- one handle per member/app. Pass the member's keystore and member ID, the host's
225
- registered Google client when applicable, and Crewhouse's callback address. Keep
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
226
318
  flow handles in the host's callback routing table until completion/cancellation.
227
319
  Crewhouse retains its own connection screen, Google setup guidance and tool gates.
228
320
  For remote apps, call `mcp()` and use `listTools`/`callTool` with their full results;
229
- for Gmail/Calendar use `token()` until their typed kits sit on this connection.
230
- 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.
231
323
 
232
324
  ## Verification
233
325
 
@@ -237,8 +329,11 @@ npm run check
237
329
  sh scripts/test.sh 'packages/connect/test/*.test.ts'
238
330
  ```
239
331
 
240
- Tests use fake providers and a real loopback callback only. They cover PKCE/state,
241
- replay/cancel/expiry, discovery/DCR/resource binding, refresh races and revocation,
242
- per-person isolation, rich MCP JSON/SSE responses and browser bundling. No real
243
- accounts or outbound network calls are used. Live provider/device sign-ins are not
244
- claimed by these tests.
332
+ `packages/connect/test/journey-connect.test.ts` drives the built package the way a
333
+ host app does — `connect`/`signIn`/`token`/`mcp`, the loopback listener, the house
334
+ Google client, `MailReader`/`MailSender` and `MailHistory`. Fake providers and a real
335
+ loopback callback only. It covers PKCE/state and exact-callback binding, per-person
336
+ isolation, refresh races and revocation, discovery/DCR/resource binding, rich MCP
337
+ JSON/SSE responses, the approved/denied send, owner-bound history and browser
338
+ bundling. No real accounts or outbound network calls are used. Live
339
+ provider/device sign-ins are not claimed by these tests.
package/SECURITY.md CHANGED
@@ -2,7 +2,8 @@
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. No
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.
@@ -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
+ }
@@ -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,6 +2,10 @@ 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 { 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';
5
9
  export type { ConnectErrorCode, ProviderErrorCause } from './errors.ts';
6
10
  export type { Provider, OAuthClient, OAuthEndpoints, ConnectOptions, SignIn, McpOptions, Grant, ClientVerification } from './types.ts';
7
11
  export { Client } from '@modelcontextprotocol/sdk/client/index.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/words.json CHANGED
@@ -18,6 +18,10 @@
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
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
+ },
21
25
  "verification": {
22
26
  "valid": "Your app details work.",
23
27
  "invalid": "Check your app details and try again.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@byokit/connect",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Connect a person's third-party apps with PKCE, refresh and typed remote MCP.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -37,6 +37,6 @@
37
37
  },
38
38
  "dependencies": {
39
39
  "@modelcontextprotocol/sdk": "1.31.0",
40
- "@byokit/secrets": "0.5.0"
40
+ "@byokit/secrets": "0.5.1"
41
41
  }
42
42
  }