@stage-labs/metro 0.1.0-beta.270 → 0.1.0-beta.272

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 (38) 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/tickets.ts +7 -0
  4. package/runtime/node_modules/@metro-labs/daemon/src/agents/accounts-api.ts +13 -8
  5. package/runtime/node_modules/@metro-labs/daemon/src/agents/api.ts +3 -1
  6. package/runtime/node_modules/@metro-labs/daemon/src/agents/file-admin.ts +13 -1
  7. package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +5 -2
  8. package/runtime/node_modules/@metro-labs/daemon/src/claude/api.ts +6 -0
  9. package/runtime/node_modules/@metro-labs/daemon/src/claude/session.ts +22 -5
  10. package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-api.ts +5 -2
  11. package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-config.ts +10 -1
  12. package/runtime/node_modules/@metro-labs/daemon/src/net/tunnel.ts +10 -3
  13. package/runtime/node_modules/@metro-labs/daemon/src/routes/bearer.ts +1 -1
  14. package/runtime/node_modules/@metro-labs/daemon/src/routes/gmail-attach.ts +67 -0
  15. package/runtime/node_modules/@metro-labs/daemon/src/routes/gmail-detach.ts +37 -0
  16. package/runtime/node_modules/@metro-labs/daemon/src/routes/local-mode.ts +12 -6
  17. package/runtime/node_modules/@metro-labs/daemon/src/stations/attach-browser.ts +8 -3
  18. package/runtime/node_modules/@metro-labs/daemon/src/stations/attach-driver.ts +1 -1
  19. package/runtime/node_modules/@metro-labs/daemon/src/stations/attach-gmail.ts +78 -0
  20. package/runtime/node_modules/@metro-labs/daemon/src/stations/attach-session.ts +30 -4
  21. package/runtime/node_modules/@metro-labs/gmail/package.json +3 -1
  22. package/runtime/node_modules/@metro-labs/gmail/src/accounts.ts +63 -11
  23. package/runtime/node_modules/@metro-labs/gmail/src/actions.ts +8 -3
  24. package/runtime/node_modules/@metro-labs/gmail/src/api.ts +4 -1
  25. package/runtime/node_modules/@metro-labs/gmail/src/attachments.ts +4 -1
  26. package/runtime/node_modules/@metro-labs/gmail/src/auth.ts +15 -5
  27. package/runtime/node_modules/@metro-labs/gmail/src/config.ts +3 -1
  28. package/runtime/node_modules/@metro-labs/gmail/src/inbound.ts +1 -0
  29. package/runtime/node_modules/@metro-labs/gmail/src/login.ts +5 -3
  30. package/runtime/node_modules/@metro-labs/gmail/src/managed-binding.ts +33 -0
  31. package/runtime/node_modules/@metro-labs/gmail/src/managed.ts +45 -0
  32. package/runtime/node_modules/@metro-labs/gmail/src/outbound.ts +1 -0
  33. package/runtime/node_modules/@metro-labs/gmail/src/query.ts +2 -0
  34. package/runtime/node_modules/@metro-labs/gmail/src/state.ts +8 -2
  35. package/runtime/node_modules/@metro-labs/http/src/api-http.ts +2 -0
  36. package/runtime/runtime.json +1 -1
  37. package/runtime/sdk-runner/node_modules/@metro-labs/core/src/tickets.ts +7 -0
  38. package/runtime/sdk-runner/runner.json +1 -1
