@stage-labs/metro 0.1.0-beta.174 → 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.
Files changed (48) hide show
  1. package/package.json +1 -1
  2. package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
  3. package/runtime/node_modules/@metro-labs/core/src/events.ts +1 -0
  4. package/runtime/node_modules/@metro-labs/core/src/lines.ts +4 -0
  5. package/runtime/node_modules/@metro-labs/core/src/protocol.ts +1 -0
  6. package/runtime/node_modules/@metro-labs/core/src/station-names.ts +1 -0
  7. package/runtime/node_modules/@metro-labs/core/src/stations/types.ts +4 -0
  8. package/runtime/node_modules/@metro-labs/daemon/package.json +1 -0
  9. package/runtime/node_modules/@metro-labs/daemon/src/agents/accounts-api.ts +9 -3
  10. package/runtime/node_modules/@metro-labs/daemon/src/agents/allowlist.ts +10 -1
  11. package/runtime/node_modules/@metro-labs/daemon/src/agents/file-admin.ts +13 -13
  12. package/runtime/node_modules/@metro-labs/daemon/src/agents/map.ts +17 -1
  13. package/runtime/node_modules/@metro-labs/daemon/src/channels/addressed.ts +1 -0
  14. package/runtime/node_modules/@metro-labs/daemon/src/channels/inbound.ts +18 -10
  15. package/runtime/node_modules/@metro-labs/daemon/src/mcp/call-tools.ts +3 -8
  16. package/runtime/node_modules/@metro-labs/daemon/src/mcp/instructions.ts +2 -1
  17. package/runtime/node_modules/@metro-labs/daemon/src/mcp/read-tool.ts +85 -0
  18. package/runtime/node_modules/@metro-labs/daemon/src/mcp/session.ts +7 -9
  19. package/runtime/node_modules/@metro-labs/daemon/src/mcp/tool-dispatch.ts +9 -0
  20. package/runtime/node_modules/@metro-labs/daemon/src/mcp/tool-schemas.ts +22 -4
  21. package/runtime/node_modules/@metro-labs/daemon/src/routes/http.ts +2 -1
  22. package/runtime/node_modules/@metro-labs/daemon/src/stations/attach-driver.ts +42 -0
  23. package/runtime/node_modules/@metro-labs/daemon/src/stations/attach-interactive.ts +4 -31
  24. package/runtime/node_modules/@metro-labs/daemon/src/stations/attach-outlook.ts +62 -0
  25. package/runtime/node_modules/@metro-labs/daemon/src/stations/attach-session.ts +24 -11
  26. package/runtime/node_modules/@metro-labs/daemon/src/stations/materialize.ts +5 -0
  27. package/runtime/node_modules/@metro-labs/daemon/src/stations/registry.ts +2 -0
  28. package/runtime/node_modules/@metro-labs/outlook/package.json +25 -0
  29. package/runtime/node_modules/@metro-labs/outlook/src/accounts.ts +107 -0
  30. package/runtime/node_modules/@metro-labs/outlook/src/actions.ts +20 -0
  31. package/runtime/node_modules/@metro-labs/outlook/src/attachments.ts +97 -0
  32. package/runtime/node_modules/@metro-labs/outlook/src/auth.ts +150 -0
  33. package/runtime/node_modules/@metro-labs/outlook/src/browser.ts +52 -0
  34. package/runtime/node_modules/@metro-labs/outlook/src/config.ts +36 -0
  35. package/runtime/node_modules/@metro-labs/outlook/src/format.ts +141 -0
  36. package/runtime/node_modules/@metro-labs/outlook/src/graph.ts +60 -0
  37. package/runtime/node_modules/@metro-labs/outlook/src/html.ts +72 -0
  38. package/runtime/node_modules/@metro-labs/outlook/src/inbound.ts +104 -0
  39. package/runtime/node_modules/@metro-labs/outlook/src/index.ts +12 -0
  40. package/runtime/node_modules/@metro-labs/outlook/src/login.ts +143 -0
  41. package/runtime/node_modules/@metro-labs/outlook/src/me.ts +25 -0
  42. package/runtime/node_modules/@metro-labs/outlook/src/outbound.ts +67 -0
  43. package/runtime/node_modules/@metro-labs/outlook/src/query.ts +119 -0
  44. package/runtime/node_modules/@metro-labs/outlook/src/state.ts +47 -0
  45. package/runtime/node_modules/@metro-labs/outlook/src/station.ts +12 -0
  46. package/runtime/node_modules/@metro-labs/outlook/src/trust.ts +70 -0
  47. package/runtime/runtime.json +1 -1
  48. package/runtime/stations.json +1 -0
