@byokit/connect 0.4.0 → 0.5.1
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 +8 -0
- package/README.md +72 -3
- package/dist/catalog.d.ts +26 -0
- package/dist/catalog.js +76 -0
- package/dist/connect.d.ts +5 -1
- package/dist/connect.js +77 -7
- package/dist/discovery.js +2 -2
- package/dist/errors.d.ts +1 -1
- package/dist/errors.js +1 -1
- package/dist/history.js +1 -1
- package/dist/http.js +1 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.js +6 -5
- package/dist/node.js +2 -2
- package/dist/providers.d.ts +23 -15
- package/dist/providers.js +1 -0
- package/dist/types.d.ts +17 -2
- package/dist/words.json +3 -2
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.5.1 (2026-10-17)
|
|
6
|
+
|
|
7
|
+
- Dependency update: pins @byokit/secrets 0.5.2.
|
|
8
|
+
|
|
9
|
+
## 0.5.0 (2026-10-17)
|
|
10
|
+
|
|
11
|
+
- FEAT: `deviceSignIn(provider, options)` connects a person with an RFC 8628 device code: it returns `{ userCode, verificationUri, expiresAt, finish(), cancel() }`, polls with the app's own OAuth client ID (pending waits, `slow_down` adds 5 s), and stores the grant in the person's keystore only when they approve. An expired code rejects with `ConnectError` `expired`; a person cancelling on the provider's page, or `cancel()`, rejects with `declined`, and neither stores anything. New `github` provider (`device` endpoints on github.com) and `Provider.device`; `ConnectOptions.redirectUri` is now optional (`signIn()` still requires it). RFC 8628 error codes stay readable in `ConnectError.cause`. The `expired` message now reads "This sign-in has expired. Start again."
|
|
12
|
+
|
|
5
13
|
## 0.4.0 (2026-10-12)
|
|
6
14
|
|
|
7
15
|
- Dependency update: pins @byokit/secrets 0.5.1.
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @byokit/connect
|
|
2
2
|
|
|
3
|
-
Connect a person's
|
|
3
|
+
Connect a person's online files, mail, calendar, notes, designs, code or other remote account.
|
|
4
4
|
Sign-in opens the provider's own page. Credentials stay in the keystore the app
|
|
5
5
|
passes, scoped to that person on their device. There is no account pool, proxy,
|
|
6
6
|
ambient credential lookup, logging or extra opt-in switch.
|
|
@@ -129,6 +129,35 @@ sanitized diagnostic `cause` as `ConnectError`. A secret is required. This check
|
|
|
129
129
|
client authentication only; it does not check consent-screen publishing, enabled
|
|
130
130
|
APIs, redirect registration or scopes. Tests use injected fake endpoints only.
|
|
131
131
|
|
|
132
|
+
## Connect GitHub with a device code
|
|
133
|
+
|
|
134
|
+
```ts
|
|
135
|
+
import { connect, deviceSignIn } from '@byokit/connect';
|
|
136
|
+
import type { Keystore } from '@byokit/secrets';
|
|
137
|
+
|
|
138
|
+
declare const store: Keystore;
|
|
139
|
+
const options = { store, person: 'Umer', client: { id: 'your-github-oauth-app-client-id' }, scopes: ['repo'] };
|
|
140
|
+
const flow = await deviceSignIn('github', options);
|
|
141
|
+
// Show flow.userCode and flow.verificationUri (github.com/login/device);
|
|
142
|
+
// the person types the code there on any device.
|
|
143
|
+
await flow.finish(); // Resolves once they approve; call flow.cancel() when the sheet closes.
|
|
144
|
+
|
|
145
|
+
const github = connect('github', options);
|
|
146
|
+
await github.connected(); // true
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
`deviceSignIn(provider, options)` starts an RFC 8628 device sign-in and returns
|
|
150
|
+
`{ userCode, verificationUri, expiresAt, finish, cancel }`; `expiresAt` is Unix
|
|
151
|
+
milliseconds. It needs the app's own OAuth client ID with device flow enabled;
|
|
152
|
+
there is no house GitHub client, and no `redirectUri`. `finish()` polls at the
|
|
153
|
+
provider's interval, adds 5 seconds on `slow_down`, and keeps waiting through a
|
|
154
|
+
poll that cannot get through. Only an approved code stores a grant, in the same
|
|
155
|
+
keystore record `connect()` reads, so `connected()`, `grant()` and `token()`
|
|
156
|
+
work afterwards. An expired code rejects with `ConnectError` code `expired`; a
|
|
157
|
+
person pressing Cancel on GitHub, or `cancel()`, rejects with `declined`. Both
|
|
158
|
+
store nothing. Any provider with `device: { code, token }` endpoints works the
|
|
159
|
+
same way.
|
|
160
|
+
|
|
132
161
|
## Loopback on a computer
|
|
133
162
|
|
|
134
163
|
```ts
|
|
@@ -189,6 +218,44 @@ A 401 triggers one refresh/retry; redirects are refused. Close clients before
|
|
|
189
218
|
`disconnect()` to release their streams. Arbitrary unauthenticated MCP servers and
|
|
190
219
|
legacy HTTP+SSE transports are outside this sign-in kit's initial scope.
|
|
191
220
|
|
|
221
|
+
## List apps to connect
|
|
222
|
+
|
|
223
|
+
```ts
|
|
224
|
+
import { catalog, connect, knownApps, mcpDirectory } from '@byokit/connect';
|
|
225
|
+
import type { Keystore } from '@byokit/secrets';
|
|
226
|
+
|
|
227
|
+
declare const store: Keystore;
|
|
228
|
+
const apps = await catalog([knownApps, mcpDirectory('https://directory.your-app.example/v0/servers?version=latest')]);
|
|
229
|
+
const app = apps[0]; // { id, name, icon?, auth, ...the Provider connect() takes }
|
|
230
|
+
const connection = connect(app, { store, person: 'Umer', redirectUri: 'https://your-app.example/connect/callback' });
|
|
231
|
+
const flow = await connection.signIn(); // Or connectLoopback(app, ...); deviceSignIn(app, ...) when app.auth is 'device'.
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
`catalog(sources)` lists the apps of the sources the host picks, in that order; an
|
|
235
|
+
id listed twice keeps its first entry. Nothing is listed, and nothing is read, by
|
|
236
|
+
default. Every listed app is a `Provider`, so it signs in through the same
|
|
237
|
+
`connect()`, `connectLoopback()` and `deviceSignIn()` as above, under the same
|
|
238
|
+
keystore record as the preset of that id. `auth` says how: `oauth` needs the app's
|
|
239
|
+
registered `client`, `discovery` finds sign-in from the app's MCP server and
|
|
240
|
+
registers itself where offered, `device` shows a typed code. `icon` is an HTTPS
|
|
241
|
+
image address from the listing; render it as untrusted content.
|
|
242
|
+
|
|
243
|
+
- `knownApps` lists the kit's presets (`providers`) and reads nothing.
|
|
244
|
+
- `mcpDirectory(url, { fetch?, timeoutMs? })` reads a remote MCP directory that
|
|
245
|
+
answers in the MCP registry's `GET /v0/servers` shape, following
|
|
246
|
+
`metadata.nextCursor` (at most 100 pages), only when listed. It keeps servers
|
|
247
|
+
with a streamable HTTP remote and no address variables or required headers;
|
|
248
|
+
local packages, legacy SSE remotes and key-header servers are left out. The URL
|
|
249
|
+
must be HTTPS (or loopback); for the MCP registry add `version=latest`.
|
|
250
|
+
- Any object with `list(): Promise<CatalogApp[]>` is a source, such as the host's
|
|
251
|
+
own managed connector directory behind its server.
|
|
252
|
+
|
|
253
|
+
A directory that fails or answers in another shape rejects with `ConnectError`
|
|
254
|
+
code `catalog` (with its `status`), an unreachable one with `network`; the whole
|
|
255
|
+
`catalog()` call rejects with it, so list a source on its own to keep the rest.
|
|
256
|
+
The host vouches for the directories it names: signing in to a listed app talks
|
|
257
|
+
to the server that listing names.
|
|
258
|
+
|
|
192
259
|
## Read and search Gmail
|
|
193
260
|
|
|
194
261
|
```ts
|
|
@@ -313,7 +380,8 @@ the connection handle; never persist its returned access token separately.
|
|
|
313
380
|
## Crewhouse adoption
|
|
314
381
|
|
|
315
382
|
Replace the OAuth, token-file and `RemoteMcp` portions of `src/connections.ts` with
|
|
316
|
-
one handle per member/app
|
|
383
|
+
one handle per member/app, listing the apps from `catalog([knownApps])` (add `mcpDirectory(url)`
|
|
384
|
+
or the host's own source to list more). Pass the member's keystore and member ID, the house Google
|
|
317
385
|
client from `googleClientFile` when applicable, and Crewhouse's callback address. Keep
|
|
318
386
|
flow handles in the host's callback routing table until completion/cancellation.
|
|
319
387
|
Crewhouse retains its own connection screen, Google setup guidance and tool gates.
|
|
@@ -330,7 +398,8 @@ sh scripts/test.sh 'packages/connect/test/*.test.ts'
|
|
|
330
398
|
```
|
|
331
399
|
|
|
332
400
|
`packages/connect/test/journey-connect.test.ts` drives the built package the way a
|
|
333
|
-
host app does — `connect`/`signIn`/`token`/`mcp`, the
|
|
401
|
+
host app does — `connect`/`signIn`/`deviceSignIn`/`token`/`mcp`, `catalog` over the known apps and a
|
|
402
|
+
loopback MCP directory (one listed app connected end to end), the loopback listener, the house
|
|
334
403
|
Google client, `MailReader`/`MailSender` and `MailHistory`. Fake providers and a real
|
|
335
404
|
loopback callback only. It covers PKCE/state and exact-callback binding, per-person
|
|
336
405
|
isolation, refresh races and revocation, discovery/DCR/resource binding, rich MCP
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Provider } from './types.ts';
|
|
2
|
+
/** How a listed app signs in: `oauth` with the app's own registered client, `discovery` from its MCP server
|
|
3
|
+
* (registering itself where the server offers it), `device` with a typed code and the app's own client. */
|
|
4
|
+
export type CatalogAuth = 'oauth' | 'discovery' | 'device';
|
|
5
|
+
/** A listed app is a `Provider`: pass it to `connect()`, `connectLoopback()` or, for `device`, `deviceSignIn()`. */
|
|
6
|
+
export interface CatalogApp extends Provider {
|
|
7
|
+
/** An HTTPS (or loopback) image address from the listing; render it as untrusted content. */
|
|
8
|
+
icon?: string;
|
|
9
|
+
auth: CatalogAuth;
|
|
10
|
+
}
|
|
11
|
+
/** Any source of apps a host can list; the kit ships `knownApps` and `mcpDirectory(url)`. */
|
|
12
|
+
export interface CatalogProvider {
|
|
13
|
+
list(): Promise<readonly CatalogApp[]>;
|
|
14
|
+
}
|
|
15
|
+
export interface DirectoryOptions {
|
|
16
|
+
fetch?: typeof fetch;
|
|
17
|
+
timeoutMs?: number;
|
|
18
|
+
}
|
|
19
|
+
/** The kit's hand-known apps (`providers`); reads nothing. */
|
|
20
|
+
export declare const knownApps: CatalogProvider;
|
|
21
|
+
/** A remote MCP directory at the caller's HTTPS (or loopback) URL, answering in the MCP registry's `GET /v0/servers`
|
|
22
|
+
* shape and paged by `metadata.nextCursor`. Nothing is read until `list()`. The caller vouches for the directory:
|
|
23
|
+
* signing in to a listed app talks to the server it names. */
|
|
24
|
+
export declare function mcpDirectory(url: string, options?: DirectoryOptions): CatalogProvider;
|
|
25
|
+
/** Lists the apps of the providers the caller picks, in their order; an id listed twice keeps its first entry. */
|
|
26
|
+
export declare function catalog(sources: readonly CatalogProvider[]): Promise<CatalogApp[]>;
|
package/dist/catalog.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { ConnectError } from './errors.js';
|
|
2
|
+
import { endpoint, json, request } from './http.js';
|
|
3
|
+
import { providers } from './providers.js';
|
|
4
|
+
function listed(provider, icon) {
|
|
5
|
+
return { ...provider, ...(icon ? { icon } : {}), auth: provider.device ? 'device' : provider.oauth ? 'oauth' : 'discovery' };
|
|
6
|
+
}
|
|
7
|
+
function web(raw) {
|
|
8
|
+
if (typeof raw !== 'string')
|
|
9
|
+
return undefined;
|
|
10
|
+
try {
|
|
11
|
+
return endpoint(raw).href;
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return undefined;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/** One MCP registry `server.json` entry, kept only when the kit can sign in to it: a streamable HTTP remote with no
|
|
18
|
+
* address variables or required headers for the person to fill in. */
|
|
19
|
+
function fromServer(server) {
|
|
20
|
+
if (!server || typeof server !== 'object')
|
|
21
|
+
return undefined;
|
|
22
|
+
const { name, title, remotes, icons } = server;
|
|
23
|
+
if (typeof name !== 'string' || !name)
|
|
24
|
+
return undefined;
|
|
25
|
+
const remote = Array.isArray(remotes) ? remotes.find(r => r?.type === 'streamable-http' && typeof r.url === 'string' && !r.url.includes('{') &&
|
|
26
|
+
!(Array.isArray(r.headers) && r.headers.some((h) => h?.isRequired))) : undefined;
|
|
27
|
+
const mcpUrl = web(remote?.url);
|
|
28
|
+
if (!mcpUrl)
|
|
29
|
+
return undefined;
|
|
30
|
+
const icon = Array.isArray(icons) ? icons.map(i => web(i?.src)).find(Boolean) : undefined;
|
|
31
|
+
return listed({ id: name, name: typeof title === 'string' && title ? title : name, mcpUrl }, icon);
|
|
32
|
+
}
|
|
33
|
+
/** The kit's hand-known apps (`providers`); reads nothing. */
|
|
34
|
+
export const knownApps = { list: async () => Object.values(providers).map(provider => listed(provider)) };
|
|
35
|
+
/** A remote MCP directory at the caller's HTTPS (or loopback) URL, answering in the MCP registry's `GET /v0/servers`
|
|
36
|
+
* shape and paged by `metadata.nextCursor`. Nothing is read until `list()`. The caller vouches for the directory:
|
|
37
|
+
* signing in to a listed app talks to the server it names. */
|
|
38
|
+
export function mcpDirectory(url, options = {}) {
|
|
39
|
+
const base = endpoint(url);
|
|
40
|
+
return { list: async () => {
|
|
41
|
+
const apps = [];
|
|
42
|
+
let cursor;
|
|
43
|
+
// ponytail: at most 100 pages per list(), so a directory that keeps paging stays finite; raise it if a real directory outgrows it.
|
|
44
|
+
for (let page = 0; page < 100; page++) {
|
|
45
|
+
const next = new URL(base);
|
|
46
|
+
if (cursor)
|
|
47
|
+
next.searchParams.set('cursor', cursor);
|
|
48
|
+
const response = await request(options.fetch ?? globalThis.fetch, next, { headers: { accept: 'application/json' } }, options.timeoutMs ?? 20_000);
|
|
49
|
+
if (!response.ok) {
|
|
50
|
+
await response.body?.cancel();
|
|
51
|
+
throw new ConnectError('catalog', response.status);
|
|
52
|
+
}
|
|
53
|
+
const body = await json(response, 'catalog');
|
|
54
|
+
if (!Array.isArray(body.servers))
|
|
55
|
+
throw new ConnectError('catalog', response.status);
|
|
56
|
+
for (const entry of body.servers) {
|
|
57
|
+
const app = fromServer(entry?.server);
|
|
58
|
+
if (app)
|
|
59
|
+
apps.push(app);
|
|
60
|
+
}
|
|
61
|
+
const more = body.metadata?.nextCursor;
|
|
62
|
+
if (typeof more !== 'string' || !more || more === cursor)
|
|
63
|
+
break;
|
|
64
|
+
cursor = more;
|
|
65
|
+
}
|
|
66
|
+
return apps;
|
|
67
|
+
} };
|
|
68
|
+
}
|
|
69
|
+
/** Lists the apps of the providers the caller picks, in their order; an id listed twice keeps its first entry. */
|
|
70
|
+
export async function catalog(sources) {
|
|
71
|
+
const apps = new Map();
|
|
72
|
+
for (const app of (await Promise.all(sources.map(source => source.list()))).flat())
|
|
73
|
+
if (!apps.has(app.id))
|
|
74
|
+
apps.set(app.id, app);
|
|
75
|
+
return [...apps.values()];
|
|
76
|
+
}
|
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, OAuthClient, Provider, SignIn, McpOptions, Grant, ClientVerification } from './types.ts';
|
|
3
|
+
import type { ConnectOptions, OAuthClient, Provider, SignIn, DeviceSignIn, 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>;
|
|
@@ -23,6 +23,9 @@ export declare class Connection {
|
|
|
23
23
|
disconnect(): Promise<void>;
|
|
24
24
|
/** Starts a fresh browser sign-in. No opt-in flag or server-held account is required. */
|
|
25
25
|
signIn(): Promise<SignIn>;
|
|
26
|
+
/** RFC 8628 device sign-in with the app's own client: show `userCode` at `verificationUri`, then await `finish()`.
|
|
27
|
+
* The poll follows the token endpoint: pending waits, slow_down adds 5 s, and an unreachable poll waits for the next. */
|
|
28
|
+
deviceSignIn(): Promise<DeviceSignIn>;
|
|
26
29
|
private register;
|
|
27
30
|
private exchange;
|
|
28
31
|
/** For trusted app code (mail/calendar/API calls). Never display or log this value. */
|
|
@@ -31,3 +34,4 @@ export declare class Connection {
|
|
|
31
34
|
mcp(options?: McpOptions): Promise<Client>;
|
|
32
35
|
}
|
|
33
36
|
export declare function connect(provider: ProviderId | Provider | string, options: ConnectOptions): Connection;
|
|
37
|
+
export declare function deviceSignIn(provider: ProviderId | Provider, options: ConnectOptions): Promise<DeviceSignIn>;
|
package/dist/connect.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import WORDS from './words.json' with { type: 'json' };
|
|
2
2
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
3
3
|
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
|
4
|
-
import { ConnectError, providerCause } from
|
|
5
|
-
import { discover } from
|
|
6
|
-
import { endpoint, redirect, request, json } from
|
|
7
|
-
import { providers } from
|
|
4
|
+
import { ConnectError, providerCause } from './errors.js';
|
|
5
|
+
import { discover } from './discovery.js';
|
|
6
|
+
import { endpoint, redirect, request, json } from './http.js';
|
|
7
|
+
import { providers } from './providers.js';
|
|
8
8
|
// Across handles, only the same store + person + connection share work. No global token cache.
|
|
9
9
|
const stores = new WeakMap();
|
|
10
10
|
function slotFor(store, key) {
|
|
@@ -35,6 +35,9 @@ function validClient(client) {
|
|
|
35
35
|
throw new ConnectError('configuration');
|
|
36
36
|
return { id: client.id, secret: client.secret, authMethod: client.authMethod };
|
|
37
37
|
}
|
|
38
|
+
function wait(ms, signal) {
|
|
39
|
+
return new Promise(resolve => { const timer = setTimeout(resolve, ms); signal.addEventListener('abort', () => { clearTimeout(timer); resolve(); }, { once: true }); });
|
|
40
|
+
}
|
|
38
41
|
function validTime(value) { return value === undefined || (typeof value === 'number' && Number.isFinite(value) && value >= 0); }
|
|
39
42
|
/** One person's connection on one device; the host owns the store and browser. */
|
|
40
43
|
export class Connection {
|
|
@@ -49,16 +52,18 @@ export class Connection {
|
|
|
49
52
|
: target;
|
|
50
53
|
this.provider = Object.freeze({ ...provider,
|
|
51
54
|
oauth: provider.oauth && Object.freeze({ ...provider.oauth }),
|
|
55
|
+
device: provider.device && Object.freeze({ ...provider.device }),
|
|
52
56
|
scopes: provider.scopes && Object.freeze([...provider.scopes]),
|
|
53
57
|
extra: provider.extra && Object.freeze({ ...provider.extra }),
|
|
54
58
|
});
|
|
55
59
|
if (!options.person || !this.provider.id)
|
|
56
60
|
throw new ConnectError('configuration');
|
|
57
|
-
|
|
61
|
+
if (options.redirectUri !== undefined)
|
|
62
|
+
redirect(options.redirectUri);
|
|
58
63
|
if (this.provider.mcpUrl)
|
|
59
64
|
endpoint(this.provider.mcpUrl);
|
|
60
65
|
this.options = { ...options, scopes: options.scopes && Object.freeze([...options.scopes]), client: options.client && { ...options.client } };
|
|
61
|
-
this.key = `byokit.connect:${JSON.stringify([options.person, this.provider.id, this.provider.mcpUrl ?? '', this.provider.oauth?.token ?? this.provider.issuer ?? '', options.client?.id ?? ''])}`;
|
|
66
|
+
this.key = `byokit.connect:${JSON.stringify([options.person, this.provider.id, this.provider.mcpUrl ?? '', this.provider.oauth?.token ?? this.provider.device?.token ?? this.provider.issuer ?? '', options.client?.id ?? ''])}`;
|
|
62
67
|
this.slot = slotFor(options.store, this.key);
|
|
63
68
|
}
|
|
64
69
|
storeKey() {
|
|
@@ -120,6 +125,9 @@ export class Connection {
|
|
|
120
125
|
}
|
|
121
126
|
/** Starts a fresh browser sign-in. No opt-in flag or server-held account is required. */
|
|
122
127
|
async signIn() {
|
|
128
|
+
const uri = this.options.redirectUri;
|
|
129
|
+
if (!uri)
|
|
130
|
+
throw new ConnectError('configuration');
|
|
123
131
|
const epoch = ++this.slot.epoch;
|
|
124
132
|
const info = await discover(this.provider, this.fetcher, this.timeout);
|
|
125
133
|
const scopes = this.options.scopes ?? info.scopes;
|
|
@@ -128,7 +136,6 @@ export class Connection {
|
|
|
128
136
|
throw new ConnectError('configuration');
|
|
129
137
|
const state = random(), verifier = random();
|
|
130
138
|
const challenge = base64url(new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier))));
|
|
131
|
-
const uri = this.options.redirectUri;
|
|
132
139
|
const url = endpoint(info.endpoints.authorize);
|
|
133
140
|
const fields = { ...this.provider.extra, response_type: 'code', client_id: client.id, redirect_uri: uri, state,
|
|
134
141
|
code_challenge: challenge, code_challenge_method: 'S256', ...(info.resource ? { resource: info.resource } : {}) };
|
|
@@ -181,6 +188,68 @@ export class Connection {
|
|
|
181
188
|
});
|
|
182
189
|
} };
|
|
183
190
|
}
|
|
191
|
+
/** RFC 8628 device sign-in with the app's own client: show `userCode` at `verificationUri`, then await `finish()`.
|
|
192
|
+
* The poll follows the token endpoint: pending waits, slow_down adds 5 s, and an unreachable poll waits for the next. */
|
|
193
|
+
async deviceSignIn() {
|
|
194
|
+
const device = this.provider.device;
|
|
195
|
+
if (!device || !this.options.client)
|
|
196
|
+
throw new ConnectError('configuration');
|
|
197
|
+
const client = validClient(this.options.client);
|
|
198
|
+
const epoch = ++this.slot.epoch;
|
|
199
|
+
const scopes = this.options.scopes ?? this.provider.scopes ?? [];
|
|
200
|
+
const info = { endpoints: { authorize: endpoint(device.code).href, token: endpoint(device.token).href }, scopes };
|
|
201
|
+
const response = await request(this.fetcher, device.code, { method: 'POST',
|
|
202
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' },
|
|
203
|
+
body: new URLSearchParams({ client_id: client.id, ...(scopes.length ? { scope: scopes.join(' ') } : {}) }) }, this.timeout);
|
|
204
|
+
const m = await json(response, 'token');
|
|
205
|
+
if (!response.ok || m.error)
|
|
206
|
+
throw new ConnectError('token', response.status, providerCause(m, [client.id, client.secret ?? '']));
|
|
207
|
+
const lives = Number(m.expires_in), seconds = m.interval === undefined ? 5 : Number(m.interval);
|
|
208
|
+
if (typeof m.device_code !== 'string' || !m.device_code || typeof m.user_code !== 'string' || !m.user_code || typeof m.verification_uri !== 'string' ||
|
|
209
|
+
!Number.isFinite(lives) || lives <= 0 || !Number.isFinite(seconds) || seconds < 0)
|
|
210
|
+
throw new ConnectError('token');
|
|
211
|
+
const code = m.device_code, verificationUri = endpoint(m.verification_uri).href, expiresAt = this.now + lives * 1000;
|
|
212
|
+
const stop = new AbortController();
|
|
213
|
+
let interval = Math.max(1000, seconds * 1000), done;
|
|
214
|
+
const ended = () => stop.signal.aborted || epoch !== this.slot.epoch;
|
|
215
|
+
const poll = async () => {
|
|
216
|
+
for (;;) {
|
|
217
|
+
if (ended())
|
|
218
|
+
throw new ConnectError('declined');
|
|
219
|
+
if (this.now >= expiresAt)
|
|
220
|
+
throw new ConnectError('expired');
|
|
221
|
+
const tokens = await this.exchange(info, client, { grant_type: 'urn:ietf:params:oauth:grant-type:device_code', device_code: code }).catch((error) => {
|
|
222
|
+
if (!(error instanceof ConnectError))
|
|
223
|
+
throw error;
|
|
224
|
+
const answer = error.cause?.error;
|
|
225
|
+
if (answer === 'expired_token')
|
|
226
|
+
throw new ConnectError('expired', error.status, error.cause);
|
|
227
|
+
if (answer === 'access_denied')
|
|
228
|
+
throw new ConnectError('declined', error.status, error.cause);
|
|
229
|
+
if (answer === 'slow_down')
|
|
230
|
+
interval += 5000;
|
|
231
|
+
// A poll that cannot get through waits for the next, as a phone cuts a backgrounded app's network.
|
|
232
|
+
else if (answer !== 'authorization_pending' && error.code !== 'network')
|
|
233
|
+
throw error;
|
|
234
|
+
return undefined;
|
|
235
|
+
});
|
|
236
|
+
if (tokens)
|
|
237
|
+
return serial(this.slot, async () => {
|
|
238
|
+
if (ended())
|
|
239
|
+
throw new ConnectError('declined');
|
|
240
|
+
// GitHub lists granted scopes with commas; RFC 6749 with spaces.
|
|
241
|
+
if (tokens.scope !== undefined && !scopes.every(sc => tokens.scope.split(/[\s,]+/).includes(sc)))
|
|
242
|
+
throw new ConnectError('scope');
|
|
243
|
+
const key = await this.storeKey();
|
|
244
|
+
if (ended())
|
|
245
|
+
throw new ConnectError('declined');
|
|
246
|
+
await this.options.store.set(key, JSON.stringify({ ...info, client, tokens }));
|
|
247
|
+
});
|
|
248
|
+
await wait(interval, stop.signal);
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
return { userCode: m.user_code, verificationUri, expiresAt, finish: () => done ??= poll(), cancel: () => stop.abort() };
|
|
252
|
+
}
|
|
184
253
|
async register(info) {
|
|
185
254
|
if (!info.endpoints.register)
|
|
186
255
|
throw new ConnectError('configuration');
|
|
@@ -307,3 +376,4 @@ export class Connection {
|
|
|
307
376
|
}
|
|
308
377
|
}
|
|
309
378
|
export function connect(provider, options) { return new Connection(provider, options); }
|
|
379
|
+
export function deviceSignIn(provider, options) { return connect(provider, options).deviceSignIn(); }
|
package/dist/discovery.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { checkResourceAllowed } from '@modelcontextprotocol/sdk/shared/auth-utils.js';
|
|
2
|
-
import { ConnectError } from
|
|
3
|
-
import { endpoint, json, request, strings } from
|
|
2
|
+
import { ConnectError } from './errors.js';
|
|
3
|
+
import { endpoint, json, request, strings } from './http.js';
|
|
4
4
|
export async function discover(provider, fetcher, timeout) {
|
|
5
5
|
if (provider.oauth) {
|
|
6
6
|
for (const url of [provider.oauth.authorize, provider.oauth.token, provider.oauth.register].filter((x) => !!x))
|
package/dist/errors.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type ConnectErrorCode = 'configuration' | 'discovery' | 'registration' | 'callback' | 'expired' | 'declined' | 'scope' | 'token' | 'signin' | 'network';
|
|
1
|
+
export type ConnectErrorCode = 'configuration' | 'discovery' | 'registration' | 'callback' | 'expired' | 'declined' | 'scope' | 'token' | 'signin' | 'network' | 'catalog';
|
|
2
2
|
export interface ProviderErrorCause {
|
|
3
3
|
readonly error: string;
|
|
4
4
|
readonly error_description?: string;
|
package/dist/errors.js
CHANGED
|
@@ -17,7 +17,7 @@ export function providerCause(body, secrets) {
|
|
|
17
17
|
}
|
|
18
18
|
return text.replace(/https?:\/\/[^\s<>"']+/gi, '[redacted]').replace(/(?:Bearer|Basic)\s+[^\s,;]+/gi, '[redacted]');
|
|
19
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'];
|
|
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', 'authorization_pending', 'slow_down', 'expired_token'];
|
|
21
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
22
|
}
|
|
23
23
|
/** Plain message and optional sanitized OAuth cause; never includes raw response bodies. */
|
package/dist/history.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Owner-bound mail history and export. The credential (a Connection) stays the
|
|
2
2
|
// only authentication; every call names its principal and anything but the bound
|
|
3
3
|
// owner is denied before any provider fetch. This module never sends.
|
|
4
|
-
import { MailReader, MailError } from
|
|
4
|
+
import { MailReader, MailError } from './mail.js';
|
|
5
5
|
export class HistoryError extends Error {
|
|
6
6
|
code;
|
|
7
7
|
status;
|
package/dist/http.js
CHANGED
package/dist/index.d.ts
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
|
-
export { connect, Connection } from './connect.ts';
|
|
1
|
+
export { connect, deviceSignIn, Connection } from './connect.ts';
|
|
2
2
|
export { providers } from './providers.ts';
|
|
3
3
|
export type { ProviderId } from './providers.ts';
|
|
4
|
+
export { catalog, knownApps, mcpDirectory } from './catalog.ts';
|
|
5
|
+
export type { CatalogApp, CatalogAuth, CatalogProvider, DirectoryOptions } from './catalog.ts';
|
|
4
6
|
export { ConnectError } from './errors.ts';
|
|
5
7
|
export { MailReader, MailSender, MailError } from './mail.ts';
|
|
6
8
|
export { MailHistory, HistoryError } from './history.ts';
|
|
7
9
|
export type { HistoryFormat, HistoryErrorCode, MailHistoryOptions, HistoryQuery, HistoryExportQuery } from './history.ts';
|
|
8
10
|
export type { MailErrorCode, MailCredential, MailReaderOptions, MailEnvelope, MailBody, MailMessage, MailPage, MailListOptions, MailGetOptions, MailSenderOptions, MailDraft, OutgoingMail, MailApproval, MailSendOptions, SentMail } from './mail.ts';
|
|
9
11
|
export type { ConnectErrorCode, ProviderErrorCause } from './errors.ts';
|
|
10
|
-
export type { Provider, OAuthClient, OAuthEndpoints, ConnectOptions, SignIn, McpOptions, Grant, ClientVerification } from './types.ts';
|
|
12
|
+
export type { Provider, OAuthClient, OAuthEndpoints, ConnectOptions, SignIn, DeviceSignIn, McpOptions, Grant, ClientVerification } from './types.ts';
|
|
11
13
|
export { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
12
14
|
export * from '@modelcontextprotocol/sdk/types.js';
|
package/dist/index.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
export { connect, Connection } from
|
|
2
|
-
export { providers } from
|
|
3
|
-
export {
|
|
4
|
-
export {
|
|
5
|
-
export {
|
|
1
|
+
export { connect, deviceSignIn, Connection } from './connect.js';
|
|
2
|
+
export { providers } from './providers.js';
|
|
3
|
+
export { catalog, knownApps, mcpDirectory } from './catalog.js';
|
|
4
|
+
export { ConnectError } from './errors.js';
|
|
5
|
+
export { MailReader, MailSender, MailError } from './mail.js';
|
|
6
|
+
export { MailHistory, HistoryError } from './history.js';
|
|
6
7
|
// Expose the pinned engine for typed protocol pass-through without a second SDK pin.
|
|
7
8
|
export { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
8
9
|
export * from '@modelcontextprotocol/sdk/types.js';
|
package/dist/node.js
CHANGED
|
@@ -2,8 +2,8 @@ import WORDS from './words.json' with { type: 'json' };
|
|
|
2
2
|
import { createServer } from 'node:http';
|
|
3
3
|
import { constants } from 'node:fs';
|
|
4
4
|
import { open } from 'node:fs/promises';
|
|
5
|
-
import { connect } from
|
|
6
|
-
import { ConnectError } from
|
|
5
|
+
import { connect } from './connect.js';
|
|
6
|
+
import { ConnectError } from './errors.js';
|
|
7
7
|
/** One-shot loopback listener on an OS-assigned port; closes on completion, cancel or timeout. */
|
|
8
8
|
export async function connectLoopback(target, options) {
|
|
9
9
|
let finish;
|
package/dist/providers.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export declare const providers: {
|
|
2
2
|
readonly google: {
|
|
3
|
-
readonly id:
|
|
4
|
-
readonly name:
|
|
3
|
+
readonly id: 'google';
|
|
4
|
+
readonly name: 'Google';
|
|
5
5
|
readonly oauth: {
|
|
6
6
|
authorize: string;
|
|
7
7
|
token: string;
|
|
@@ -13,8 +13,8 @@ export declare const providers: {
|
|
|
13
13
|
};
|
|
14
14
|
};
|
|
15
15
|
readonly drive: {
|
|
16
|
-
readonly id:
|
|
17
|
-
readonly name:
|
|
16
|
+
readonly id: 'drive';
|
|
17
|
+
readonly name: 'Google Drive';
|
|
18
18
|
readonly oauth: {
|
|
19
19
|
authorize: string;
|
|
20
20
|
token: string;
|
|
@@ -24,11 +24,11 @@ export declare const providers: {
|
|
|
24
24
|
access_type: string;
|
|
25
25
|
prompt: string;
|
|
26
26
|
};
|
|
27
|
-
readonly mcpUrl:
|
|
27
|
+
readonly mcpUrl: 'https://drivemcp.googleapis.com/mcp/v1';
|
|
28
28
|
};
|
|
29
29
|
readonly gmail: {
|
|
30
|
-
readonly id:
|
|
31
|
-
readonly name:
|
|
30
|
+
readonly id: 'gmail';
|
|
31
|
+
readonly name: 'Gmail';
|
|
32
32
|
readonly oauth: {
|
|
33
33
|
authorize: string;
|
|
34
34
|
token: string;
|
|
@@ -40,8 +40,8 @@ export declare const providers: {
|
|
|
40
40
|
};
|
|
41
41
|
};
|
|
42
42
|
readonly calendar: {
|
|
43
|
-
readonly id:
|
|
44
|
-
readonly name:
|
|
43
|
+
readonly id: 'calendar';
|
|
44
|
+
readonly name: 'Google Calendar';
|
|
45
45
|
readonly oauth: {
|
|
46
46
|
authorize: string;
|
|
47
47
|
token: string;
|
|
@@ -53,14 +53,22 @@ export declare const providers: {
|
|
|
53
53
|
};
|
|
54
54
|
};
|
|
55
55
|
readonly notion: {
|
|
56
|
-
readonly id:
|
|
57
|
-
readonly name:
|
|
58
|
-
readonly mcpUrl:
|
|
56
|
+
readonly id: 'notion';
|
|
57
|
+
readonly name: 'Notion';
|
|
58
|
+
readonly mcpUrl: 'https://mcp.notion.com/mcp';
|
|
59
59
|
};
|
|
60
60
|
readonly canva: {
|
|
61
|
-
readonly id:
|
|
62
|
-
readonly name:
|
|
63
|
-
readonly mcpUrl:
|
|
61
|
+
readonly id: 'canva';
|
|
62
|
+
readonly name: 'Canva';
|
|
63
|
+
readonly mcpUrl: 'https://mcp.canva.com/mcp';
|
|
64
|
+
};
|
|
65
|
+
readonly github: {
|
|
66
|
+
readonly id: 'github';
|
|
67
|
+
readonly name: 'GitHub';
|
|
68
|
+
readonly device: {
|
|
69
|
+
readonly code: 'https://github.com/login/device/code';
|
|
70
|
+
readonly token: 'https://github.com/login/oauth/access_token';
|
|
71
|
+
};
|
|
64
72
|
};
|
|
65
73
|
};
|
|
66
74
|
export type ProviderId = keyof typeof providers;
|
package/dist/providers.js
CHANGED
|
@@ -7,4 +7,5 @@ export const providers = {
|
|
|
7
7
|
calendar: { id: 'calendar', name: 'Google Calendar', oauth: googleOAuth, scopes: ['https://www.googleapis.com/auth/calendar.events'], extra: googleExtra },
|
|
8
8
|
notion: { id: 'notion', name: 'Notion', mcpUrl: 'https://mcp.notion.com/mcp' },
|
|
9
9
|
canva: { id: 'canva', name: 'Canva', mcpUrl: 'https://mcp.canva.com/mcp' },
|
|
10
|
+
github: { id: 'github', name: 'GitHub', device: { code: 'https://github.com/login/device/code', token: 'https://github.com/login/oauth/access_token' } },
|
|
10
11
|
};
|
package/dist/types.d.ts
CHANGED
|
@@ -17,6 +17,11 @@ export interface Provider {
|
|
|
17
17
|
mcpUrl?: string;
|
|
18
18
|
issuer?: string;
|
|
19
19
|
oauth?: OAuthEndpoints;
|
|
20
|
+
/** RFC 8628 device authorization: the person types a code on any device instead of returning to a redirect. */
|
|
21
|
+
device?: {
|
|
22
|
+
code: string;
|
|
23
|
+
token: string;
|
|
24
|
+
};
|
|
20
25
|
scopes?: readonly string[];
|
|
21
26
|
/** Provider-specific authorization parameters; PKCE/state cannot be overridden. */
|
|
22
27
|
extra?: Readonly<Record<string, string>>;
|
|
@@ -26,8 +31,8 @@ export interface ConnectOptions {
|
|
|
26
31
|
store: Keystore;
|
|
27
32
|
/** Distinguishes connections belonging to different people sharing a store. */
|
|
28
33
|
person: string;
|
|
29
|
-
/** HTTPS, loopback HTTP, or the host's registered native app URI. */
|
|
30
|
-
redirectUri
|
|
34
|
+
/** HTTPS, loopback HTTP, or the host's registered native app URI. Needed by `signIn()`, not by `deviceSignIn()`. */
|
|
35
|
+
redirectUri?: string;
|
|
31
36
|
client?: OAuthClient;
|
|
32
37
|
clientName?: string;
|
|
33
38
|
scopes?: readonly string[];
|
|
@@ -43,6 +48,16 @@ export interface SignIn {
|
|
|
43
48
|
finish(callback: string | URL): Promise<void>;
|
|
44
49
|
cancel(): void;
|
|
45
50
|
}
|
|
51
|
+
export interface DeviceSignIn {
|
|
52
|
+
/** Show this code and `verificationUri`; the person types the code there on any device. */
|
|
53
|
+
readonly userCode: string;
|
|
54
|
+
readonly verificationUri: string;
|
|
55
|
+
/** Unix milliseconds when the code stops working. */
|
|
56
|
+
readonly expiresAt: number;
|
|
57
|
+
/** Waits for the person; stores the grant only when they approve. The same promise on every call. */
|
|
58
|
+
finish(): Promise<void>;
|
|
59
|
+
cancel(): void;
|
|
60
|
+
}
|
|
46
61
|
export interface McpOptions {
|
|
47
62
|
clientInfo?: {
|
|
48
63
|
name: string;
|
package/dist/words.json
CHANGED
|
@@ -4,12 +4,13 @@
|
|
|
4
4
|
"discovery": "Could not find the app’s sign-in page.",
|
|
5
5
|
"registration": "The app could not set up sign-in. Try again.",
|
|
6
6
|
"callback": "This sign-in link does not match. Start again.",
|
|
7
|
-
"expired": "This sign-in
|
|
7
|
+
"expired": "This sign-in has expired. Start again.",
|
|
8
8
|
"declined": "Nothing was connected. Try again whenever you like.",
|
|
9
9
|
"scope": "Some permissions were left out. Sign in again and select them.",
|
|
10
10
|
"token": "The app did not finish connecting. Try again.",
|
|
11
11
|
"signin": "Sign in to this app again.",
|
|
12
|
-
"network": "Could not reach the app. Check your connection and try again."
|
|
12
|
+
"network": "Could not reach the app. Check your connection and try again.",
|
|
13
|
+
"catalog": "Could not load the list of apps. Try again later."
|
|
13
14
|
},
|
|
14
15
|
"browser": {
|
|
15
16
|
"unavailable": "This page is not available.",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@byokit/connect",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
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.
|
|
40
|
+
"@byokit/secrets": "0.5.2"
|
|
41
41
|
}
|
|
42
42
|
}
|