@stage-labs/metro 0.1.0-beta.271 → 0.1.0-beta.273
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 +1 -1
- package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
- package/runtime/marketplace/plugin/STAGE.md +3 -0
- package/runtime/node_modules/@metro-labs/core/src/tickets.ts +7 -0
- package/runtime/node_modules/@metro-labs/daemon/src/agents/accounts-api.ts +13 -8
- package/runtime/node_modules/@metro-labs/daemon/src/agents/api.ts +3 -1
- package/runtime/node_modules/@metro-labs/daemon/src/agents/file-admin.ts +13 -1
- package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +5 -2
- package/runtime/node_modules/@metro-labs/daemon/src/claude/setup.ts +1 -0
- package/runtime/node_modules/@metro-labs/daemon/src/net/tunnel.ts +10 -3
- package/runtime/node_modules/@metro-labs/daemon/src/routes/bearer.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/routes/gmail-attach.ts +67 -0
- package/runtime/node_modules/@metro-labs/daemon/src/routes/gmail-detach.ts +37 -0
- package/runtime/node_modules/@metro-labs/daemon/src/routes/local-mode.ts +12 -6
- package/runtime/node_modules/@metro-labs/daemon/src/stations/attach-browser.ts +8 -3
- package/runtime/node_modules/@metro-labs/daemon/src/stations/attach-driver.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/stations/attach-gmail.ts +78 -0
- package/runtime/node_modules/@metro-labs/daemon/src/stations/attach-session.ts +30 -4
- package/runtime/node_modules/@metro-labs/gmail/package.json +3 -1
- package/runtime/node_modules/@metro-labs/gmail/src/accounts.ts +63 -11
- package/runtime/node_modules/@metro-labs/gmail/src/actions.ts +8 -3
- package/runtime/node_modules/@metro-labs/gmail/src/api.ts +4 -1
- package/runtime/node_modules/@metro-labs/gmail/src/attachments.ts +4 -1
- package/runtime/node_modules/@metro-labs/gmail/src/auth.ts +15 -5
- package/runtime/node_modules/@metro-labs/gmail/src/config.ts +3 -1
- package/runtime/node_modules/@metro-labs/gmail/src/inbound.ts +1 -0
- package/runtime/node_modules/@metro-labs/gmail/src/login.ts +5 -3
- package/runtime/node_modules/@metro-labs/gmail/src/managed-binding.ts +33 -0
- package/runtime/node_modules/@metro-labs/gmail/src/managed.ts +45 -0
- package/runtime/node_modules/@metro-labs/gmail/src/outbound.ts +1 -0
- package/runtime/node_modules/@metro-labs/gmail/src/query.ts +2 -0
- package/runtime/node_modules/@metro-labs/gmail/src/state.ts +8 -2
- package/runtime/node_modules/@metro-labs/http/src/api-http.ts +2 -0
- package/runtime/runtime.json +1 -1
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/tickets.ts +7 -0
- package/runtime/sdk-runner/runner.json +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "metro",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.273",
|
|
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",
|
|
@@ -45,6 +45,9 @@ The separate `metadata.status` and `metadata.priority` fields are optional. Stat
|
|
|
45
45
|
- Delete one of your own messages: `delete {"line": "…", "message_id": "…"}`. XMTP has no edit and no typing.
|
|
46
46
|
- History: `read {"line": "…", "limit": 20}`.
|
|
47
47
|
- Poll: `ask {"line": "…", "question": "Ship today?", "options": ["Yes", "No"]}`.
|
|
48
|
+
- Poll votes are numeric reactions with zero-based indexes: `0` is the first option, `1` is the second, and so on, in the poll's stored option order. For the poll above, `0` means Yes and `1` means No. Never use a one-based guess. Match the inbound reaction's `target_id` to the poll's `messageId` returned by `ask`, not its `pollId` or the reaction's own `message_id`.
|
|
49
|
+
- With several questions, the first question still uses bare option indexes (`0`, `1`, …); later questions use `q:o`, with both indexes zero-based. `1:0` means the first option of the second question. Explicit `0:0` also means the first option of the first question.
|
|
50
|
+
- Keep the poll's questions, options and `messageId`: `read` shows `[poll]` and `[reaction]`, not their choices or vote values. If the original mapping is unavailable, do not guess.
|
|
48
51
|
- File: `create_upload {"name": "report.pdf"}`, run the `curl` line it returns in a shell, then `send {"line": "…", "attachments": [{"upload": "<upload_id>"}]}`. Files that are not images are refused over about 190 KiB.
|
|
49
52
|
|
|
50
53
|
**Frames.** A frame is a small interactive view: a report, a choice, a form. Stage shows the frame itself in the chat (its `start` screen when it has screens), at image size, at most 400 x 400, clipped with a fade when taller. A tap opens it full screen. Its buttons do nothing in the chat: they work in the full view.
|
|
@@ -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);
|
|
@@ -5,7 +5,7 @@ import { approversForAccount } from './map.js';
|
|
|
5
5
|
import { isRecord } from '@metro-labs/core/is-record';
|
|
6
6
|
import type { RecentSender } from './senders.js';
|
|
7
7
|
import { ApiError } from '@metro-labs/http/api-error';
|
|
8
|
-
import { apiFailure, bodyField, readJsonBody, sendJson } from '@metro-labs/http/api-http';
|
|
8
|
+
import { apiFailure, bodyField, readJsonBody, sendJson, type ApiSession } from '@metro-labs/http/api-http';
|
|
9
9
|
import { type AccountRef } from './account-attach.js';
|
|
10
10
|
import { type AccountRoute } from './account-routes.js';
|
|
11
11
|
import { handlePolicy, type SetPolicy } from './policy-route.js';
|
|
@@ -138,7 +138,7 @@ async function storeAccount(
|
|
|
138
138
|
|
|
139
139
|
function asInput(body: unknown): Record<string, unknown> {
|
|
140
140
|
const out: Record<string, unknown> = {};
|
|
141
|
-
for (const key of ['apiId', 'apiHash', 'phone', 'mailbox', 'clientId', 'clientSecret'])
|
|
141
|
+
for (const key of ['apiId', 'apiHash', 'phone', 'mailbox', 'clientId', 'clientSecret', 'mode', 'sendEnabled', 'accountId'])
|
|
142
142
|
out[key] = bodyField(body, key);
|
|
143
143
|
return out;
|
|
144
144
|
}
|
|
@@ -148,12 +148,13 @@ async function handleStart(
|
|
|
148
148
|
res: ServerResponse,
|
|
149
149
|
deps: AccountApiDeps,
|
|
150
150
|
agentId: string,
|
|
151
|
+
owner: AttachOwner,
|
|
151
152
|
): Promise<void> {
|
|
152
153
|
const body = await readJsonBody(req);
|
|
153
154
|
const station = bodyField(body, 'station');
|
|
154
155
|
if (isInteractiveStation(station)) {
|
|
155
156
|
const view = await deps.attachSessions.start(
|
|
156
|
-
|
|
157
|
+
owner,
|
|
157
158
|
station,
|
|
158
159
|
asInput(body),
|
|
159
160
|
);
|
|
@@ -320,10 +321,12 @@ async function dispatchAttach(
|
|
|
320
321
|
deps: AccountApiDeps,
|
|
321
322
|
agentId: string,
|
|
322
323
|
route: Extract<AccountRoute, { kind: 'start' | 'session' | 'step' }>,
|
|
324
|
+
session?: ApiSession,
|
|
323
325
|
): Promise<void> {
|
|
324
|
-
|
|
325
|
-
if (route.kind === '
|
|
326
|
-
return
|
|
326
|
+
const owner: AttachOwner = { agentId, organization: session?.subject, userId: session?.userId, sessionId: session?.sessionId, authorization: req.headers.authorization };
|
|
327
|
+
if (route.kind === 'start') return handleStart(req, res, deps, agentId, owner);
|
|
328
|
+
if (route.kind === 'session') return handleSession(req, res, deps, owner, route.attachId);
|
|
329
|
+
return handleStep(req, res, deps, owner, route.attachId);
|
|
327
330
|
}
|
|
328
331
|
|
|
329
332
|
async function dispatchRoute(
|
|
@@ -332,8 +335,9 @@ async function dispatchRoute(
|
|
|
332
335
|
deps: AccountApiDeps,
|
|
333
336
|
agentId: string,
|
|
334
337
|
route: AccountRoute,
|
|
338
|
+
session?: ApiSession,
|
|
335
339
|
): Promise<void> {
|
|
336
|
-
if (route.kind === 'start' || route.kind === 'session' || route.kind === 'step') return dispatchAttach(req, res, deps, agentId, route);
|
|
340
|
+
if (route.kind === 'start' || route.kind === 'session' || route.kind === 'step') return dispatchAttach(req, res, deps, agentId, route, session);
|
|
337
341
|
if (route.kind === 'allowlist') return handleAllowlist(req, res, deps, agentId, route);
|
|
338
342
|
if (route.kind === 'enabled') return handleEnabled(req, res, deps, agentId, route);
|
|
339
343
|
if (route.kind === 'policy') return handlePolicy(req, res, deps, agentId, route);
|
|
@@ -348,9 +352,10 @@ export async function handleAccountRoute(
|
|
|
348
352
|
deps: AccountApiDeps,
|
|
349
353
|
agentId: string,
|
|
350
354
|
route: AccountRoute,
|
|
355
|
+
session?: ApiSession,
|
|
351
356
|
): Promise<void> {
|
|
352
357
|
try {
|
|
353
|
-
await dispatchRoute(req, res, deps, agentId, route);
|
|
358
|
+
await dispatchRoute(req, res, deps, agentId, route, session);
|
|
354
359
|
} catch (err) {
|
|
355
360
|
apiFailure(req, res, err);
|
|
356
361
|
}
|
|
@@ -25,6 +25,7 @@ export interface AgentApiDeps extends AccountApiDeps {
|
|
|
25
25
|
capabilities: () => Record<string, string[]>;
|
|
26
26
|
toolGroups?: () => Record<string, { name: string; group: string }[]>;
|
|
27
27
|
attachable?: string[];
|
|
28
|
+
features?: string[];
|
|
28
29
|
connectorIds: (agentIds: string[]) => Promise<Map<string, string[]>>;
|
|
29
30
|
}
|
|
30
31
|
|
|
@@ -69,6 +70,7 @@ async function handleList(
|
|
|
69
70
|
const base = {
|
|
70
71
|
agents: list.map((a) => agentPayload(a, connectors)),
|
|
71
72
|
capabilities: deps.capabilities(),
|
|
73
|
+
features: deps.features ?? [],
|
|
72
74
|
attachable: deps.attachable ?? ATTACHABLE,
|
|
73
75
|
...(deps.toolGroups === undefined ? {} : { tools: deps.toolGroups() }),
|
|
74
76
|
};
|
|
@@ -100,7 +102,7 @@ async function dispatch(
|
|
|
100
102
|
sendJson(req, res, 401, { error: 'unauthorized' });
|
|
101
103
|
return;
|
|
102
104
|
}
|
|
103
|
-
if (tgt.kind === 'accounts') await handleAccountRoute(req, res, deps, tgt.id, tgt.route);
|
|
105
|
+
if (tgt.kind === 'accounts') await handleAccountRoute(req, res, deps, tgt.id, tgt.route, session);
|
|
104
106
|
else await handleList(req, res, deps);
|
|
105
107
|
}
|
|
106
108
|
|
|
@@ -116,6 +116,7 @@ function assertUnclaimed(existing: Stored[], agent: LoadedAgent): void {
|
|
|
116
116
|
}
|
|
117
117
|
|
|
118
118
|
function assertImportable(agent: LoadedAgent): void {
|
|
119
|
+
if (agent.accounts.some((a) => a.station === 'gmail' && a.config.managed === true)) throw new AgentAdminError('Managed Gmail cannot be imported. Connect the mailbox again on this box.', 400);
|
|
119
120
|
const immovable = agent.accounts.find((a) => !MOVABLE_STATIONS.has(a.station));
|
|
120
121
|
if (immovable !== undefined)
|
|
121
122
|
throw new AgentAdminError(
|
|
@@ -170,6 +171,7 @@ function mergedStations(
|
|
|
170
171
|
const fromMetro = agent.accounts
|
|
171
172
|
.map((a) => ({ ...a, allowlist: a.allowlist ?? ['*'] }))
|
|
172
173
|
.filter((m) => mode === 'overwrite' || !here(m.station, m.id));
|
|
174
|
+
if (fromMetro.some((m) => m.station === 'gmail' && before.some((s) => s.station === 'gmail' && s.id === m.id && s.config.managed === true))) throw new AgentAdminError('Managed Gmail cannot be overwritten by an import. Delete the connection first.', 400);
|
|
173
175
|
const kept = before.filter((s) => !fromMetro.some((m) => m.station === s.station && m.id === s.id));
|
|
174
176
|
return [...fromMetro, ...kept];
|
|
175
177
|
}
|
|
@@ -198,6 +200,16 @@ function fileFor(
|
|
|
198
200
|
}
|
|
199
201
|
}
|
|
200
202
|
|
|
203
|
+
export function localRenewGmail(agentId: string, accountId: string, previous: string, config: Record<string, unknown>, dir = agentsDir()): AccountRef {
|
|
204
|
+
const stored = agentOrThrow(agentId, dir);
|
|
205
|
+
const account = stored.file.stations.find((s) => s.station === 'gmail' && s.id === accountId);
|
|
206
|
+
if (account === undefined || (account.config.authorizationId ?? account.config.refreshToken) !== previous) throw new AgentAdminError('This Gmail connection changed. Start again.', 409);
|
|
207
|
+
if (account.config.accountEmail !== config.accountEmail) throw new AgentAdminError('The Gmail mailbox did not match. Nothing was changed.', 400);
|
|
208
|
+
account.config = { ...account.config, ...config };
|
|
209
|
+
save(stored);
|
|
210
|
+
return { agentId, station: 'gmail', accountId };
|
|
211
|
+
}
|
|
212
|
+
|
|
201
213
|
export function readLocalAgentFile(agentId: string, dir = agentsDir()): AgentFile {
|
|
202
214
|
const found = storedAgents(dir).find((s) => s.file.id === agentId);
|
|
203
215
|
if (found === undefined) throw missing();
|
|
@@ -233,7 +245,7 @@ export async function localAttachAccount(
|
|
|
233
245
|
assertCredentialFree(storedAgents(dir), station, config);
|
|
234
246
|
const taken = new Set(stored.file.stations.map((a) => a.id));
|
|
235
247
|
const accountId = freshId(taken);
|
|
236
|
-
stored.file.stations.push({ station, id: accountId, allowlist: ['*'], enabled: true, config });
|
|
248
|
+
stored.file.stations.push({ station, id: accountId, allowlist: ['*'], enabled: true, ...(station === 'gmail' && config.sendEnabled === false ? { policy: { write: 'deny' as const } } : {}), config });
|
|
237
249
|
save(stored);
|
|
238
250
|
return Promise.resolve({ agentId, station, accountId });
|
|
239
251
|
}
|
|
@@ -7,7 +7,8 @@ import { acquireLock, STATE_DIR, trainsDir } from './paths.js';
|
|
|
7
7
|
import { installCrashGuard, markDaemonReady } from './crash-guard.js';
|
|
8
8
|
import { METRO_VERSION } from '@metro-labs/core/version';
|
|
9
9
|
import { funnelDriver, Tunnel, tunnelWanted } from '../net/tunnel.js';
|
|
10
|
-
import { webhookPort } from '../files/attach-serve.js';
|
|
10
|
+
import { publicBaseUrl, webhookPort } from '../files/attach-serve.js';
|
|
11
|
+
import { setManagedHost } from '@metro-labs/gmail/managed-binding';
|
|
11
12
|
import {
|
|
12
13
|
localConnectHint,
|
|
13
14
|
publicConnectHint,
|
|
@@ -80,7 +81,8 @@ supervisor.onTrainEvent((env, train) => {
|
|
|
80
81
|
});
|
|
81
82
|
|
|
82
83
|
let webhookServer: Server | null = null;
|
|
83
|
-
const
|
|
84
|
+
const syncGmailHost = (): void => { setManagedHost(publicBaseUrl()); };
|
|
85
|
+
const tunnel = tunnelWanted() ? new Tunnel(funnelDriver(webhookPort()), announcePublic, undefined, syncGmailHost) : null;
|
|
84
86
|
|
|
85
87
|
function announcePublic(url: string): void {
|
|
86
88
|
process.stderr.write(`\n${publicConnectHint(url, localOwner())}`);
|
|
@@ -140,6 +142,7 @@ installBearerSessions(agentsDir(), localOwner);
|
|
|
140
142
|
log.info({ agent: await ensureLocalAgent() }, 'local daemon: agent');
|
|
141
143
|
await materializeFrom(fileSource);
|
|
142
144
|
forgetOrphans(knownAccounts());
|
|
145
|
+
syncGmailHost();
|
|
143
146
|
supervisor.start();
|
|
144
147
|
sweepLeftoverCalls();
|
|
145
148
|
const metroMcp = await createMetroMcp({ liveEvents: liveEvents() });
|
|
@@ -160,6 +160,7 @@ const PRIOR_STAGE: ReadonlySet<string> = new Set([
|
|
|
160
160
|
'f87e9494ea6151d140b742c1b88b4f435ae653559be641fddc2160cd24dd4e1b',
|
|
161
161
|
'cb4307b016855c1a09d48bc8b3924a3080bd78b205963df3d6f20401d0486df9',
|
|
162
162
|
'f2be51c5290d2e61b47f10c379fa45d051a59a6b3f06d99174c2a170074bc8f5',
|
|
163
|
+
'4c727aa110c9023371cfb364f3304ad9db22cdf2501208ef1e42e6ccc245b00f',
|
|
163
164
|
]);
|
|
164
165
|
const PRIOR_MEMORY: ReadonlySet<string> = new Set(['08795399635c8cdec2b50cb4e336f89ae880f3997ae52560ef647a9cdc8f1b08']);
|
|
165
166
|
|
|
@@ -187,12 +187,18 @@ export class Tunnel {
|
|
|
187
187
|
private driver: TunnelDriver,
|
|
188
188
|
private onUrl: (url: string) => void = () => undefined,
|
|
189
189
|
private resolves: Resolves = resolvesPublicly,
|
|
190
|
+
private onChange: () => void = () => undefined,
|
|
190
191
|
) {}
|
|
191
192
|
|
|
193
|
+
private setUrl(url: string | null): void {
|
|
194
|
+
liveUrl = url;
|
|
195
|
+
this.onChange();
|
|
196
|
+
}
|
|
197
|
+
|
|
192
198
|
private noticeUrl(text: string): void {
|
|
193
199
|
const url = this.driver.urlIn(text);
|
|
194
200
|
if (url === null || url === liveUrl) return;
|
|
195
|
-
|
|
201
|
+
this.setUrl(url);
|
|
196
202
|
this.restartDelay = RESTART_DELAY_MS;
|
|
197
203
|
log.info({ url }, `${this.driver.name} up; waiting for its name to resolve`);
|
|
198
204
|
this.announceWhenResolvable(url).catch((err: unknown) => {
|
|
@@ -240,7 +246,7 @@ export class Tunnel {
|
|
|
240
246
|
}, SILENT_MS).unref();
|
|
241
247
|
child.on('exit', (code) => {
|
|
242
248
|
this.child = null;
|
|
243
|
-
|
|
249
|
+
this.setUrl(null);
|
|
244
250
|
if (this.closed) return;
|
|
245
251
|
this.afterExit(code).catch((err: unknown) => {
|
|
246
252
|
log.warn({ err: errMsg(err) }, `${this.driver.name}: exit handling failed`);
|
|
@@ -289,7 +295,7 @@ export class Tunnel {
|
|
|
289
295
|
if (misses < ADOPTED_MISSES) return;
|
|
290
296
|
this.unwatch();
|
|
291
297
|
log.warn({ url }, `${this.driver.name}: the Funnel this daemon adopted no longer answers; publishing our own`);
|
|
292
|
-
|
|
298
|
+
this.setUrl(null);
|
|
293
299
|
this.restartDelay = RESTART_DELAY_MS;
|
|
294
300
|
this.start();
|
|
295
301
|
};
|
|
@@ -312,5 +318,6 @@ export class Tunnel {
|
|
|
312
318
|
this.unwatch();
|
|
313
319
|
this.child?.kill('SIGINT');
|
|
314
320
|
this.child = null;
|
|
321
|
+
this.setUrl(null);
|
|
315
322
|
}
|
|
316
323
|
}
|
|
@@ -28,7 +28,7 @@ export function bearerSessionsFor(owner: () => string | null, keys: SigningKeys)
|
|
|
28
28
|
const held = owner();
|
|
29
29
|
if (held === null || session.organization === null || session.organization !== held)
|
|
30
30
|
throw new ApiError('this machine belongs to another organization', 403);
|
|
31
|
-
return { subject: held, role: session.role === 'admin' ? 'admin' : 'member', ...(isOperatorUser(session.userId) ? { operator: true as const } : {}) };
|
|
31
|
+
return { subject: held, userId: session.userId, sessionId: session.sessionId, role: session.role === 'admin' ? 'admin' : 'member', ...(isOperatorUser(session.userId) ? { operator: true as const } : {}) };
|
|
32
32
|
};
|
|
33
33
|
}
|
|
34
34
|
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { ApiError } from '@metro-labs/http/api-error';
|
|
3
|
+
import { stringOf } from '@metro-labs/http/api-http';
|
|
4
|
+
import { publicBaseUrl } from '../files/attach-serve.js';
|
|
5
|
+
import { localOwner, readLocalAgentFile, localRenewGmail } from '../agents/file-admin.js';
|
|
6
|
+
import { startManagedGmail } from '../stations/attach-gmail.js';
|
|
7
|
+
import { startGmail } from '../stations/attach-browser.js';
|
|
8
|
+
import type { AttachOwner, StartAttach } from '../stations/attach-session.js';
|
|
9
|
+
import type { AttachOutcome, DriverHooks } from '../stations/attach-driver.js';
|
|
10
|
+
import { assertGmailNotDeleting } from './gmail-detach.js';
|
|
11
|
+
|
|
12
|
+
export function assertAttachOwner(owner: AttachOwner): void {
|
|
13
|
+
readLocalAgentFile(owner.agentId);
|
|
14
|
+
if (owner.organization !== undefined && owner.organization !== localOwner()) throw new ApiError('This box changed organization. Start again.', 403);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function gmailHost(): string {
|
|
18
|
+
const raw = publicBaseUrl();
|
|
19
|
+
const url = raw === null ? null : new URL(raw);
|
|
20
|
+
if (url === null || url.protocol !== 'https:' || url.username || url.password || url.pathname !== '/' || url.search || url.hash) throw new ApiError('Managed Gmail needs this box registered in Metro with its HTTPS address.', 400);
|
|
21
|
+
return url.host;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface Upgrade {
|
|
25
|
+
accountId: string;
|
|
26
|
+
previous: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function gmailInput(owner: AttachOwner, input: Record<string, unknown>): { fields: Record<string, unknown>; upgrade?: Upgrade } {
|
|
30
|
+
if (input.mode !== 'upgrade') return { fields: { ...input, sendEnabled: false } };
|
|
31
|
+
const accountId = stringOf(input.accountId);
|
|
32
|
+
assertGmailNotDeleting(owner.agentId, accountId);
|
|
33
|
+
const account = readLocalAgentFile(owner.agentId).stations.find((s) => s.station === 'gmail' && s.id === accountId);
|
|
34
|
+
if (account === undefined) throw new ApiError('No such Gmail connection.', 404);
|
|
35
|
+
if (account.config.sendEnabled !== false) throw new ApiError('Sending is already authorized for this Gmail connection.', 409);
|
|
36
|
+
return {
|
|
37
|
+
fields: { clientId: account.config.clientId, clientSecret: account.config.clientSecret, mailbox: account.config.accountEmail, sendEnabled: true, mode: account.config.managed === true ? 'managed' : 'byo' },
|
|
38
|
+
upgrade: { accountId, previous: stringOf(account.config.authorizationId ?? account.config.refreshToken) },
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const startGmailAttach: StartAttach = async (_station, input, hooks, owner) => {
|
|
43
|
+
assertAttachOwner(owner);
|
|
44
|
+
const { fields, upgrade } = gmailInput(owner, input);
|
|
45
|
+
const bound: DriverHooks = {
|
|
46
|
+
...hooks,
|
|
47
|
+
done: (outcome: AttachOutcome) => {
|
|
48
|
+
hooks.done({ ...outcome, config: { ...outcome.config, authorizationId: randomUUID(), ...(upgrade === undefined ? {} : { gmailUpgrade: upgrade }) } });
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
if (fields.mode !== 'managed') return startGmail(fields, bound);
|
|
52
|
+
const host = gmailHost();
|
|
53
|
+
return startManagedGmail({ host, owner, mailbox: fields.mailbox, sendEnabled: fields.sendEnabled === true, check: () => {
|
|
54
|
+
assertAttachOwner(owner);
|
|
55
|
+
if (gmailHost() !== host) throw new ApiError('This box changed address. Start again.', 409);
|
|
56
|
+
} }, bound);
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export function finishGmailUpgrade(owner: AttachOwner, config: Record<string, unknown>): ReturnType<typeof localRenewGmail> | null {
|
|
60
|
+
const value = config.gmailUpgrade;
|
|
61
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
62
|
+
const upgrade = value as Record<string, unknown>;
|
|
63
|
+
assertGmailNotDeleting(owner.agentId, stringOf(upgrade.accountId));
|
|
64
|
+
const saved = { ...config };
|
|
65
|
+
delete saved.gmailUpgrade;
|
|
66
|
+
return localRenewGmail(owner.agentId, stringOf(upgrade.accountId), stringOf(upgrade.previous), saved);
|
|
67
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { ApiError } from '@metro-labs/http/api-error';
|
|
2
|
+
import { localDetachAccount, localOwner, readLocalAgentFile } from '../agents/file-admin.js';
|
|
3
|
+
import { forwardTrainCall } from '../stations/train-call.js';
|
|
4
|
+
|
|
5
|
+
const deleting = new Set<string>();
|
|
6
|
+
const keyOf = (agentId: string, accountId: string): string => `${agentId}/${accountId}`;
|
|
7
|
+
|
|
8
|
+
export function assertGmailNotDeleting(agentId: string, accountId: string): void {
|
|
9
|
+
if (deleting.has(keyOf(agentId, accountId))) throw new ApiError('This Gmail connection is being deleted. Try again after it finishes.', 409);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async function revokeConnection(accountId: string, authorizationId: unknown): Promise<void> {
|
|
13
|
+
try {
|
|
14
|
+
const response = await forwardTrainCall('gmail', 'disconnect', { account: accountId, authorizationId });
|
|
15
|
+
const result = response.result as { revoked?: unknown } | undefined;
|
|
16
|
+
if (response.error !== undefined || result?.revoked !== true) throw new Error('revocation not confirmed');
|
|
17
|
+
} catch {
|
|
18
|
+
throw new ApiError('Google access could not be revoked. The Gmail channel was kept. Keep the Gmail service running and try deleting again.', 503);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const detachWithGmailRevocation: typeof localDetachAccount = async (agentId, station, accountId, dir) => {
|
|
23
|
+
const config = station === 'gmail' ? readLocalAgentFile(agentId, dir).stations.find((a) => a.station === station && a.id === accountId)?.config : undefined;
|
|
24
|
+
if (config?.managed !== true) return localDetachAccount(agentId, station, accountId, dir);
|
|
25
|
+
assertGmailNotDeleting(agentId, accountId);
|
|
26
|
+
const key = keyOf(agentId, accountId);
|
|
27
|
+
const owner = localOwner(dir);
|
|
28
|
+
deleting.add(key);
|
|
29
|
+
try {
|
|
30
|
+
await revokeConnection(accountId, config.authorizationId);
|
|
31
|
+
const current = readLocalAgentFile(agentId, dir).stations.find((a) => a.station === station && a.id === accountId)?.config;
|
|
32
|
+
if (localOwner(dir) !== owner || current?.authorizationId !== config.authorizationId) throw new ApiError('This box or Gmail connection changed during deletion. Google access was revoked; review the connection before trying again.', 409);
|
|
33
|
+
return await localDetachAccount(agentId, station, accountId, dir);
|
|
34
|
+
} finally {
|
|
35
|
+
deleting.delete(key);
|
|
36
|
+
}
|
|
37
|
+
};
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { ApiError } from '@metro-labs/http/api-error';
|
|
2
2
|
import { errMsg, log } from '@metro-labs/core/log';
|
|
3
3
|
import { AttachSessions } from '../stations/attach-session.js';
|
|
4
|
+
import { startInteractiveAttach } from '../stations/attach-interactive.js';
|
|
5
|
+
import { assertAttachOwner, finishGmailUpgrade, startGmailAttach } from './gmail-attach.js';
|
|
6
|
+
import { detachWithGmailRevocation } from './gmail-detach.js';
|
|
4
7
|
import { recentSenders } from '../agents/senders.js';
|
|
5
8
|
import { forwardTrainCall } from '../stations/train-call.js';
|
|
6
9
|
import { syncPluginServers } from '../connectors/plugin-sync.js';
|
|
@@ -36,7 +39,6 @@ import { blockedReason } from '../connectors/gates.js';
|
|
|
36
39
|
import {
|
|
37
40
|
setLocalOwner,
|
|
38
41
|
localAttachAccount,
|
|
39
|
-
localDetachAccount,
|
|
40
42
|
localSetAllowlist,
|
|
41
43
|
localSetPolicy,
|
|
42
44
|
localSetAccountEnabled,
|
|
@@ -64,11 +66,14 @@ export interface LocalModeDeps {
|
|
|
64
66
|
function attachSessions(deps: LocalModeDeps): AttachSessions {
|
|
65
67
|
return new AttachSessions({
|
|
66
68
|
authorize: (owner) => {
|
|
67
|
-
|
|
69
|
+
assertAttachOwner(owner);
|
|
68
70
|
return Promise.resolve();
|
|
69
71
|
},
|
|
70
|
-
|
|
71
|
-
|
|
72
|
+
start: (station, input, hooks, owner) => station === 'gmail' ? startGmailAttach(station, input, hooks, owner) : startInteractiveAttach(station, input, hooks),
|
|
73
|
+
complete: async (owner, station, config, active) => {
|
|
74
|
+
assertAttachOwner(owner);
|
|
75
|
+
if (!active()) throw new ApiError('This sign-in was cancelled or expired.', 409);
|
|
76
|
+
const ref = (station === 'gmail' ? finishGmailUpgrade(owner, config) : null) ?? await localAttachAccount(owner.agentId, station, config);
|
|
72
77
|
const activated = await deps.syncStations(station).then(
|
|
73
78
|
() => true,
|
|
74
79
|
(err: unknown) => {
|
|
@@ -92,10 +97,11 @@ function agentApi(deps: LocalModeDeps): AgentApiDeps {
|
|
|
92
97
|
capabilities: deps.capabilities,
|
|
93
98
|
...(deps.toolGroups === undefined ? {} : { toolGroups: deps.toolGroups }),
|
|
94
99
|
attachable: ATTACHABLE.filter((s) => s !== 'webhook'),
|
|
100
|
+
features: ['gmail-managed'],
|
|
95
101
|
connectorIds: connectorIdsOfLocalAgents,
|
|
96
102
|
prepareAccount: deps.prepareAccount,
|
|
97
103
|
attachAccount: localAttachAccount,
|
|
98
|
-
detachAccount:
|
|
104
|
+
detachAccount: detachWithGmailRevocation,
|
|
99
105
|
syncStations: deps.syncStations,
|
|
100
106
|
setAllowlist: localSetAllowlist,
|
|
101
107
|
setPolicy: localSetPolicy,
|
|
@@ -151,7 +157,7 @@ function bundleApi(deps: LocalModeDeps): BundleApiDeps {
|
|
|
151
157
|
const file = readLocalAgentFile(agentId);
|
|
152
158
|
const bundle: AgentBundle = {
|
|
153
159
|
version: 1,
|
|
154
|
-
agent: { id: file.id, name: file.name ?? '', stations: file.stations },
|
|
160
|
+
agent: { id: file.id, name: file.name ?? '', stations: file.stations.filter((a) => a.station !== 'gmail' || a.config.managed !== true) },
|
|
155
161
|
connectors: readLocalConnectors().map((c) => ({ id: c.id, name: c.name, url: c.url, config: { ...c.config } })),
|
|
156
162
|
};
|
|
157
163
|
return Promise.resolve(bundle);
|
|
@@ -34,6 +34,7 @@ function refused(err: unknown, provider: string): StationAttachError {
|
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
function browserAttach(login: BrowserLogin, hooks: DriverHooks, how: BrowserAttach): StartedAttach {
|
|
37
|
+
let cancelled = false;
|
|
37
38
|
const finish = async (input: StepInput): Promise<void> => {
|
|
38
39
|
const state = text(input.state);
|
|
39
40
|
if (state !== login.state) throw new StationAttachError(OTHER_ATTEMPT, 400);
|
|
@@ -45,7 +46,8 @@ function browserAttach(login: BrowserLogin, hooks: DriverHooks, how: BrowserAtta
|
|
|
45
46
|
const code = text(input.code);
|
|
46
47
|
if (code === '') throw new StationAttachError(`${how.provider} did not send a sign-in code back`, 400);
|
|
47
48
|
try {
|
|
48
|
-
|
|
49
|
+
const outcome = await login.finish(code, state);
|
|
50
|
+
if (!cancelled) hooks.done(outcome);
|
|
49
51
|
} catch (err) {
|
|
50
52
|
hooks.fail(said(err, `Metro could not finish the ${how.provider} sign-in.`));
|
|
51
53
|
}
|
|
@@ -55,7 +57,10 @@ function browserAttach(login: BrowserLogin, hooks: DriverHooks, how: BrowserAtta
|
|
|
55
57
|
prompt: { step: 'browser', prompt: how.prompt, authorizeUrl: login.authorizeUrl },
|
|
56
58
|
expiresAt: Date.now() + BROWSER_TTL_MS,
|
|
57
59
|
driver: {
|
|
58
|
-
cancel: () =>
|
|
60
|
+
cancel: () => {
|
|
61
|
+
cancelled = true;
|
|
62
|
+
return how.cancel?.() ?? Promise.resolve();
|
|
63
|
+
},
|
|
59
64
|
submit: (input) => (input.mode === 'device' && device !== undefined ? device() : finish(input)),
|
|
60
65
|
},
|
|
61
66
|
};
|
|
@@ -94,7 +99,7 @@ export async function startGmail(input: Record<string, unknown>, hooks: DriverHo
|
|
|
94
99
|
const { GmailBrowserLogin, failureOf } = await import('@metro-labs/gmail/login');
|
|
95
100
|
let login;
|
|
96
101
|
try {
|
|
97
|
-
login = new GmailBrowserLogin({ clientId: input.clientId, clientSecret: input.clientSecret, mailbox: input.mailbox });
|
|
102
|
+
login = new GmailBrowserLogin({ clientId: input.clientId, clientSecret: input.clientSecret, mailbox: input.mailbox, sendEnabled: input.sendEnabled === true });
|
|
98
103
|
} catch (err) {
|
|
99
104
|
throw refused(err, 'Google');
|
|
100
105
|
}
|
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
|
12
|
-
clientSecret
|
|
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
|
-
|
|
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,
|
|
49
|
-
const client = { clientId: cfg.clientId, clientSecret: cfg.clientSecret };
|
|
50
|
-
this.keeper = new TokenKeeper(this.state,
|
|
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
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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:
|
|
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
|
|
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<
|
|
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(
|
|
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
|
};
|
package/runtime/runtime.json
CHANGED
|
@@ -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);
|