@@ -0,0 +1,78 @@
1
+ import { parseMailbox, pkcePair, SignInError } from '@metro-labs/core/stations/oauth';
2
+ import { managedRequest, managedTokens } from '@metro-labs/gmail/managed';
3
+ import { ApiError } from '@metro-labs/http/api-error';
4
+ import { stringOf } from '@metro-labs/http/api-http';
5
+ import type { AttachOwner } from './attach-session.js';
6
+ import type { AttachOutcome, DriverHooks, StartedAttach } from './attach-driver.js';
7
+
8
+ interface ManagedStart {
9
+ host: string;
10
+ owner: AttachOwner;
11
+ mailbox: unknown;
12
+ sendEnabled: boolean;
13
+ check: () => void;
14
+ }
15
+
16
+ function authorizationOf(owner: AttachOwner): string {
17
+ if (!owner.userId || !owner.sessionId || !owner.organization || !owner.authorization) throw new ApiError('Sign in to Metro again before connecting Gmail.', 401);
18
+ return owner.authorization;
19
+ }
20
+
21
+ function signInLink(started: Record<string, unknown>): { state: string; authorizeUrl: string; expiresAt: number } {
22
+ const state = stringOf(started.state);
23
+ const authorizeUrl = stringOf(started.authorizeUrl);
24
+ const url = new URL(authorizeUrl);
25
+ const expiresAt = started.expiresAt;
26
+ if (!state || url.origin !== 'https://accounts.google.com' || url.pathname !== '/o/oauth2/v2/auth' || url.searchParams.get('state') !== state || url.searchParams.get('redirect_uri') !== 'https://metro.box/' || typeof expiresAt !== 'number' || !Number.isFinite(expiresAt) || expiresAt <= Date.now()) throw new SignInError('Metro returned an invalid Gmail sign-in link.');
27
+ return { state, authorizeUrl, expiresAt };
28
+ }
29
+
30
+ function outcomeOf(result: Record<string, unknown>, input: ManagedStart, mailbox: string | null): AttachOutcome {
31
+ const accountEmail = parseMailbox(result.accountEmail);
32
+ if (accountEmail === null || (mailbox !== null && accountEmail !== mailbox) || result.managed !== true || result.managedHost !== input.host || result.sendEnabled !== input.sendEnabled) throw new SignInError('Metro returned a Gmail connection for a different mailbox or access level.');
33
+ return { config: { ...managedTokens(result), accountEmail, managed: true, managedHost: input.host, managedOrganization: input.owner.organization, sendEnabled: input.sendEnabled }, identity: { email: accountEmail } };
34
+ }
35
+
36
+ export async function startManagedGmail(input: ManagedStart, hooks: DriverHooks): Promise<StartedAttach> {
37
+ const { host, owner, sendEnabled, check } = input;
38
+ const bearer = authorizationOf(owner);
39
+ const pair = pkcePair();
40
+ const mailbox = parseMailbox(input.mailbox);
41
+ check();
42
+ const started = await managedRequest('start', { host, agentId: owner.agentId, challenge: pair.challenge, ...(mailbox === null ? {} : { mailbox }), sendEnabled }, bearer);
43
+ check();
44
+ const { state, authorizeUrl, expiresAt } = signInLink(started);
45
+ let cancelled = false;
46
+ let used = false;
47
+ return {
48
+ expiresAt,
49
+ prompt: { step: 'browser', prompt: sendEnabled ? 'Approve sending for this mailbox on Google. Metro write permissions stay unchanged.' : 'Choose a Google account and approve read-only Gmail access. Metro cannot send mail with this connection.', authorizeUrl },
50
+ driver: {
51
+ cancel: async () => {
52
+ cancelled = true;
53
+ await managedRequest('cancel', { state, host, agentId: owner.agentId }, bearer);
54
+ },
55
+ submit: async (value, authorization) => {
56
+ if (cancelled || used || Date.now() >= expiresAt) throw new ApiError('This sign-in is finished or expired. Start again.', 409);
57
+ if (stringOf(value.state) !== state) throw new ApiError('This sign-in belongs to another attempt.', 400);
58
+ if (!authorization) throw new ApiError('Sign in to Metro again.', 401);
59
+ used = true;
60
+ try {
61
+ if (stringOf(value.error)) {
62
+ await managedRequest('cancel', { state, host, agentId: owner.agentId }, authorization);
63
+ hooks.fail('Google sign-in was declined or blocked. Nothing was connected.');
64
+ return;
65
+ }
66
+ const code = stringOf(value.code);
67
+ if (!code) throw new ApiError('Google did not return a sign-in code.', 400);
68
+ check();
69
+ const result = await managedRequest('exchange', { state, code, verifier: pair.verifier, host, agentId: owner.agentId }, authorization);
70
+ check();
71
+ if (!cancelled) hooks.done(outcomeOf(result, input, mailbox));
72
+ } catch {
73
+ hooks.fail('Metro could not finish this Gmail sign-in. Start again.');
74
+ }
75
+ },
76
+ },
77
+ };
78
+ }
@@ -18,6 +18,7 @@ export type StartAttach = (
18
18
  station: InteractiveStation,
19
19
  input: Record<string, unknown>,
20
20
  hooks: DriverHooks,
21
+ owner: AttachOwner,
21
22
  ) => Promise<StartedAttach>;
22
23
 
23
24
  export const ATTACH_ID_RE = /^as_[A-Za-z0-9_-]{22}$/;
@@ -30,6 +31,10 @@ const MAX_TOTAL = 40;
30
31
 
31
32
  export interface AttachOwner {
32
33
  agentId: string;
34
+ organization?: string;
35
+ userId?: string;
36
+ sessionId?: string;
37
+ authorization?: string;
33
38
  }
34
39
 
