@stage-labs/metro 0.1.0-beta.175 → 0.1.0-beta.176

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage-labs/metro",
3
- "version": "0.1.0-beta.175",
3
+ "version": "0.1.0-beta.176",
4
4
  "description": "The metro command line. Sign in once per machine, then hand your MCP connector list to Claude Code without the credentials touching disk, argv or shell history.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "metro",
3
- "version": "0.1.0-beta.175",
3
+ "version": "0.1.0-beta.176",
4
4
  "description": "Metro for Claude Code, installed by the metro daemon on this machine: every connector the agent holds as its own MCP server relayed through the daemon, the orchestrator-only guard on the main thread, and the standing rules loaded at each session start. No vendor credential ever sits in a config file.",
5
5
  "author": {
6
6
  "name": "Bonustrack",
@@ -26,6 +26,7 @@ import {
26
26
  isInteractiveStation,
27
27
  type InteractiveStation,
28
28
  } from '../stations/attach-interactive.js';
29
+ import type { StepInput } from '../stations/attach-driver.js';
29
30
  import {
30
31
  type AttachOwner,
31
32
  type AttachView,
@@ -41,7 +42,7 @@ export interface AttachSessionApi {
41
42
  submit: (
42
43
  owner: AttachOwner,
43
44
  attachId: string,
44
- input: { code?: unknown; password?: unknown },
45
+ input: StepInput,
45
46
  ) => Promise<AttachView>;
46
47
  cancel: (owner: AttachOwner, attachId: string) => Promise<void>;
47
48
  }
@@ -319,9 +320,14 @@ async function handleStep(
319
320
  attachId: string,
320
321
  ): Promise<void> {
321
322
  const body = await readJsonBody(req);
323
+ const field = (key: string): unknown => bodyField(body, key);
322
324
  const view = await deps.attachSessions.submit(owner, attachId, {
323
- code: bodyField(body, 'code'),
324
- password: bodyField(body, 'password'),
325
+ code: field('code'),
326
+ password: field('password'),
327
+ state: field('state'),
328
+ mode: field('mode'),
329
+ error: field('error'),
330
+ errorDescription: field('errorDescription'),
325
331
  });
326
332
  sendJson(req, res, 200, view);
327
333
  }
@@ -0,0 +1,42 @@
1
+ export type AttachStep = 'code' | 'password' | 'scan' | 'pair' | 'device' | 'browser';
2
+
3
+ export interface AttachPrompt {
4
+ step: AttachStep;
5
+ prompt: string;
6
+ qr?: string;
7
+ pairingCode?: string;
8
+ userCode?: string;
9
+ verificationUri?: string;
10
+ authorizeUrl?: string;
11
+ }
12
+
13
+ export interface AttachOutcome {
14
+ config: Record<string, unknown>;
15
+ identity: Record<string, string>;
16
+ }
17
+
18
+ export interface DriverHooks {
19
+ prompt: (p: AttachPrompt) => void;
20
+ done: (o: AttachOutcome) => void;
21
+ fail: (message: string) => void;
22
+ }
23
+
24
+ export interface StepInput {
25
+ code?: unknown;
26
+ password?: unknown;
27
+ state?: unknown;
28
+ mode?: unknown;
29
+ error?: unknown;
30
+ errorDescription?: unknown;
31
+ }
32
+
33
+ export interface AttachDriver {
34
+ submit: (input: StepInput) => Promise<void>;
35
+ cancel: () => Promise<void>;
36
+ }
37
+
38
+ export interface StartedAttach {
39
+ driver: AttachDriver;
40
+ prompt: AttachPrompt;
41
+ expiresAt?: number;
42
+ }
@@ -1,42 +1,11 @@
1
1
  import { StationAttachError } from './attach.js';
2
+ import type { DriverHooks, StartedAttach } from './attach-driver.js';
3
+ import { startOutlook } from './attach-outlook.js';
2
4
 
3
5
  export const INTERACTIVE_STATIONS = ['telegram', 'whatsapp', 'outlook'] as const;
4
6
 
5
7
  export type InteractiveStation = (typeof INTERACTIVE_STATIONS)[number];
6
8
 
7
- export type AttachStep = 'code' | 'password' | 'scan' | 'pair' | 'device';
8
-
9
- export interface AttachPrompt {
10
- step: AttachStep;
11
- prompt: string;
12
- qr?: string;
13
- pairingCode?: string;
14
- userCode?: string;
15
- verificationUri?: string;
16
- }
17
-
18
- export interface AttachOutcome {
19
- config: Record<string, unknown>;
20
- identity: Record<string, string>;
21
- }
22
-
23
- export interface DriverHooks {
24
- prompt: (p: AttachPrompt) => void;
25
- done: (o: AttachOutcome) => void;
26
- fail: (message: string) => void;
27
- }
28
-
29
- export interface AttachDriver {
30
- submit: (input: { code?: unknown; password?: unknown }) => Promise<void>;
31
- cancel: () => Promise<void>;
32
- }
33
-
34
- export interface StartedAttach {
35
- driver: AttachDriver;
36
- prompt: AttachPrompt;
37
- expiresAt?: number;
38
- }
39
-
40
9
  const CODE_PROMPT =
41
10
  'Telegram sent a login code to that number. Enter it here.';
42
11
  const PASSWORD_PROMPT =
@@ -153,28 +122,6 @@ async function startWhatsapp(
153
122
  };
154
123
  }
155
124
 
156
- const DEVICE_PROMPT =
157
- 'Open the Microsoft sign-in page, type this code, and sign in with the mailbox this agent should read.';
158
-
159
- async function startOutlook(hooks: DriverHooks): Promise<StartedAttach> {
160
- const { OutlookLogin, OutlookLoginError } = await import('@metro-labs/outlook/login');
161
- const login = new OutlookLogin({ onDone: hooks.done, onFailed: hooks.fail });
162
- const code = await login.start().catch((err: unknown) =>
163
- refuse(err instanceof OutlookLoginError ? err : null, 'Microsoft refused to start the sign-in'),
164
- );
165
- return {
166
- prompt: { step: 'device', prompt: DEVICE_PROMPT, userCode: code.userCode, verificationUri: code.verificationUri },
167
- expiresAt: code.expiresAt,
168
- driver: {
169
- cancel: () => login.cancel(),
170
- submit: () =>
171
- Promise.reject(
172
- new StationAttachError('this sign-in finishes on the Microsoft page, there is nothing to submit here', 409),
173
- ),
174
- },
175
- };
176
- }
177
-
178
125
  export async function startInteractiveAttach(
179
126
  station: InteractiveStation,
180
127
  input: Record<string, unknown>,
@@ -0,0 +1,62 @@
1
+ import type { OutlookLogin } from '@metro-labs/outlook/login';
2
+ import { StationAttachError } from './attach.js';
3
+ import type { DriverHooks, StartedAttach, StepInput } from './attach-driver.js';
4
+
5
+ const BROWSER_PROMPT =
6
+ 'Sign in with Microsoft in a new tab, with the mailbox this agent should read. This page updates on its own once you are done.';
7
+ const DEVICE_PROMPT =
8
+ 'Open the Microsoft sign-in page, type this code, and sign in with the mailbox this agent should read.';
9
+ const OTHER_ATTEMPT = 'This sign-in link belongs to another attempt. Start again from the Channels page.';
10
+ const BROWSER_TTL_MS = 15 * 60_000;
11
+
12
+ const text = (v: unknown): string => (typeof v === 'string' ? v.trim() : '');
13
+
14
+ export async function startOutlook(hooks: DriverHooks): Promise<StartedAttach> {
15
+ const { OutlookBrowserLogin, OutlookLogin: DeviceLogin, OutlookLoginError, failureOf } = await import('@metro-labs/outlook/login');
16
+ const said = (err: unknown, fallback: string): string =>
17
+ err instanceof OutlookLoginError && err.message !== '' ? err.message : fallback;
18
+ const browser = (() => {
19
+ try {
20
+ return new OutlookBrowserLogin();
21
+ } catch (err) {
22
+ throw new StationAttachError(said(err, 'Microsoft refused to start the sign-in'), 400);
23
+ }
24
+ })();
25
+ let device: OutlookLogin | null = null;
26
+
27
+ const toDevice = async (): Promise<void> => {
28
+ if (device !== null) return;
29
+ const login = new DeviceLogin({ onDone: hooks.done, onFailed: hooks.fail });
30
+ const code = await login.start().catch((err: unknown) => {
31
+ throw new StationAttachError(said(err, 'Microsoft refused to start the sign-in'), 400);
32
+ });
33
+ device = login;
34
+ hooks.prompt({ step: 'device', prompt: DEVICE_PROMPT, userCode: code.userCode, verificationUri: code.verificationUri });
35
+ };
36
+
37
+ const finish = async (input: StepInput): Promise<void> => {
38
+ const state = text(input.state);
39
+ if (state !== browser.state) throw new StationAttachError(OTHER_ATTEMPT, 400);
40
+ const error = text(input.error);
41
+ if (error !== '') {
42
+ hooks.fail(failureOf({ error, error_description: text(input.errorDescription) }));
43
+ return;
44
+ }
45
+ const code = text(input.code);
46
+ if (code === '') throw new StationAttachError('Microsoft did not send a sign-in code back', 400);
47
+ try {
48
+ hooks.done(await browser.finish(code, state));
49
+ } catch (err) {
50
+ hooks.fail(said(err, 'Metro could not finish the Microsoft sign-in.'));
51
+ }
52
+ };
53
+
54
+ return {
55
+ prompt: { step: 'browser', prompt: BROWSER_PROMPT, authorizeUrl: browser.authorizeUrl },
56
+ expiresAt: Date.now() + BROWSER_TTL_MS,
57
+ driver: {
58
+ cancel: () => device?.cancel() ?? Promise.resolve(),
59
+ submit: (input) => (input.mode === 'device' ? toDevice() : finish(input)),
60
+ },
61
+ };
62
+ }
@@ -3,16 +3,16 @@ import { ApiError } from '@metro-labs/http/api-error';
3
3
  import { errMsg, log } from '@metro-labs/core/log';
4
4
  import type { StationName } from '@metro-labs/core/station-names';
5
5
  import { ensureStationDeps } from './runtime-deps.js';
6
- import {
7
- startInteractiveAttach,
8
- type AttachDriver,
9
- type AttachOutcome,
10
- type AttachPrompt,
11
- type AttachStep,
12
- type DriverHooks,
13
- type InteractiveStation,
14
- type StartedAttach,
15
- } from './attach-interactive.js';
6
+ import { startInteractiveAttach, type InteractiveStation } from './attach-interactive.js';
7
+ import type {
8
+ AttachDriver,
9
+ AttachOutcome,
10
+ AttachPrompt,
11
+ AttachStep,
12
+ DriverHooks,
13
+ StartedAttach,
14
+ StepInput,
15
+ } from './attach-driver.js';
16
16
 
17
17
  export type StartAttach = (
18
18
  station: InteractiveStation,
@@ -42,6 +42,7 @@ export interface AttachView {
42
42
  pairingCode: string | null;
43
43
  userCode: string | null;
44
44
  verificationUri: string | null;
45
+ authorizeUrl: string | null;
45
46
  accountId: string | null;
46
47
  identity: Record<string, string>;
47
48
  activated: boolean;
@@ -86,6 +87,7 @@ function blankView(attachId: string, station: string): AttachView {
86
87
  pairingCode: null,
87
88
  userCode: null,
88
89
  verificationUri: null,
90
+ authorizeUrl: null,
89
91
  accountId: null,
90
92
  identity: {},
91
93
  activated: false,
@@ -138,6 +140,7 @@ export class AttachSessions {
138
140
  pairingCode: null,
139
141
  userCode: null,
140
142
  verificationUri: null,
143
+ authorizeUrl: null,
141
144
  step: null,
142
145
  expiresAt: Date.now() + SETTLED_TTL_MS,
143
146
  });
@@ -187,6 +190,7 @@ export class AttachSessions {
187
190
  session.view.pairingCode = p.pairingCode ?? null;
188
191
  session.view.userCode = p.userCode ?? null;
189
192
  session.view.verificationUri = p.verificationUri ?? null;
193
+ session.view.authorizeUrl = p.authorizeUrl ?? null;
190
194
  },
191
195
  done: (o: AttachOutcome): void => {
192
196
  this.finish(attachId, station, o).catch((err: unknown) => {
@@ -247,7 +251,7 @@ export class AttachSessions {
247
251
  async submit(
248
252
  owner: AttachOwner,
249
253
  attachId: string,
250
- input: { code?: unknown; password?: unknown },
254
+ input: StepInput,
251
255
  ): Promise<AttachView> {
252
256
  const session = this.own(owner, attachId);
253
257
  const driver = session.driver;
@@ -29,6 +29,7 @@ type Body = Record<string, unknown>;
29
29
 
30
30
  const DEVICE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code';
31
31
  const CONSENT_CODES = new Set([65001, 90094, 90095]);
32
+ const POLICY_CODES = new Set([530035, 53003]);
32
33
 
33
34
  const text = (v: unknown): string => (typeof v === 'string' ? v : '');
34
35
  const seconds = (v: unknown, fallback: number): number =>
@@ -39,7 +40,7 @@ export function requireClientId(id = clientId()): string {
39
40
  return id;
40
41
  }
41
42
 
42
- async function postForm(path: string, fields: Record<string, string>, fetchImpl: FetchLike): Promise<{ status: number; body: Body }> {
43
+ export async function postForm(path: string, fields: Record<string, string>, fetchImpl: FetchLike): Promise<{ status: number; body: Body }> {
43
44
  let res: Response;
44
45
  try {
45
46
  res = await fetchImpl(`${loginBase()}/oauth2/v2.0/${path}`, {
@@ -63,13 +64,20 @@ function codesOf(body: Body): number[] {
63
64
  const needsConsent = (error: string, codes: number[]): boolean =>
64
65
  error === 'consent_required' || codes.some((c) => CONSENT_CODES.has(c));
65
66
 
66
- export function failureOf(body: Body, id = clientId()): string {
67
- const error = text(body.error);
68
- const codes = codesOf(body);
67
+ function knownFailure(error: string, codes: number[], id: string): string | null {
69
68
  if (needsConsent(error, codes))
70
- return `An administrator of this Microsoft 365 organization must approve Metro once before it can read this mailbox. They can do it at https://login.microsoftonline.com/common/adminconsent?client_id=${id} and then you can start again.`;
69
+ return `An administrator of this Microsoft 365 organization must approve Metro once before it can read this mailbox. They can do it at https://login.microsoftonline.com/organizations/adminconsent?client_id=${id} and then you can start again.`;
70
+ if (codes.some((c) => POLICY_CODES.has(c)))
71
+ return 'Your company blocks this kind of sign-in. Ask your Microsoft 365 administrator, or use the Microsoft sign-in page instead of a code.';
71
72
  if (error === 'expired_token' || codes.includes(70020)) return 'The sign-in code expired before it was used. Start again.';
72
73
  if (error === 'access_denied' || codes.includes(65004)) return 'The sign-in was declined, so nothing was connected.';
74
+ return null;
75
+ }
76
+
77
+ export function failureOf(body: Body, id = clientId()): string {
78
+ const error = text(body.error);
79
+ const known = knownFailure(error, codesOf(body), id);
80
+ if (known !== null) return known;
73
81
  const detail = text(body.error_description).split(/\r?\n/)[0] ?? '';
74
82
  return detail === '' ? `Microsoft refused the sign-in (${error === '' ? 'no reason given' : error}).` : `Microsoft refused the sign-in: ${detail}`;
75
83
  }
@@ -93,7 +101,7 @@ export function tenantOf(...jwts: string[]): string | null {
93
101
  return null;
94
102
  }
95
103
 
96
- function tokensOf(body: Body, now: number, previous?: string): Tokens {
104
+ export function tokensOf(body: Body, now: number, previous?: string): Tokens {
97
105
  const accessToken = text(body.access_token);
98
106
  const refreshToken = text(body.refresh_token) || (previous ?? '');
99
107
  if (accessToken === '' || refreshToken === '') throw new OutlookAuthError('Microsoft answered without the tokens Metro needs.');
@@ -0,0 +1,52 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ import { failureOf, OutlookAuthError, postForm, requireClientId, tenantOf, tokensOf, type FetchLike, type Tokens } from './auth.js';
3
+ import { loginBase, redirectUri, SCOPES } from './config.js';
4
+
5
+ export interface Pkce {
6
+ verifier: string;
7
+ challenge: string;
8
+ }
9
+
10
+ export function pkcePair(verifier = randomBytes(32).toString('base64url')): Pkce {
11
+ return { verifier, challenge: createHash('sha256').update(verifier).digest('base64url') };
12
+ }
13
+
14
+ export const newState = (): string => randomBytes(24).toString('base64url');
15
+
16
+ export function authorizeUrl(challenge: string, state: string): string {
17
+ const query = new URLSearchParams({
18
+ client_id: requireClientId(),
19
+ response_type: 'code',
20
+ redirect_uri: redirectUri(),
21
+ response_mode: 'query',
22
+ scope: SCOPES,
23
+ state,
24
+ code_challenge: challenge,
25
+ code_challenge_method: 'S256',
26
+ prompt: 'select_account',
27
+ });
28
+ return `${loginBase()}/oauth2/v2.0/authorize?${query.toString()}`;
29
+ }
30
+
31
+ export interface Redeemed {
32
+ tokens: Tokens;
33
+ tenantId: string | null;
34
+ }
35
+
36
+ export async function redeemCode(code: string, verifier: string, fetchImpl: FetchLike, now = Date.now()): Promise<Redeemed> {
37
+ const { status, body } = await postForm(
38
+ 'token',
39
+ {
40
+ grant_type: 'authorization_code',
41
+ client_id: requireClientId(),
42
+ code,
43
+ code_verifier: verifier,
44
+ redirect_uri: redirectUri(),
45
+ scope: SCOPES,
46
+ },
47
+ fetchImpl,
48
+ );
49
+ if (status !== 200) throw new OutlookAuthError(failureOf(body));
50
+ const tokens = tokensOf(body, now);
51
+ return { tokens, tenantId: tenantOf(typeof body.id_token === 'string' ? body.id_token : '', tokens.accessToken) };
52
+ }
@@ -27,5 +27,10 @@ const base = (raw: string | undefined, fallback: string): string => {
27
27
  export const loginBase = (): string =>
28
28
  base(process.env.METRO_OUTLOOK_LOGIN_URL, 'https://login.microsoftonline.com/common');
29
29
 
30
+ export const redirectUri = (): string => {
31
+ const value = trimmed(process.env.METRO_OUTLOOK_REDIRECT);
32
+ return value === '' ? 'https://metro.box/' : value;
33
+ };
34
+
30
35
  export const graphBase = (): string =>
31
36
  base(process.env.METRO_OUTLOOK_GRAPH_URL, 'https://graph.microsoft.com/v1.0');
@@ -5,11 +5,14 @@ import {
5
5
  type DeviceCode,
6
6
  type FetchLike,
7
7
  type Poll,
8
+ type Tokens,
8
9
  } from './auth.js';
9
- import { verifyMailbox } from './me.js';
10
+ import { authorizeUrl, newState, pkcePair, redeemCode, type Pkce } from './browser.js';
11
+ import { verifyMailbox, type Mailbox } from './me.js';
10
12
 
11
13
  export { OutlookAuthError as OutlookLoginError } from './auth.js';
12
14
  export { NOT_SET_UP } from './config.js';
15
+ export { failureOf } from './auth.js';
13
16
 
14
17
  export interface OutlookLoginResult {
15
18
  config: Record<string, unknown>;
@@ -28,6 +31,44 @@ export interface OutlookLoginDeps {
28
31
 
29
32
  const SLOW_DOWN_MS = 5_000;
30
33
 
34
+ function resultOf(tokens: Tokens, tenantId: string | null, mailbox: Mailbox, now: number): OutlookLoginResult {
35
+ return {
36
+ config: {
37
+ accountEmail: mailbox.email,
38
+ refreshToken: tokens.refreshToken,
39
+ accessToken: tokens.accessToken,
40
+ expiresAt: tokens.expiresAt,
41
+ tenantId,
42
+ createdAt: new Date(now).toISOString(),
43
+ },
44
+ identity: { email: mailbox.email, ...(mailbox.name === '' ? {} : { name: mailbox.name }) },
45
+ };
46
+ }
47
+
48
+ export class OutlookBrowserLogin {
49
+ readonly state = newState();
50
+ readonly authorizeUrl: string;
51
+ private readonly pkce: Pkce = pkcePair();
52
+ private used = false;
53
+ private readonly fetchImpl: FetchLike;
54
+ private readonly now: () => number;
55
+
56
+ constructor(deps: OutlookLoginDeps = {}) {
57
+ this.fetchImpl = deps.fetch ?? ((input, init) => fetch(input, init));
58
+ this.now = deps.now ?? Date.now;
59
+ this.authorizeUrl = authorizeUrl(this.pkce.challenge, this.state);
60
+ }
61
+
62
+ async finish(code: string, state: string): Promise<OutlookLoginResult> {
63
+ if (state !== this.state)
64
+ throw new OutlookAuthError('This sign-in link belongs to another attempt. Start again from the Channels page.');
65
+ if (this.used) throw new OutlookAuthError('This sign-in was already used. Start again from the Channels page.');
66
+ this.used = true;
67
+ const { tokens, tenantId } = await redeemCode(code, this.pkce.verifier, this.fetchImpl, this.now());
68
+ return resultOf(tokens, tenantId, await verifyMailbox(tokens.accessToken, this.fetchImpl), this.now());
69
+ }
70
+ }
71
+
31
72
  export class OutlookLogin {
32
73
  private code: DeviceCode | null = null;
33
74
  private timer: ReturnType<typeof setTimeout> | null = null;
@@ -97,16 +138,6 @@ export class OutlookLogin {
97
138
  const mailbox = await verifyMailbox(poll.tokens.accessToken, this.fetchImpl);
98
139
  if (this.stopped) return;
99
140
  this.stopped = true;
100
- this.events.onDone({
101
- config: {
102
- accountEmail: mailbox.email,
103
- refreshToken: poll.tokens.refreshToken,
104
- accessToken: poll.tokens.accessToken,
105
- expiresAt: poll.tokens.expiresAt,
106
- tenantId: poll.tenantId,
107
- createdAt: new Date(this.now()).toISOString(),
108
- },
109
- identity: { email: mailbox.email, ...(mailbox.name === '' ? {} : { name: mailbox.name }) },
110
- });
141
+ this.events.onDone(resultOf(poll.tokens, poll.tenantId, mailbox, this.now()));
111
142
  }
112
143
  }
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.175"
2
+ "version": "0.1.0-beta.176"
3
3
  }