@@ -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,
@@ -40,6 +40,9 @@ export interface AttachView {
40
40
  prompt: string;
41
41
  qr: string | null;
42
42
  pairingCode: string | null;
43
+ userCode: string | null;
44
+ verificationUri: string | null;
45
+ authorizeUrl: string | null;
43
46
  accountId: string | null;
44
47
  identity: Record<string, string>;
45
48
  activated: boolean;
@@ -82,6 +85,9 @@ function blankView(attachId: string, station: string): AttachView {
82
85
  prompt: '',
83
86
  qr: null,
84
87
  pairingCode: null,
88
+ userCode: null,
89
+ verificationUri: null,
90
+ authorizeUrl: null,
85
91
  accountId: null,
86
92
  identity: {},
87
93
  activated: false,
@@ -132,6 +138,9 @@ export class AttachSessions {
132
138
  Object.assign(session.view, patch, {
133
139
  qr: null,
134
140
  pairingCode: null,
141
+ userCode: null,
142
+ verificationUri: null,
143
+ authorizeUrl: null,
135
144
  step: null,
136
145
  expiresAt: Date.now() + SETTLED_TTL_MS,
137
146
  });
@@ -179,6 +188,9 @@ export class AttachSessions {
179
188
  session.view.prompt = p.prompt;
180
189
  session.view.qr = p.qr ?? null;
181
190
  session.view.pairingCode = p.pairingCode ?? null;
191
+ session.view.userCode = p.userCode ?? null;
192
+ session.view.verificationUri = p.verificationUri ?? null;
193
+ session.view.authorizeUrl = p.authorizeUrl ?? null;
182
194
  },
183
195
  done: (o: AttachOutcome): void => {
184
196
  this.finish(attachId, station, o).catch((err: unknown) => {
@@ -219,6 +231,7 @@ export class AttachSessions {
219
231
  this.hooksFor(attachId, station),
220
232
  );
221
233
  session.driver = started.driver;
234
+ if (started.expiresAt !== undefined) session.view.expiresAt = started.expiresAt;
222
235
  this.hooksFor(attachId, station).prompt(started.prompt);
223
236
  } catch (err) {
224
237
  this.sessions.delete(attachId);
@@ -238,7 +251,7 @@ export class AttachSessions {
238
251
  async submit(
239
252
  owner: AttachOwner,
240
253
  attachId: string,
241
- input: { code?: unknown; password?: unknown },
254
+ input: StepInput,
242
255
  ): Promise<AttachView> {
243
256
  const session = this.own(owner, attachId);
244
257
  const driver = session.driver;
@@ -82,6 +82,11 @@ const STATION_TARGETS: Record<StationName, StationTarget> = {
82
82
  fileEnv: 'THREEMA_ACCOUNTS_FILE',
83
83
  trainImport: '@metro-labs/threema/train',
84
84
  },
85
+ outlook: {
86
+ file: 'outlook-accounts.json',
87
+ fileEnv: 'OUTLOOK_ACCOUNTS_FILE',
88
+ trainImport: '@metro-labs/outlook/train',
89
+ },
85
90
  webhook: {
86
91
  file: 'webhook-accounts.json',
87
92
  fileEnv: 'WEBHOOK_ACCOUNTS_FILE',
@@ -6,6 +6,7 @@ import { telegramStation } from '@metro-labs/telegram';
6
6
  import { discordBotStation } from '@metro-labs/discord-bot';
7
7
  import { whatsappStation } from '@metro-labs/whatsapp';
8
8
  import { threemaStation } from '@metro-labs/threema';
9
+ import { outlookStation } from '@metro-labs/outlook';
9
10
  import { webhookStation } from '@metro-labs/webhook';
10
11
 
11
12
  export const STATIONS: readonly Station[] = [
@@ -15,6 +16,7 @@ export const STATIONS: readonly Station[] = [
15
16
  discordBotStation,
16
17
  whatsappStation,
17
18
  threemaStation,
19
+ outlookStation,
18
20
  webhookStation,
19
21
  ];
20
22
 
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "@metro-labs/outlook",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "exports": {
7
+ ".": "./src/station.ts",
8
+ "./train": "./src/index.ts",
9
+ "./login": "./src/login.ts"
10
+ },
11
+ "scripts": {
12
+ "build": "tsc",
13
+ "test": "tsc --noEmit && bun test test/"
14
+ },
15
+ "dependencies": {
16
+ "@metro-labs/core": "workspace:*"
17
+ },
18
+ "devDependencies": {
19
+ "@stage-labs/config": "0.1.0-beta.2",
20
+ "eslint": "^10.3.0",
21
+ "knip": "^6.15.0",
22
+ "madge": "^8.0.0",
23
+ "typescript": "^5"
24
+ }
25
+ }
@@ -0,0 +1,107 @@
1
+ import { homedir } from 'node:os';
2
+ import { join } from 'node:path';
3
+ import { makeAccountStore, resolveAccountId, type Die } from '@metro-labs/core/stations/account-store';
4
+ import { TrainError } from '@metro-labs/core/train-error';
5
+ import { refreshTokens, type FetchLike } from './auth.js';
6
+ import { conversationOfLine } from './format.js';
7
+ import { loadState, saveState, type AccountState } from './state.js';
8
+
9
+ const ACCOUNTS_FILE = process.env.OUTLOOK_ACCOUNTS_FILE ?? join(homedir(), '.metro', 'outlook-accounts.json');
10
+
11
+ const RENEW_BEFORE_MS = 5 * 60_000;
12
+
13
+ export interface AccountConfig {
14
+ id: string;
15
+ accountEmail: string;
16
+ refreshToken: string;
17
+ accessToken?: string;
18
+ expiresAt?: number;
19
+ tenantId?: string | null;
20
+ deltaLink?: string | null;
21
+ includeAutomated?: boolean;
22
+ }
23
+
24
+ function checkAccount(a: AccountConfig, die: Die): void {
25
+ if (!a.id) die('account missing id');
26
+ if (typeof a.accountEmail !== 'string' || !a.accountEmail.includes('@')) die(`account '${a.id}' has no mailbox address`);
27
+ if (typeof a.refreshToken !== 'string' || a.refreshToken === '') die(`account '${a.id}' has no refresh token`);
28
+ }
29
+
30
+ export const { loadAccounts } = makeAccountStore<AccountConfig>({
31
+ prefix: 'outlook',
32
+ file: ACCOUNTS_FILE,
33
+ validate(raw, die) {
34
+ const seen = new Set<string>();
35
+ for (const a of raw) {
36
+ checkAccount(a, die);
37
+ if (seen.has(a.id)) die(`duplicate account id '${a.id}'`);
38
+ seen.add(a.id);
39
+ }
40
+ },
41
+ });
42
+
43
+ export class Account {
44
+ readonly email: string;
45
+ readonly state: AccountState;
46
+ private refreshing: Promise<string> | null = null;
47
+
48
+ constructor(
49
+ readonly cfg: AccountConfig,
50
+ private readonly fetchImpl: FetchLike = (input, init) => fetch(input, init),
51
+ ) {
52
+ this.email = cfg.accountEmail.toLowerCase();
53
+ this.state = loadState(cfg.id, {
54
+ refreshToken: cfg.refreshToken,
55
+ accessToken: cfg.accessToken ?? '',
56
+ expiresAt: cfg.expiresAt ?? 0,
57
+ deltaLink: cfg.deltaLink ?? null,
58
+ });
59
+ }
60
+
61
+ get id(): string {
62
+ return this.cfg.id;
63
+ }
64
+
65
+ save(): void {
66
+ saveState(this.cfg.id, this.state);
67
+ }
68
+
69
+ token(force = false): Promise<string> {
70
+ const fresh = this.state.accessToken !== '' && this.state.expiresAt - RENEW_BEFORE_MS > Date.now();
71
+ if (fresh && !force) return Promise.resolve(this.state.accessToken);
72
+ this.refreshing ??= this.renew().finally(() => {
73
+ this.refreshing = null;
74
+ });
75
+ return this.refreshing;
76
+ }
77
+
78
+ private async renew(): Promise<string> {
79
+ let tokens;
80
+ try {
81
+ tokens = await refreshTokens(this.state.refreshToken, this.fetchImpl);
82
+ } catch (err) {
83
+ throw new TrainError('outlook_signed_out', err instanceof Error ? err.message : String(err), { retryable: false });
84
+ }
85
+ Object.assign(this.state, tokens);
86
+ this.save();
87
+ return tokens.accessToken;
88
+ }
89
+
90
+ fetch(input: string, init?: RequestInit): Promise<Response> {
91
+ return this.fetchImpl(input, init);
92
+ }
93
+ }
94
+
95
+ export const accounts = new Map<string, Account>();
96
+
97
+ export function accountFor(id: string): Account {
98
+ const acct = accounts.get(id);
99
+ if (!acct) throw new Error(`unknown account '${id}' (have: ${[...accounts.keys()].join(', ')})`);
100
+ return acct;
101
+ }
102
+
103
+ export function accountOf(args: { account?: unknown; line?: unknown }): Account {
104
+ const line = typeof args.line === 'string' && args.line !== '' ? args.line : undefined;
105
+ const account = typeof args.account === 'string' && args.account !== '' ? args.account : undefined;
106
+ return accountFor(resolveAccountId(accounts, { account, line }, (l) => conversationOfLine(l)?.accountId));
107
+ }
@@ -0,0 +1,20 @@
1
+ import { makeStation, respond } from '@metro-labs/core/stations/station-runtime';
2
+ import type { Normalized } from '@metro-labs/core/stations/messaging-normalize';
3
+ import { accounts } from './accounts.js';
4
+ import { MAILBOX_URL } from './config.js';
5
+ import { reply, send } from './outbound.js';
6
+ import { read } from './query.js';
7
+
8
+ type Args = Record<string, unknown>;
9
+
10
+ function listAccounts(id: string): void {
11
+ const list = [...accounts.values()].map((a) => ({ id: a.id, handle: a.email, url: MAILBOX_URL, email: a.email }));
12
+ respond(id, { result: { accounts: list } });
13
+ }
14
+
15
+ const normalizeOutlook = (action: string, args: Args): Normalized => ({ action, args });
16
+
17
+ export const handleCall = makeStation({
18
+ handlers: { accounts: listAccounts, send, reply, read },
19
+ normalize: normalizeOutlook,
20
+ });
@@ -0,0 +1,97 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { readFile, stat } from 'node:fs/promises';
3
+ import { assertAttachmentSize, kindOf, saveBufferToCache, type SavedAttachment } from '@metro-labs/core/stations/attachments';
4
+ import { TrainError } from '@metro-labs/core/train-error';
5
+ import type { Account } from './accounts.js';
6
+ import type { AttachmentMeta } from './format.js';
7
+ import { graph, graphJson, messagePath } from './graph.js';
8
+
9
+ export const MAX_FILE_BYTES = 3 * 1024 * 1024;
10
+
11
+ const FILE_TYPE = '#microsoft.graph.fileAttachment';
12
+
13
+ export interface GraphAttachment {
14
+ id: string;
15
+ name?: string;
16
+ contentType?: string;
17
+ size?: number;
18
+ isInline?: boolean;
19
+ '@odata.type'?: string;
20
+ }
21
+
22
+ export interface MailFile extends AttachmentMeta {
23
+ id: string;
24
+ }
25
+
26
+ export const metaOf = (f: MailFile): AttachmentMeta => ({
27
+ kind: f.kind,
28
+ name: f.name,
29
+ mime: f.mime,
30
+ ...(f.size === undefined ? {} : { size: f.size }),
31
+ });
32
+
33
+ export async function listFiles(acct: Account, messageId: string): Promise<MailFile[]> {
34
+ const body = await graphJson<{ value?: GraphAttachment[] }>(
35
+ acct,
36
+ `${messagePath(messageId)}/attachments?$select=id,name,contentType,size,isInline`,
37
+ );
38
+ return (body.value ?? [])
39
+ .filter((a) => a['@odata.type'] === undefined || a['@odata.type'] === FILE_TYPE)
40
+ .map((a) => {
41
+ const name = a.name ?? 'attachment';
42
+ const mime = a.contentType ?? 'application/octet-stream';
43
+ return { id: a.id, name, mime, kind: kindOf(mime, name), ...(typeof a.size === 'number' ? { size: a.size } : {}) };
44
+ });
45
+ }
46
+
47
+ const cacheKey = (messageId: string): string => createHash('sha256').update(messageId).digest('hex').slice(0, 16);
48
+
49
+ export async function saveFile(acct: Account, messageId: string, file: MailFile, index: number): Promise<SavedAttachment> {
50
+ if (file.size !== undefined) assertAttachmentSize(file.size);
51
+ const res = await graph(acct, `${messagePath(messageId)}/attachments/${encodeURIComponent(file.id)}/$value`);
52
+ const data = new Uint8Array(await res.arrayBuffer());
53
+ return saveBufferToCache(data, cacheKey(messageId), index, { mime: file.mime, name: file.name });
54
+ }
55
+
56
+ export interface OutgoingFile {
57
+ path: string;
58
+ mime: string;
59
+ name: string;
60
+ }
61
+
62
+ export const filesOf = (raw: unknown): OutgoingFile[] =>
63
+ (Array.isArray(raw) ? raw : [])
64
+ .map((a) => (a ?? {}) as Record<string, unknown>)
65
+ .filter((a) => typeof a.path === 'string' && a.path !== '')
66
+ .map((a) => ({
67
+ path: String(a.path),
68
+ mime: typeof a.mime === 'string' && a.mime !== '' ? a.mime : 'application/octet-stream',
69
+ name: typeof a.name === 'string' && a.name !== '' ? a.name : 'file',
70
+ }));
71
+
72
+ export async function assertSendable(files: OutgoingFile[]): Promise<void> {
73
+ for (const file of files) {
74
+ const { size } = await stat(file.path);
75
+ if (size > MAX_FILE_BYTES)
76
+ throw new TrainError(
77
+ 'outlook_file_too_big',
78
+ `Metro sends Outlook files up to 3 MB for now; ${file.name} is ${(size / 1024 / 1024).toFixed(1)} MB, so share a link to it instead`,
79
+ { retryable: false },
80
+ );
81
+ }
82
+ }
83
+
84
+ export async function attachFile(acct: Account, draftId: string, file: OutgoingFile): Promise<string> {
85
+ const data = await readFile(file.path);
86
+ await graph(acct, `${messagePath(draftId)}/attachments`, {
87
+ method: 'POST',
88
+ headers: { 'content-type': 'application/json' },
89
+ body: JSON.stringify({
90
+ '@odata.type': FILE_TYPE,
91
+ name: file.name,
92
+ contentType: file.mime,
93
+ contentBytes: data.toString('base64'),
94
+ }),
95
+ });
96
+ return kindOf(file.mime, file.path);
97
+ }
@@ -0,0 +1,150 @@
1
+ import { clientId, loginBase, NOT_SET_UP, SCOPES } from './config.js';
2
+
3
+ export class OutlookAuthError extends Error {}
4
+
5
+ export type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;
6
+
7
+ export interface Tokens {
8
+ accessToken: string;
9
+ refreshToken: string;
10
+ expiresAt: number;
11
+ }
12
+
13
+ export interface DeviceCode {
14
+ deviceCode: string;
15
+ userCode: string;
16
+ verificationUri: string;
17
+ message: string;
18
+ expiresAt: number;
19
+ intervalMs: number;
20
+ }
21
+
22
+ export type Poll =
23
+ | { kind: 'pending' }
24
+ | { kind: 'slow_down' }
25
+ | { kind: 'done'; tokens: Tokens; tenantId: string | null }
26
+ | { kind: 'failed'; message: string };
27
+
28
+ type Body = Record<string, unknown>;
29
+
30
+ const DEVICE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code';
31
+ const CONSENT_CODES = new Set([65001, 90094, 90095]);
32
+ const POLICY_CODES = new Set([530035, 53003]);
33
+
34
+ const text = (v: unknown): string => (typeof v === 'string' ? v : '');
35
+ const seconds = (v: unknown, fallback: number): number =>
36
+ typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : fallback;
37
+
38
+ export function requireClientId(id = clientId()): string {
39
+ if (id === '') throw new OutlookAuthError(NOT_SET_UP);
40
+ return id;
41
+ }
42
+
43
+ export async function postForm(path: string, fields: Record<string, string>, fetchImpl: FetchLike): Promise<{ status: number; body: Body }> {
44
+ let res: Response;
45
+ try {
46
+ res = await fetchImpl(`${loginBase()}/oauth2/v2.0/${path}`, {
47
+ method: 'POST',
48
+ headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' },
49
+ body: new URLSearchParams(fields).toString(),
50
+ });
51
+ } catch (err) {
52
+ throw new OutlookAuthError(`Metro could not reach Microsoft: ${err instanceof Error ? err.message : String(err)}`);
53
+ }
54
+ const raw: unknown = await res.json().catch(() => ({}));
55
+ return { status: res.status, body: typeof raw === 'object' && raw !== null ? (raw as Body) : {} };
56
+ }
57
+
58
+ function codesOf(body: Body): number[] {
59
+ const listed = Array.isArray(body.error_codes) ? body.error_codes.filter((c): c is number => typeof c === 'number') : [];
60
+ const named = [...text(body.error_description).matchAll(/AADSTS(\d+)/g)].map((m) => Number(m[1]));
61
+ return [...listed, ...named];
62
+ }
63
+
64
+ const needsConsent = (error: string, codes: number[]): boolean =>
65
+ error === 'consent_required' || codes.some((c) => CONSENT_CODES.has(c));
66
+
67
+ function knownFailure(error: string, codes: number[], id: string): string | null {
68
+ if (needsConsent(error, codes))
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.';
72
+ if (error === 'expired_token' || codes.includes(70020)) return 'The sign-in code expired before it was used. Start again.';
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;
81
+ const detail = text(body.error_description).split(/\r?\n/)[0] ?? '';
82
+ return detail === '' ? `Microsoft refused the sign-in (${error === '' ? 'no reason given' : error}).` : `Microsoft refused the sign-in: ${detail}`;
83
+ }
84
+
85
+ function decodeClaims(jwt: string): Body | null {
86
+ const part = jwt.split('.')[1];
87
+ if (part === undefined) return null;
88
+ try {
89
+ const raw: unknown = JSON.parse(Buffer.from(part, 'base64url').toString('utf8'));
90
+ return typeof raw === 'object' && raw !== null ? (raw as Body) : null;
91
+ } catch {
92
+ return null;
93
+ }
94
+ }
95
+
96
+ export function tenantOf(...jwts: string[]): string | null {
97
+ for (const jwt of jwts) {
98
+ const tid = decodeClaims(jwt)?.tid;
99
+ if (typeof tid === 'string' && tid !== '') return tid;
100
+ }
101
+ return null;
102
+ }
103
+
104
+ export function tokensOf(body: Body, now: number, previous?: string): Tokens {
105
+ const accessToken = text(body.access_token);
106
+ const refreshToken = text(body.refresh_token) || (previous ?? '');
107
+ if (accessToken === '' || refreshToken === '') throw new OutlookAuthError('Microsoft answered without the tokens Metro needs.');
108
+ return { accessToken, refreshToken, expiresAt: now + seconds(body.expires_in, 3600) * 1000 };
109
+ }
110
+
111
+ export async function requestDeviceCode(fetchImpl: FetchLike = fetch, now = Date.now()): Promise<DeviceCode> {
112
+ const { status, body } = await postForm('devicecode', { client_id: requireClientId(), scope: SCOPES }, fetchImpl);
113
+ const userCode = text(body.user_code);
114
+ const deviceCode = text(body.device_code);
115
+ if (status !== 200 || userCode === '' || deviceCode === '') throw new OutlookAuthError(failureOf(body));
116
+ return {
117
+ deviceCode,
118
+ userCode,
119
+ verificationUri: text(body.verification_uri) || 'https://microsoft.com/devicelogin',
120
+ message: text(body.message),
121
+ expiresAt: now + seconds(body.expires_in, 900) * 1000,
122
+ intervalMs: seconds(body.interval, 5) * 1000,
123
+ };
124
+ }
125
+
126
+ export async function pollDeviceCode(code: DeviceCode, fetchImpl: FetchLike = fetch, now = Date.now()): Promise<Poll> {
127
+ const { status, body } = await postForm(
128
+ 'token',
129
+ { grant_type: DEVICE_GRANT, client_id: requireClientId(), device_code: code.deviceCode },
130
+ fetchImpl,
131
+ );
132
+ if (status === 200) {
133
+ const tokens = tokensOf(body, now);
134
+ return { kind: 'done', tokens, tenantId: tenantOf(text(body.id_token), tokens.accessToken) };
135
+ }
136
+ const error = text(body.error);
137
+ if (error === 'authorization_pending') return { kind: 'pending' };
138
+ if (error === 'slow_down') return { kind: 'slow_down' };
139
+ return { kind: 'failed', message: failureOf(body) };
140
+ }
141
+
142
+ export async function refreshTokens(refreshToken: string, fetchImpl: FetchLike = fetch, now = Date.now()): Promise<Tokens> {
143
+ const { status, body } = await postForm(
144
+ 'token',
145
+ { grant_type: 'refresh_token', client_id: requireClientId(), refresh_token: refreshToken, scope: SCOPES },
146
+ fetchImpl,
147
+ );
148
+ if (status !== 200) throw new OutlookAuthError(`Microsoft refused to renew this mailbox's sign-in, so connect Outlook again. ${failureOf(body)}`);
149
+ return tokensOf(body, now, refreshToken);
150
+ }
@@ -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
+ }
@@ -0,0 +1,36 @@
1
+ export const OUTLOOK_CLIENT_ID = 'a2fbd978-97ee-4cc9-92bb-9d9567a7bf40';
2
+
3
+ export const SCOPES = [
4
+ 'https://graph.microsoft.com/Mail.ReadWrite',
5
+ 'https://graph.microsoft.com/Mail.Send',
6
+ 'https://graph.microsoft.com/User.Read',
7
+ 'offline_access',
8
+ 'openid',
9
+ ].join(' ');
10
+
11
+ export const NOT_SET_UP = 'Outlook is not set up on this Metro yet.';
12
+
13
+ export const MAILBOX_URL = 'https://outlook.office.com/mail/';
14
+
15
+ const trimmed = (raw: string | undefined): string => (raw ?? '').trim();
16
+
17
+ export function clientId(): string {
18
+ const fromEnv = trimmed(process.env.METRO_OUTLOOK_CLIENT_ID);
19
+ return fromEnv === '' ? OUTLOOK_CLIENT_ID : fromEnv;
20
+ }
21
+
22
+ const base = (raw: string | undefined, fallback: string): string => {
23
+ const value = trimmed(raw);
24
+ return (value === '' ? fallback : value).replace(/\/+$/, '');
25
+ };
26
+
27
+ export const loginBase = (): string =>
28
+ base(process.env.METRO_OUTLOOK_LOGIN_URL, 'https://login.microsoftonline.com/common');
29
+
30
+ export const redirectUri = (): string => {
31
+ const value = trimmed(process.env.METRO_OUTLOOK_REDIRECT);
32
+ return value === '' ? 'https://metro.box/' : value;
33
+ };
34
+
35
+ export const graphBase = (): string =>
36
+ base(process.env.METRO_OUTLOOK_GRAPH_URL, 'https://graph.microsoft.com/v1.0');