35
40
  export interface AttachView {
@@ -54,6 +59,7 @@ type CompleteAttach = (
54
59
  owner: AttachOwner,
55
60
  station: StationName,
56
61
  config: Record<string, unknown>,
62
+ active: () => boolean,
57
63
  ) => Promise<{ accountId: string; activated: boolean }>;
58
64
 
59
65
  type AuthorizeAttach = (owner: AttachOwner) => Promise<void>;
@@ -68,6 +74,8 @@ interface Session {
68
74
  owner: AttachOwner;
69
75
  driver: AttachDriver | null;
70
76
  view: AttachView;
77
+ finishing?: boolean;
78
+ committing?: boolean;
71
79
  }
72
80
 
73
81
  const missing = (): ApiError => new ApiError('no such attach session', 404);
@@ -114,7 +122,8 @@ export class AttachSessions {
114
122
 
115
123
  private own(owner: AttachOwner, attachId: string): Session {
116
124
  const session = this.sessions.get(attachId);
117
- if (session?.owner.agentId !== owner.agentId) throw missing();
125
+ if (session === undefined || session.owner.agentId !== owner.agentId || session.owner.organization !== owner.organization || session.owner.userId !== owner.userId || session.owner.sessionId !== owner.sessionId) throw missing();
126
+ if (session.view.expiresAt <= Date.now()) throw new ApiError('this sign-in expired; start again', 410);
118
127
  return session;
119
128
  }
120
129
 
@@ -153,12 +162,21 @@ export class AttachSessions {
153
162
  outcome: AttachOutcome,
154
163
  ): Promise<void> {
155
164
  const session = this.sessions.get(attachId);
156
- if (session?.view.status !== 'pending') return;
165
+ const active = (): boolean => session !== undefined && this.sessions.get(attachId) === session && session.view.status === 'pending' && session.view.expiresAt > Date.now();
166
+ if (!active() || session === undefined || session.finishing === true) return;
167
+ session.finishing = true;
157
168
  try {
169
+ await this.deps.authorize(session.owner);
170
+ if (!active()) return;
158
171
  const landed = await this.deps.complete(
159
172
  session.owner,
160
173
  station,
161
174
  outcome.config,
175
+ () => {
176
+ if (!active()) return false;
177
+ session.committing = true;
178
+ return true;
179
+ },
162
180
  );
163
181
  this.settle(session, {
164
182
  status: 'done',
@@ -229,7 +247,12 @@ export class AttachSessions {
229
247
  station,
230
248
  input,
231
249
  this.hooksFor(attachId, station),
250
+ owner,
232
251
  );
252
+ if (this.sessions.get(attachId) !== session || session.view.expiresAt <= Date.now()) {
253
+ await started.driver.cancel();
254
+ throw new ApiError('this sign-in expired or stopped; start again', 410);
255
+ }
233
256
  session.driver = started.driver;
234
257
  if (started.expiresAt !== undefined) session.view.expiresAt = started.expiresAt;
235
258
  this.hooksFor(attachId, station).prompt(started.prompt);
@@ -257,12 +280,15 @@ export class AttachSessions {
257
280
  const driver = session.driver;
258
281
  if (session.view.status !== 'pending' || !driver)
259
282
  throw new ApiError('this sign-in is already finished', 409);
260
- await driver.submit(input);
283
+ await this.deps.authorize(owner);
284
+ this.own(owner, attachId);
285
+ await driver.submit(input, owner.authorization);
261
286
  return { ...session.view };
262
287
  }
263
288
 
264
289
  async cancel(owner: AttachOwner, attachId: string): Promise<void> {
265
290
  const session = this.own(owner, attachId);
291
+ if (session.committing === true) throw new ApiError('this sign-in has already been saved', 409);
266
292
  this.sessions.delete(attachId);
267
293
  await session.driver?.cancel().catch(() => undefined);
268
294
  log.info({ attachId }, 'attach-session: cancelled');
@@ -270,7 +296,7 @@ export class AttachSessions {
270
296
 
271
297
  async sweep(now = Date.now()): Promise<void> {
272
298
  for (const [attachId, session] of [...this.sessions]) {
273
- if (session.view.expiresAt > now) continue;
299
+ if (session.view.expiresAt > now || (session.committing === true && session.view.status === 'pending')) continue;
274
300
  this.sessions.delete(attachId);
275
301
  const driver = session.driver;
276
302
  session.driver = null;
@@ -6,7 +6,9 @@
6
6
  "exports": {
7
7
  ".": "./src/station.ts",
8
8
  "./train": "./src/index.ts",
9
- "./login": "./src/login.ts"
9
+ "./login": "./src/login.ts",
10
+ "./managed": "./src/managed.ts",
11
+ "./managed-binding": "./src/managed-binding.ts"
10
12
  },
11
13
  "scripts": {
12
14
  "build": "tsc",
@@ -3,13 +3,18 @@ import { threadOfLine } from '@metro-labs/core/stations/mail';
3
3
  import { TokenKeeper, type FetchLike } from '@metro-labs/core/stations/oauth';
4
4
  import { TrainError } from '@metro-labs/core/train-error';
5
5
  import { refreshTokens } from './auth.js';
6
+ import { renewManaged, revokeManaged } from './managed.js';
7
+ import { checkManagedBinding, type ManagedBinding } from './managed-binding.js';
6
8
  import { loadState, saveState, type AccountState } from './state.js';
7
9
 
8
- export interface AccountConfig {
10
+ export interface AccountConfig extends ManagedBinding {
9
11
  id: string;
10
12
  accountEmail: string;
11
- clientId: string;
12
- clientSecret: string;
13
+ clientId?: string;
14
+ clientSecret?: string;
15
+ refreshGrant?: string;
16
+ authorizationId?: string;
17
+ sendEnabled?: boolean;
13
18
  refreshToken: string;
14
19
  accessToken?: string;
15
20
  expiresAt?: number;
@@ -18,9 +23,15 @@ export interface AccountConfig {
18
23
 
19
24
  const filled = (v: unknown): boolean => typeof v === 'string' && v !== '';
20
25
 
26
+ function checkClient(a: AccountConfig, die: Die): void {
27
+ if (a.managed === true) {
28
+ if (!filled(a.refreshGrant) || !filled(a.managedHost) || !filled(a.managedOrganization) || !filled(a.authorizationId) || typeof a.sendEnabled !== 'boolean') die(`account '${a.id}' has an incomplete managed sign-in`);
29
+ } else if (!filled(a.clientId) || !filled(a.clientSecret)) die(`account '${a.id}' has no OAuth client`);
30
+ }
31
+
21
32
  function checkAccount(a: AccountConfig, die: Die): void {
22
33
  if (!filled(a.accountEmail) || !a.accountEmail.includes('@')) die(`account '${a.id}' has no mailbox address`);
23
- if (!filled(a.clientId) || !filled(a.clientSecret)) die(`account '${a.id}' has no OAuth client`);
34
+ checkClient(a, die);
24
35
  if (!filled(a.refreshToken)) die(`account '${a.id}' has no refresh token`);
25
36
  }
26
37
 
@@ -39,15 +50,24 @@ export class Account {
39
50
  readonly email: string;
40
51
  readonly state: AccountState;
41
52
  private readonly keeper: TokenKeeper;
53
+ private readonly pendingTokens = new Set<Promise<string>>();
54
+ private closing = false;
55
+ private revoked = false;
42
56
 
43
57
  constructor(
44
58
  readonly cfg: AccountConfig,
45
59
  private readonly fetchImpl: FetchLike = (input, init) => fetch(input, init),
46
60
  ) {
47
61
  this.email = cfg.accountEmail.toLowerCase();
48
- this.state = loadState(cfg.id, { refreshToken: cfg.refreshToken, accessToken: cfg.accessToken ?? '', expiresAt: cfg.expiresAt ?? 0 });
49
- const client = { clientId: cfg.clientId, clientSecret: cfg.clientSecret };
50
- this.keeper = new TokenKeeper(this.state, (refreshToken) => refreshTokens(client, refreshToken, this.fetchImpl).catch(signedOut), () => {
62
+ this.state = loadState(cfg.id, cfg);
63
+ const client = { clientId: cfg.clientId ?? '', clientSecret: cfg.clientSecret ?? '' };
64
+ this.keeper = new TokenKeeper(this.state, async (refreshToken) => {
65
+ checkManagedBinding(cfg);
66
+ const tokens = await (cfg.managed === true
67
+ ? renewManaged(refreshToken, this.state.refreshGrant ?? '', cfg.managedHost ?? '', this.fetchImpl)
68
+ : refreshTokens(client, refreshToken, this.fetchImpl, Date.now(), cfg.sendEnabled)).catch(signedOut);
69
+ return tokens;
70
+ }, () => {
51
71
  this.save();
52
72
  });
53
73
  }
@@ -60,12 +80,44 @@ export class Account {
60
80
  saveState(this.cfg.id, this.state);
61
81
  }
62
82
 
63
- token(force = false): Promise<string> {
64
- return this.keeper.token(force);
83
+ check(): void {
84
+ if (this.closing) throw new TrainError('gmail_disconnecting', 'This Gmail connection is disconnecting.', { retryable: false });
85
+ checkManagedBinding(this.cfg);
86
+ }
87
+
88
+ async token(force = false): Promise<string> {
89
+ this.check();
90
+ const pending = this.keeper.token(force);
91
+ this.pendingTokens.add(pending);
92
+ try {
93
+ const token = await pending;
94
+ this.check();
95
+ return token;
96
+ } finally {
97
+ this.pendingTokens.delete(pending);
98
+ }
99
+ }
100
+
101
+ async disconnect(authorizationId: unknown): Promise<void> {
102
+ if (this.cfg.managed !== true || !authorizationId || authorizationId !== this.cfg.authorizationId) throw new TrainError('gmail_connection_changed', 'This Gmail connection changed. Try deleting again.', { retryable: false });
103
+ if (this.revoked) return;
104
+ if (this.closing) throw new TrainError('gmail_disconnecting', 'This Gmail connection is disconnecting.', { retryable: false });
105
+ this.closing = true;
106
+ try {
107
+ await Promise.allSettled([...this.pendingTokens]);
108
+ await revokeManaged(this.state.refreshToken, this.state.refreshGrant ?? '', this.cfg.managedHost ?? '', this.fetchImpl);
109
+ this.revoked = true;
110
+ } catch (err) {
111
+ this.closing = false;
112
+ throw err;
113
+ }
65
114
  }
66
115
 
67
- fetch(input: string, init?: RequestInit): Promise<Response> {
68
- return this.fetchImpl(input, init);
116
+ async fetch(input: string, init?: RequestInit): Promise<Response> {
117
+ this.check();
118
+ const result = await this.fetchImpl(input, init);
119
+ this.check();
120
+ return result;
69
121
  }
70
122
  }
71
123
 
@@ -1,14 +1,19 @@
1
1
  import { makeStation, respond } from '@metro-labs/core/stations/station-runtime';
2
- import { accounts } from './accounts.js';
2
+ import { accountOf, accounts } from './accounts.js';
3
3
  import { MAILBOX_URL } from './config.js';
4
4
  import { reply, send } from './outbound.js';
5
5
  import { read } from './query.js';
6
6
 
7
7
  function listAccounts(id: string): void {
8
- const list = [...accounts.values()].map((a) => ({ id: a.id, handle: a.email, url: `${MAILBOX_URL}mail/u/${a.email}/`, email: a.email }));
8
+ const list = [...accounts.values()].map((a) => ({ id: a.id, handle: a.email, url: `${MAILBOX_URL}mail/u/${a.email}/`, email: a.email, managed: a.cfg.managed === true, ...(a.cfg.sendEnabled === undefined ? {} : { sendEnabled: a.cfg.sendEnabled }) }));
9
9
  respond(id, { result: { accounts: list } });
10
10
  }
11
11
 
12
+ async function disconnect(id: string, args: Record<string, unknown>): Promise<void> {
13
+ await accountOf(args).disconnect(args.authorizationId);
14
+ respond(id, { result: { revoked: true } });
15
+ }
16
+
12
17
  export const handleCall = makeStation({
13
- handlers: { accounts: listAccounts, send, reply, read },
18
+ handlers: { accounts: listAccounts, send, reply, read, disconnect },
14
19
  });
@@ -21,11 +21,14 @@ export async function gmail(acct: Account, path: string, init: RequestInit = {})
21
21
  if (res.status === 401) res = await call(acct, path, init, true);
22
22
  if (res.ok) return res;
23
23
  const detail = await googleMessage(res);
24
+ acct.check();
24
25
  throw res.status === 404 && path.startsWith(`${USER}/history`) ? new HistoryGone(detail) : refusalOf(GMAIL, res.status, detail);
25
26
  }
26
27
 
27
28
  export async function gmailJson<T>(acct: Account, path: string, init: RequestInit = {}): Promise<T> {
28
- return (await (await gmail(acct, path, init)).json()) as T;
29
+ const body = (await (await gmail(acct, path, init)).json()) as T;
30
+ acct.check();
31
+ return body;
29
32
  }
30
33
 
31
34
  export const queryOf = (params: [string, string][]): string => new URLSearchParams(params).toString();
@@ -20,7 +20,10 @@ export async function saveFile(acct: Account, messageId: string, file: MailFile,
20
20
  if (file.size !== undefined) assertAttachmentSize(file.size);
21
21
  const data = file.data ?? (file.attachmentId === null ? null : await attachmentData(acct, messageId, file.attachmentId));
22
22
  if (data === null) throw new TrainError('gmail_file_missing', `Gmail gave no content for ${file.name}`, { retryable: false });
23
- return saveBufferToCache(new Uint8Array(Buffer.from(data, 'base64url')), messageId, index, { mime: file.mime, name: file.name });
23
+ acct.check();
24
+ const saved = await saveBufferToCache(new Uint8Array(Buffer.from(data, 'base64url')), messageId, index, { mime: file.mime, name: file.name });
25
+ acct.check();
26
+ return saved;
24
27
  }
25
28
 
26
29
  export async function assertSendable(files: OutgoingFile[]): Promise<void> {
@@ -1,5 +1,12 @@
1
1
  import { postForm, SignInError, tokensOf, type FetchLike, type OAuthBody, type Tokens } from '@metro-labs/core/stations/oauth';
2
- import { apiBase, authorizeBase, redirectUri, SCOPES, tokenUrl } from './config.js';
2
+ import { apiBase, authorizeBase, redirectUri, scopesFor, tokenUrl } from './config.js';
3
+
4
+ export function checkScopes(scope: unknown, sendEnabled: boolean): void {
5
+ const granted = typeof scope === 'string' ? scope.split(/\s+/).filter(Boolean) : [];
6
+ const wanted = scopesFor(sendEnabled).split(' ');
7
+ if (wanted.some((s) => !granted.includes(s)) || granted.some((s) => !wanted.includes(s)))
8
+ throw new SignInError('Google did not grant exactly the requested Gmail access. Review this app in your Google Account, then connect again.');
9
+ }
3
10
 
4
11
  export interface GmailClient {
5
12
  clientId: string;
@@ -30,13 +37,14 @@ export function failureOf(body: OAuthBody): string {
30
37
  return detail === '' ? `Google refused the sign-in (${error === '' ? 'no reason given' : error}).` : `Google refused the sign-in: ${detail}`;
31
38
  }
32
39
 
33
- export function authorizeUrl(client: GmailClient, challenge: string, state: string, loginHint: string | null): string {
40
+ export function authorizeUrl(client: GmailClient, challenge: string, state: string, loginHint: string | null, sendEnabled = false): string {
34
41
  const query = new URLSearchParams({
35
42
  ...(loginHint === null ? {} : { login_hint: loginHint }),
36
43
  client_id: client.clientId,
37
44
  redirect_uri: redirectUri(),
38
45
  response_type: 'code',
39
- scope: SCOPES,
46
+ scope: scopesFor(sendEnabled),
47
+ ...(sendEnabled ? { include_granted_scopes: 'true' } : {}),
40
48
  state,
41
49
  code_challenge: challenge,
42
50
  code_challenge_method: 'S256',
@@ -49,7 +57,7 @@ export function authorizeUrl(client: GmailClient, challenge: string, state: stri
49
57
  const post = (fields: Record<string, string>, fetchImpl: FetchLike): Promise<{ status: number; body: OAuthBody }> =>
50
58
  postForm(tokenUrl(), fields, fetchImpl, 'Google');
51
59
 
52
- export async function redeemCode(client: GmailClient, code: string, verifier: string, fetchImpl: FetchLike, now = Date.now()): Promise<Tokens> {
60
+ export async function redeemCode(client: GmailClient, code: string, verifier: string, fetchImpl: FetchLike, now = Date.now(), sendEnabled = false): Promise<Tokens> {
53
61
  const { status, body } = await post(
54
62
  {
55
63
  grant_type: 'authorization_code',
@@ -62,15 +70,17 @@ export async function redeemCode(client: GmailClient, code: string, verifier: st
62
70
  fetchImpl,
63
71
  );
64
72
  if (status !== 200) throw new SignInError(failureOf(body));
73
+ checkScopes(body.scope, sendEnabled);
65
74
  return tokensOf(body, now, 'Google');
66
75
  }
67
76
 
68
- export async function refreshTokens(client: GmailClient, refreshToken: string, fetchImpl: FetchLike, now = Date.now()): Promise<Tokens> {
77
+ export async function refreshTokens(client: GmailClient, refreshToken: string, fetchImpl: FetchLike, now = Date.now(), sendEnabled?: boolean): Promise<Tokens> {
69
78
  const { status, body } = await post(
70
79
  { grant_type: 'refresh_token', refresh_token: refreshToken, client_id: client.clientId, client_secret: client.clientSecret },
71
80
  fetchImpl,
72
81
  );
73
82
  if (status !== 200) throw new SignInError(`Google refused to renew this mailbox's sign-in, so connect Gmail again. ${failureOf(body)}`);
83
+ if (sendEnabled !== undefined) checkScopes(body.scope, sendEnabled);
74
84
  return tokensOf(body, now, 'Google', refreshToken);
75
85
  }
76
86
 
@@ -1,6 +1,8 @@
1
1
  import { envUrl, envValue } from '@metro-labs/core/stations/oauth';
2
2
 
3
- export const SCOPES = ['https://www.googleapis.com/auth/gmail.readonly', 'https://www.googleapis.com/auth/gmail.send'].join(' ');
3
+ export const READ_SCOPE = 'https://www.googleapis.com/auth/gmail.readonly';
4
+ export const SEND_SCOPE = 'https://www.googleapis.com/auth/gmail.send';
5
+ export const scopesFor = (sendEnabled = false): string => [READ_SCOPE, ...(sendEnabled ? [SEND_SCOPE] : [])].join(' ');
4
6
 
5
7
  export const MAILBOX_URL = 'https://mail.google.com/';
6
8
 
@@ -46,6 +46,7 @@ async function deliver(acct: Account, messageId: string): Promise<boolean> {
46
46
  }
47
47
  const files = mailFilesOf(m);
48
48
  const env = mailEnvelope('gmail', acct.id, acct.email, item, files.map(metaOf), verdict.verified);
49
+ acct.check();
49
50
  emitInbound(acct.id, env);
50
51
  const at = { station: 'gmail', account: acct.id, line: String(env.line), forId: String(env.id) };
51
52
  for (const [index, file] of files.entries())
@@ -12,6 +12,7 @@ export interface GmailLoginDeps {
12
12
  clientId?: unknown;
13
13
  clientSecret?: unknown;
14
14
  mailbox?: unknown;
15
+ sendEnabled?: boolean;
15
16
  fetch?: FetchLike;
16
17
  now?: () => number;
17
18
  }
@@ -22,13 +23,14 @@ export class GmailBrowserLogin extends BrowserSignIn<GmailLoginResult> {
22
23
  const mailbox = parseMailbox(deps.mailbox);
23
24
  const fetchImpl: FetchLike = deps.fetch ?? ((input, init) => fetch(input, init));
24
25
  const now = deps.now ?? Date.now;
26
+ const sendEnabled = deps.sendEnabled === true;
25
27
  super({
26
- authorize: (challenge, state) => authorizeUrl(client, challenge, state, mailbox),
28
+ authorize: (challenge, state) => authorizeUrl(client, challenge, state, mailbox, sendEnabled),
27
29
  redeem: async (code, verifier) => {
28
- const tokens = await redeemCode(client, code, verifier, fetchImpl, now());
30
+ const tokens = await redeemCode(client, code, verifier, fetchImpl, now(), sendEnabled);
29
31
  const email = await verifyMailbox(tokens.accessToken, fetchImpl, mailbox);
30
32
  return {
31
- config: { accountEmail: email, ...client, ...tokens, createdAt: new Date(now()).toISOString() },
33
+ config: { accountEmail: email, ...client, ...tokens, sendEnabled, createdAt: new Date(now()).toISOString() },
32
34
  identity: { email },
33
35
  };
34
36
  },
@@ -0,0 +1,33 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { ensureSecureDir, writeSecure } from '@metro-labs/core/secure-fs';
5
+ import { TrainError } from '@metro-labs/core/train-error';
6
+
7
+ export interface ManagedBinding {
8
+ managed?: boolean;
9
+ managedOrganization?: string;
10
+ managedHost?: string;
11
+ }
12
+
13
+ const bindingDir = (): string => process.env.METRO_AGENTS_DIR ?? join(homedir(), '.metro', 'agents');
14
+
15
+ export function setManagedHost(url: string | null): void {
16
+ const dir = bindingDir();
17
+ ensureSecureDir(dir);
18
+ writeSecure(join(dir, '.gmail-host'), url === null ? '' : new URL(url).host);
19
+ }
20
+
21
+ export function checkManagedBinding(config: ManagedBinding): void {
22
+ if (config.managed !== true) return;
23
+ let owner = '';
24
+ let host = '';
25
+ try {
26
+ owner = readFileSync(join(bindingDir(), '.owner'), 'utf8').trim();
27
+ host = readFileSync(join(bindingDir(), '.gmail-host'), 'utf8').trim();
28
+ } catch {
29
+ throw new TrainError('gmail_binding_changed', 'Reconnect managed Gmail on this box before using it.', { retryable: false });
30
+ }
31
+ if (!owner || owner !== config.managedOrganization || !host || host !== config.managedHost)
32
+ throw new TrainError('gmail_binding_changed', 'This box changed owner or address. Reconnect managed Gmail before using it.', { retryable: false });
33
+ }
@@ -0,0 +1,45 @@
1
+ import { SignInError, type FetchLike, type Tokens } from '@metro-labs/core/stations/oauth';
2
+
3
+ const BASE = 'https://api.metro.box/api/gmail';
4
+ const reach: FetchLike = (input, init) => fetch(input, init);
5
+
6
+ export interface ManagedTokens extends Tokens {
7
+ refreshGrant: string;
8
+ }
9
+
10
+ export async function managedRequest(path: 'start' | 'exchange' | 'cancel' | 'refresh' | 'revoke', body: Record<string, unknown>, bearer?: string, fetchImpl = reach): Promise<Record<string, unknown>> {
11
+ let response: Response;
12
+ try {
13
+ response = await fetchImpl(`${BASE}/${path}`, {
14
+ method: 'POST',
15
+ headers: { 'content-type': 'application/json', ...(bearer === undefined ? {} : { authorization: bearer }) },
16
+ body: JSON.stringify(body),
17
+ redirect: 'error',
18
+ signal: AbortSignal.timeout(25_000),
19
+ });
20
+ } catch {
21
+ throw new SignInError('Metro could not reach managed Gmail sign-in. Try again.');
22
+ }
23
+ if (!response.ok) {
24
+ const retry = response.status >= 500 ? 'Try again later.' : 'Start Gmail sign-in again. Your Google Workspace administrator may need to allow the app.';
25
+ throw new SignInError(`Metro could not complete managed Gmail sign-in. ${retry}`);
26
+ }
27
+ const value: unknown = await response.json().catch(() => null);
28
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) throw new SignInError('Metro returned an invalid Gmail answer.');
29
+ return value as Record<string, unknown>;
30
+ }
31
+
32
+ export function managedTokens(value: Record<string, unknown>): ManagedTokens {
33
+ if (typeof value.accessToken !== 'string' || !value.accessToken || typeof value.refreshToken !== 'string' || !value.refreshToken || typeof value.refreshGrant !== 'string' || !value.refreshGrant || typeof value.expiresAt !== 'number' || !Number.isFinite(value.expiresAt))
34
+ throw new SignInError('Metro returned an incomplete Gmail sign-in.');
35
+ return { accessToken: value.accessToken, refreshToken: value.refreshToken, refreshGrant: value.refreshGrant, expiresAt: value.expiresAt };
36
+ }
37
+
38
+ export async function renewManaged(refreshToken: string, refreshGrant: string, host: string, fetchImpl = reach): Promise<ManagedTokens> {
39
+ return managedTokens(await managedRequest('refresh', { refreshToken, refreshGrant, host }, undefined, fetchImpl));
40
+ }
41
+
42
+ export async function revokeManaged(refreshToken: string, refreshGrant: string, host: string, fetchImpl = reach): Promise<void> {
43
+ const result = await managedRequest('revoke', { refreshToken, refreshGrant, host }, undefined, fetchImpl);
44
+ if (result.revoked !== true) throw new SignInError('Metro did not confirm Google revocation. Try again.');
45
+ }
@@ -74,6 +74,7 @@ async function outgoing(args: Args): Promise<Outgoing> {
74
74
  const parsed = threadOfLine('gmail', line);
75
75
  if (parsed === null) throw new TrainError('gmail_bad_line', `not a valid gmail line: ${line}`, { retryable: false });
76
76
  const acct = accountOf(args);
77
+ if (acct.cfg.sendEnabled === false) throw new TrainError('gmail_read_only', 'This Gmail connection is read-only. Allow sending with Google in channel settings, then choose a Metro write policy.', { retryable: false });
77
78
  const files = filesOf(args.attachments);
78
79
  const text = typeof args.text === 'string' ? args.text : '';
79
80
  if (text.trim() === '' && files.length === 0) throw new TrainError('gmail_text_required', 'give some text or a file to send', { retryable: false });
@@ -69,6 +69,7 @@ async function searched(acct: Account, q: ReadQuery, beforeAt: string): Promise<
69
69
  async function list(id: string, acct: Account, q: ReadQuery): Promise<void> {
70
70
  const beforeAt = await receivedAt(acct, q.before);
71
71
  const found = q.threadId !== null && q.query === '' ? await inThread(acct, q, q.threadId, beforeAt) : await searched(acct, q, beforeAt);
72
+ acct.check();
72
73
  respond(id, {
73
74
  result: {
74
75
  account: acct.id,
@@ -85,6 +86,7 @@ async function one(id: string, acct: Account, messageId: string): Promise<void>
85
86
  const saved = await saveFile(acct, m.id, file, index);
86
87
  attachments.push({ name: file.name, mime: file.mime, kind: file.kind, size: saved.bytes, local_path: saved.path });
87
88
  }
89
+ acct.check();
88
90
  respond(id, { result: { account: acct.id, message: { ...mailFull('gmail', acct.id, itemOf(m)), attachments } } });
89
91
  }
90
92
 
@@ -4,16 +4,22 @@ import { seenOf } from '@metro-labs/core/stations/mail';
4
4
  import { tokenStateOf, type Tokens } from '@metro-labs/core/stations/oauth';
5
5
 
6
6
  export interface AccountState extends Tokens {
7
+ refreshGrant?: string;
8
+ authorizationId?: string;
7
9
  historyId: string | null;
8
10
  seen: string[];
9
11
  }
10
12
 
11
13
  export const stateFiles = accountFiles('GMAIL_STATE_DIR', 'gmail-state-');
12
14
 
13
- export function loadState(accountId: string, seed: Partial<Tokens>): AccountState {
15
+ export function loadState(accountId: string, seed: Partial<AccountState>): AccountState {
14
16
  const saved = readJson<Record<string, unknown>>(stateFiles.path(accountId), {});
17
+ const same = saved.authorizationId === seed.authorizationId;
18
+ const held = same ? saved : {};
15
19
  return {
16
- ...tokenStateOf(saved, seed),
20
+ ...tokenStateOf(held, seed),
21
+ refreshGrant: typeof held.refreshGrant === 'string' ? held.refreshGrant : seed.refreshGrant,
22
+ authorizationId: seed.authorizationId,
17
23
  historyId: typeof saved.historyId === 'string' && saved.historyId !== '' ? saved.historyId : null,
18
24
  seen: seenOf(saved.seen),
19
25
  };
@@ -8,6 +8,8 @@ export type Role = 'admin' | 'member';
8
8
 
9
9
  export interface ApiSession {
10
10
  subject: string;
11
+ userId?: string;
12
+ sessionId?: string;
11
13
  role: Role;
12
14
  operator?: true;
13
15
  }
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.270"
2
+ "version": "0.1.0-beta.272"
3
3
  }
@@ -2,6 +2,7 @@ import { randomBytes } from 'node:crypto';
2
2
 
3
3
  export interface TicketStore<T> {
4
4
  mint: (value: T, now?: number) => { ticket: string; expiresAt: number };
5
+ peek: (ticket: string, now?: number) => T | undefined;
5
6
  take: (ticket: string, now?: number) => T | undefined;
6
7
  size: (now?: number) => number;
7
8
  }
@@ -24,6 +25,12 @@ export function ticketStore<T>(ttlMs: number, max: number): TicketStore<T> {
24
25
  open.set(ticket, { value, expiresAt });
25
26
  return { ticket, expiresAt };
26
27
  },
28
+ peek(ticket, now = Date.now()) {
29
+ const entry = open.get(ticket);
30
+ if (entry !== undefined && entry.expiresAt > now) return entry.value;
31
+ open.delete(ticket);
32
+ return undefined;
33
+ },
27
34
  take(ticket, now = Date.now()) {
28
35
  const entry = open.get(ticket);
29
36
  open.delete(ticket);
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.1.0-beta.270",
2
+ "version": "0.1.0-beta.272",
3
3
  "dependencies": {
4
4
  "@anthropic-ai/claude-agent-sdk": "0.3.287",
5
5
  "@modelcontextprotocol/sdk": "^1.29.0",