@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 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 Google, Drive, Notion, Canva or other remote MCP account.
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. Pass the member's keystore and member ID, the house Google
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 loopback listener, the house
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[]>;
@@ -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 "./errors.js";
5
- import { discover } from "./discovery.js";
6
- import { endpoint, redirect, request, json } from "./http.js";
7
- import { providers } from "./providers.js";
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
- redirect(options.redirectUri);
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 "./errors.js";
3
- import { endpoint, json, request, strings } from "./http.js";
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 "./mail.js";
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
@@ -1,4 +1,4 @@
1
- import { ConnectError } from "./errors.js";
1
+ import { ConnectError } from './errors.js';
2
2
  /** Remote secrets go over HTTPS; loopback HTTP is useful for local servers and tests. */
3
3
  export function endpoint(raw) {
4
4
  let u;
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 "./connect.js";
2
- export { providers } from "./providers.js";
3
- export { ConnectError } from "./errors.js";
4
- export { MailReader, MailSender, MailError } from "./mail.js";
5
- export { MailHistory, HistoryError } from "./history.js";
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 "./connect.js";
6
- import { ConnectError } from "./errors.js";
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;
@@ -1,7 +1,7 @@
1
1
  export declare const providers: {
2
2
  readonly google: {
3
- readonly id: "google";
4
- readonly name: "Google";
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: "drive";
17
- readonly name: "Google Drive";
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: "https://drivemcp.googleapis.com/mcp/v1";
27
+ readonly mcpUrl: 'https://drivemcp.googleapis.com/mcp/v1';
28
28
  };
29
29
  readonly gmail: {
30
- readonly id: "gmail";
31
- readonly name: "Gmail";
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: "calendar";
44
- readonly name: "Google Calendar";
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: "notion";
57
- readonly name: "Notion";
58
- readonly mcpUrl: "https://mcp.notion.com/mcp";
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: "canva";
62
- readonly name: "Canva";
63
- readonly mcpUrl: "https://mcp.canva.com/mcp";
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: string;
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 link has expired. Start again.",
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.4.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.1"
40
+ "@byokit/secrets": "0.5.2"
41
41
  }
42
42
  }