@stage-labs/metro 0.1.0-beta.25 → 0.1.0-beta.27
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/dist/cli.js +6 -0
- package/dist/plugin.js +82 -0
- package/dist/prompt.js +1 -1
- package/package.json +1 -1
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/boot.ts +4 -3
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/cli-pair-api.ts +20 -8
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/connector-api.ts +2 -1
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/connector-json.ts +14 -24
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/connector-oauth.ts +1 -1
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/http.ts +2 -0
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/relay.ts +302 -0
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/run-api.ts +10 -5
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/session-apis.ts +2 -0
- package/runtime/node_modules/@metro-labs/mcp/src/db/connector-relay.ts +126 -0
- package/runtime/node_modules/@metro-labs/mcp/src/db/connectors.ts +3 -24
- package/runtime/runtime.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -6,6 +6,7 @@ import { clearToken, credentialsPath, metroUrl, metroWebUrl, writeToken, } from
|
|
|
6
6
|
import { detach, lockedBy, probe, runningPid, stopAll, tail } from './control.js';
|
|
7
7
|
import { tailEvents } from './tail.js';
|
|
8
8
|
import { assertAgentId, daemonPlan, localUrl, readRunToken, runDaemon, runtimeDir, writeRunToken, } from './runtime.js';
|
|
9
|
+
import { installPlugin, syncPluginServers } from './plugin.js';
|
|
9
10
|
import { update } from './update.js';
|
|
10
11
|
import { currentVersion } from './version.js';
|
|
11
12
|
const USAGE = `metro — the command line for your MCP connectors
|
|
@@ -24,6 +25,7 @@ const USAGE = `metro — the command line for your MCP connectors
|
|
|
24
25
|
metro logout forget this machine's sign-in
|
|
25
26
|
metro whoami print the account and collection this machine may read
|
|
26
27
|
metro mcp print the mcpServers block for the authorized collection
|
|
28
|
+
metro plugin set up the Claude Code plugin (connector servers + /metro:login)
|
|
27
29
|
metro update update to the newest published version
|
|
28
30
|
metro version print this CLI's version
|
|
29
31
|
|
|
@@ -102,6 +104,9 @@ async function login() {
|
|
|
102
104
|
const { token, email, collection } = await claimCode(await askSecret('Paste the code here (input is hidden): '));
|
|
103
105
|
writeToken(token);
|
|
104
106
|
process.stderr.write(`Authorized '${collection}' for ${email}. Stored in ${credentialsPath()}\n`);
|
|
107
|
+
if (syncPluginServers())
|
|
108
|
+
process.stderr.write('Claude Code plugin refreshed — new sessions have the connectors; ' +
|
|
109
|
+
'run /reload-plugins in any session already open.\n');
|
|
105
110
|
}
|
|
106
111
|
async function whoami() {
|
|
107
112
|
const { email, collection } = await whoisAuthorized();
|
|
@@ -131,6 +136,7 @@ const COMMANDS = {
|
|
|
131
136
|
process.stdout.write(`${await mcpServers()}\n`);
|
|
132
137
|
return 0;
|
|
133
138
|
},
|
|
139
|
+
plugin: installPlugin,
|
|
134
140
|
update,
|
|
135
141
|
version: async () => {
|
|
136
142
|
process.stdout.write(`${currentVersion()}\n`);
|
package/dist/plugin.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { readToken } from './store.js';
|
|
6
|
+
const MARKETPLACE_REPO = 'bonustrack/metro';
|
|
7
|
+
const PLUGIN_SPEC = 'metro@metro';
|
|
8
|
+
export function installPathFrom(raw) {
|
|
9
|
+
let parsed;
|
|
10
|
+
try {
|
|
11
|
+
parsed = JSON.parse(raw);
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
if (typeof parsed !== 'object' || parsed === null)
|
|
17
|
+
return null;
|
|
18
|
+
const plugins = parsed.plugins;
|
|
19
|
+
if (typeof plugins !== 'object' || plugins === null)
|
|
20
|
+
return null;
|
|
21
|
+
const entries = plugins[PLUGIN_SPEC];
|
|
22
|
+
if (!Array.isArray(entries))
|
|
23
|
+
return null;
|
|
24
|
+
const first = entries[0];
|
|
25
|
+
return typeof first?.installPath === 'string' ? first.installPath : null;
|
|
26
|
+
}
|
|
27
|
+
export function pluginInstallPath() {
|
|
28
|
+
const registry = join(homedir(), '.claude', 'plugins', 'installed_plugins.json');
|
|
29
|
+
let raw;
|
|
30
|
+
try {
|
|
31
|
+
raw = readFileSync(registry, 'utf8');
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
const path = installPathFrom(raw);
|
|
37
|
+
if (path === null)
|
|
38
|
+
return null;
|
|
39
|
+
return existsSync(join(path, 'bin', 'metro-plugin.mjs')) ? path : null;
|
|
40
|
+
}
|
|
41
|
+
function runClaude(args) {
|
|
42
|
+
const res = spawnSync('claude', args, { encoding: 'utf8' });
|
|
43
|
+
if (res.error !== undefined)
|
|
44
|
+
throw new Error('the `claude` command is not on PATH — install Claude Code first');
|
|
45
|
+
const output = `${res.stdout ?? ''}${res.stderr ?? ''}`;
|
|
46
|
+
return { ok: res.status === 0, output };
|
|
47
|
+
}
|
|
48
|
+
const tolerable = (output) => /already|exists|latest version/i.test(output);
|
|
49
|
+
export function syncPluginServers() {
|
|
50
|
+
const path = pluginInstallPath();
|
|
51
|
+
if (path === null || readToken() === null)
|
|
52
|
+
return false;
|
|
53
|
+
const script = join(path, 'bin', 'metro-plugin.mjs');
|
|
54
|
+
const refreshed = spawnSync(process.execPath, [script, 'refresh'], {
|
|
55
|
+
stdio: ['ignore', 'inherit', 'inherit'],
|
|
56
|
+
});
|
|
57
|
+
if (refreshed.status !== 0)
|
|
58
|
+
return false;
|
|
59
|
+
runClaude(['plugin', 'update', PLUGIN_SPEC]);
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
export async function installPlugin() {
|
|
63
|
+
const added = runClaude(['plugin', 'marketplace', 'add', MARKETPLACE_REPO]);
|
|
64
|
+
if (!added.ok && !tolerable(added.output)) {
|
|
65
|
+
process.stderr.write(added.output);
|
|
66
|
+
return 1;
|
|
67
|
+
}
|
|
68
|
+
const installed = runClaude(['plugin', 'install', PLUGIN_SPEC]);
|
|
69
|
+
if (!installed.ok && !tolerable(installed.output)) {
|
|
70
|
+
process.stderr.write(installed.output);
|
|
71
|
+
return 1;
|
|
72
|
+
}
|
|
73
|
+
process.stderr.write('Claude Code plugin installed.\n');
|
|
74
|
+
if (syncPluginServers()) {
|
|
75
|
+
process.stderr.write('Connector servers loaded — new Claude sessions have them; ' +
|
|
76
|
+
'run /reload-plugins in any session already open.\n');
|
|
77
|
+
return 0;
|
|
78
|
+
}
|
|
79
|
+
process.stderr.write('Now sign in: metro login (get a code at https://metro.box/#/authorize), ' +
|
|
80
|
+
'or run /metro:login <code> inside Claude Code.\n');
|
|
81
|
+
return Promise.resolve(0);
|
|
82
|
+
}
|
package/dist/prompt.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stage-labs/metro",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.27",
|
|
4
4
|
"description": "The metro command line. Sign in once per machine, then hand your MCP connector list to Claude Code without the credentials touching disk, argv or shell history.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -63,13 +63,14 @@ import {
|
|
|
63
63
|
createPendingConnectorForEmail,
|
|
64
64
|
deleteConnectorForEmail,
|
|
65
65
|
disconnectConnectorForEmail,
|
|
66
|
+
connectorNamesByIds,
|
|
66
67
|
getConnectorForEmail,
|
|
67
68
|
listConnectorsForEmail,
|
|
68
|
-
listFreshConnectorsByIds,
|
|
69
69
|
renameConnectorForEmail,
|
|
70
70
|
reconnectConnectorForEmail,
|
|
71
71
|
verifyConnectorForEmail,
|
|
72
72
|
} from '../db/connectors.js';
|
|
73
|
+
import { relayTarget } from '../db/connector-relay.js';
|
|
73
74
|
import {
|
|
74
75
|
addMemberForEmail,
|
|
75
76
|
createProjectForEmail,
|
|
@@ -236,7 +237,7 @@ const projectApi: ProjectApiDeps = {
|
|
|
236
237
|
|
|
237
238
|
const connectorApi: ConnectorApiDeps = {
|
|
238
239
|
listConnectors: listConnectorsForEmail,
|
|
239
|
-
|
|
240
|
+
connectorNamesByIds,
|
|
240
241
|
listCollections: listCollectionsForEmail,
|
|
241
242
|
getCollection: getCollectionForEmail,
|
|
242
243
|
createCollection: createCollectionForEmail,
|
|
@@ -262,7 +263,7 @@ async function main(): Promise<void> {
|
|
|
262
263
|
const metroMcp = await createMetroMcp();
|
|
263
264
|
webhookServer = await startWebhookServer(
|
|
264
265
|
emit,
|
|
265
|
-
{ agentApi, connectorApi, projectApi, runApi },
|
|
266
|
+
{ agentApi, connectorApi, projectApi, runApi, relayApi: { target: relayTarget } },
|
|
266
267
|
metroMcp.httpHandler,
|
|
267
268
|
metroCall,
|
|
268
269
|
);
|
|
@@ -7,8 +7,10 @@ import {
|
|
|
7
7
|
readJsonBody,
|
|
8
8
|
sendJson,
|
|
9
9
|
} from './api-http.js';
|
|
10
|
+
import { extractToken } from '../mcp/request-identity.js';
|
|
11
|
+
import { publicBaseOrDefault } from './attach-serve.js';
|
|
10
12
|
import { CLI_CODE_RE, takeCliCode } from './cli-pair.js';
|
|
11
|
-
import {
|
|
13
|
+
import { relayServersJson } from './connector-json.js';
|
|
12
14
|
import { sessionTtlFromEnv } from './google-oauth.js';
|
|
13
15
|
import { signCliToken } from './session.js';
|
|
14
16
|
import type { ConnectorApiDeps } from './connector-api.js';
|
|
@@ -38,11 +40,16 @@ async function handleClaim(
|
|
|
38
40
|
sendJson(req, res, 503, { error: 'sign-in is not configured' });
|
|
39
41
|
return;
|
|
40
42
|
}
|
|
41
|
-
const
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
:
|
|
43
|
+
const raw = bodyField(await readJsonBody(req), 'code');
|
|
44
|
+
const code = typeof raw === 'string' ? raw.trim() : '';
|
|
45
|
+
if (!CLI_CODE_RE.test(code)) {
|
|
46
|
+
sendJson(req, res, 400, {
|
|
47
|
+
error:
|
|
48
|
+
'that does not look like a collection code — metro login wants the mc_… code from metro.box/#/authorize',
|
|
49
|
+
});
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const taken = takeCliCode(code);
|
|
46
53
|
if (taken === undefined) {
|
|
47
54
|
sendJson(req, res, 400, {
|
|
48
55
|
error: 'that code has expired or was already used',
|
|
@@ -75,8 +82,13 @@ async function handleRead(
|
|
|
75
82
|
sendJson(req, res, 200, { email: who.email, collection: collection.name });
|
|
76
83
|
return;
|
|
77
84
|
}
|
|
78
|
-
const
|
|
79
|
-
|
|
85
|
+
const entries = await deps.connectorNamesByIds(collection.connectorIds);
|
|
86
|
+
const json = relayServersJson(
|
|
87
|
+
entries,
|
|
88
|
+
publicBaseOrDefault(),
|
|
89
|
+
extractToken(req) ?? '',
|
|
90
|
+
);
|
|
91
|
+
sendJson(req, res, 200, { json, collection: collection.name });
|
|
80
92
|
}
|
|
81
93
|
|
|
82
94
|
export function handleCliPairRequest(
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
} from './api-http.js';
|
|
13
13
|
import { parseId } from '../db/ids.js';
|
|
14
14
|
import type { ConnectorCollectionRow } from '../db/connector-collections.js';
|
|
15
|
+
import type { RelayServerEntry } from './connector-json.js';
|
|
15
16
|
import {
|
|
16
17
|
handleCallback,
|
|
17
18
|
handleConnect,
|
|
@@ -34,7 +35,7 @@ const asText = (value: unknown): string =>
|
|
|
34
35
|
|
|
35
36
|
export interface ConnectorApiDeps extends OAuthRouteDeps {
|
|
36
37
|
listConnectors: (email: string, project: string) => Promise<Connector[]>;
|
|
37
|
-
|
|
38
|
+
connectorNamesByIds: (ids: string[]) => Promise<RelayServerEntry[]>;
|
|
38
39
|
listCollections: (
|
|
39
40
|
email: string,
|
|
40
41
|
project: string,
|
|
@@ -1,31 +1,21 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
export interface ConnectorEntry {
|
|
1
|
+
export interface RelayServerEntry {
|
|
2
|
+
id: string;
|
|
4
3
|
name: string;
|
|
5
|
-
url: string;
|
|
6
|
-
transport: ConnectorTransport;
|
|
7
|
-
header: string | null;
|
|
8
|
-
secret: string | null;
|
|
9
|
-
bearer: string | null;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
function headersOf(entry: ConnectorEntry): Record<string, string> | null {
|
|
13
|
-
const { header, secret, bearer } = entry;
|
|
14
|
-
if (typeof header === 'string' && typeof secret === 'string')
|
|
15
|
-
return { [header]: secret };
|
|
16
|
-
if (typeof bearer === 'string' && bearer !== '')
|
|
17
|
-
return { Authorization: `Bearer ${bearer}` };
|
|
18
|
-
return null;
|
|
19
4
|
}
|
|
20
5
|
|
|
21
|
-
|
|
22
|
-
const base = { type: entry.transport, url: entry.url };
|
|
23
|
-
const headers = headersOf(entry);
|
|
24
|
-
return headers === null ? base : { ...base, headers };
|
|
25
|
-
}
|
|
6
|
+
export const NAME_PREFIX = 'metro.box ';
|
|
26
7
|
|
|
27
|
-
export function
|
|
8
|
+
export function relayServersJson(
|
|
9
|
+
entries: RelayServerEntry[],
|
|
10
|
+
base: string,
|
|
11
|
+
cliToken: string,
|
|
12
|
+
): string {
|
|
28
13
|
const mcpServers: Record<string, unknown> = {};
|
|
29
|
-
for (const entry of entries)
|
|
14
|
+
for (const entry of entries)
|
|
15
|
+
mcpServers[`${NAME_PREFIX}${entry.name}`] = {
|
|
16
|
+
type: 'http',
|
|
17
|
+
url: `${base}/relay/${entry.id}`,
|
|
18
|
+
headers: { Authorization: `Bearer ${cliToken}` },
|
|
19
|
+
};
|
|
30
20
|
return JSON.stringify({ mcpServers }, null, 2);
|
|
31
21
|
}
|
|
@@ -17,7 +17,7 @@ import { startPending, takePending, type PendingAuth } from './oauth-pending.js'
|
|
|
17
17
|
|
|
18
18
|
export { takePending, type PendingAuth };
|
|
19
19
|
|
|
20
|
-
const REFRESH_SKEW_MS =
|
|
20
|
+
const REFRESH_SKEW_MS = 300_000;
|
|
21
21
|
|
|
22
22
|
export const CALLBACK_PATH = '/api/connectors/callback';
|
|
23
23
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { handleSessionApis, type SessionApis } from './session-apis.js';
|
|
2
|
+
import { handleRelayRequest } from './relay.js';
|
|
2
3
|
import {
|
|
3
4
|
createServer,
|
|
4
5
|
type IncomingMessage,
|
|
@@ -334,6 +335,7 @@ async function handlePreMcpRoutes(
|
|
|
334
335
|
if (handleSessionApis(req, res, apis)) return true;
|
|
335
336
|
if (handleUploadRequest(req, res)) return true;
|
|
336
337
|
if (handleAttachRequest(req, res)) return true;
|
|
338
|
+
if (apis.relayApi && handleRelayRequest(req, res, apis.relayApi)) return true;
|
|
337
339
|
if (await handleWebhookRoute(req, res, emit)) return true;
|
|
338
340
|
return Boolean(monitorCall && handleMonitorRequest(req, res, monitorCall));
|
|
339
341
|
}
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
import type { RelayTarget } from '../db/connector-relay.js';
|
|
3
|
+
import { cliIdentity } from './api-http.js';
|
|
4
|
+
import { ApiError } from './api-error.js';
|
|
5
|
+
import { errMsg, log } from './log.js';
|
|
6
|
+
|
|
7
|
+
export interface RelayApiDeps {
|
|
8
|
+
target: (
|
|
9
|
+
collectionId: string,
|
|
10
|
+
connectorId: string,
|
|
11
|
+
force: boolean,
|
|
12
|
+
) => Promise<RelayTarget>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const ID_PATH_RE = /^\/relay\/([A-Za-z0-9][A-Za-z0-9_-]{10})$/;
|
|
16
|
+
const METHODS = new Set(['POST', 'GET', 'DELETE']);
|
|
17
|
+
const RELAY_BODY_MAX = 8 * 1024 * 1024;
|
|
18
|
+
const FIRST_BYTE_MS = 120_000;
|
|
19
|
+
const PASS_REQ = [
|
|
20
|
+
'accept',
|
|
21
|
+
'content-type',
|
|
22
|
+
'mcp-session-id',
|
|
23
|
+
'mcp-protocol-version',
|
|
24
|
+
'last-event-id',
|
|
25
|
+
] as const;
|
|
26
|
+
const PASS_RES = [
|
|
27
|
+
'content-type',
|
|
28
|
+
'mcp-session-id',
|
|
29
|
+
'mcp-protocol-version',
|
|
30
|
+
'cache-control',
|
|
31
|
+
] as const;
|
|
32
|
+
|
|
33
|
+
const keepaliveMs = (): number =>
|
|
34
|
+
Number(process.env.METRO_RELAY_KEEPALIVE_MS) || 25_000;
|
|
35
|
+
|
|
36
|
+
const reconnectUrl = (id: string): string =>
|
|
37
|
+
`https://metro.box/#/connector/${id}`;
|
|
38
|
+
|
|
39
|
+
function answer(
|
|
40
|
+
res: ServerResponse,
|
|
41
|
+
status: number,
|
|
42
|
+
body: Record<string, unknown>,
|
|
43
|
+
): void {
|
|
44
|
+
if (res.headersSent) return;
|
|
45
|
+
res.writeHead(status, { 'content-type': 'application/json' });
|
|
46
|
+
res.end(JSON.stringify(body));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const signinAnswer = (res: ServerResponse, id: string): void => {
|
|
50
|
+
answer(res, 424, {
|
|
51
|
+
error: 'this connector needs signing in again',
|
|
52
|
+
reconnect: reconnectUrl(id),
|
|
53
|
+
});
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
function closeIfBodyUnread(req: IncomingMessage, res: ServerResponse): void {
|
|
57
|
+
if (req.readableEnded) return;
|
|
58
|
+
const drop = (): void => {
|
|
59
|
+
req.socket?.destroy();
|
|
60
|
+
};
|
|
61
|
+
if (res.writableFinished) drop();
|
|
62
|
+
else res.once('finish', drop);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function readCapped(req: IncomingMessage): Promise<Uint8Array<ArrayBuffer>> {
|
|
66
|
+
const declared = Number(req.headers['content-length']);
|
|
67
|
+
if (Number.isFinite(declared) && declared > RELAY_BODY_MAX)
|
|
68
|
+
throw new ApiError('relay body exceeds 8 MiB', 413);
|
|
69
|
+
const chunks: Buffer[] = [];
|
|
70
|
+
let total = 0;
|
|
71
|
+
for await (const chunk of req) {
|
|
72
|
+
const buf = chunk as Buffer;
|
|
73
|
+
total += buf.length;
|
|
74
|
+
if (total > RELAY_BODY_MAX)
|
|
75
|
+
throw new ApiError('relay body exceeds 8 MiB', 413);
|
|
76
|
+
chunks.push(buf);
|
|
77
|
+
}
|
|
78
|
+
const merged = Buffer.concat(chunks);
|
|
79
|
+
const out = new Uint8Array(new ArrayBuffer(merged.byteLength));
|
|
80
|
+
out.set(merged);
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function upstreamHeaders(
|
|
85
|
+
req: IncomingMessage,
|
|
86
|
+
injected: Record<string, string>,
|
|
87
|
+
): Record<string, string> {
|
|
88
|
+
const out: Record<string, string> = {};
|
|
89
|
+
for (const name of PASS_REQ) {
|
|
90
|
+
const value = req.headers[name];
|
|
91
|
+
if (typeof value === 'string') out[name] = value;
|
|
92
|
+
}
|
|
93
|
+
return { ...out, ...injected };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function forward(
|
|
97
|
+
req: IncomingMessage,
|
|
98
|
+
target: { url: string; headers: Record<string, string> },
|
|
99
|
+
body: Uint8Array<ArrayBuffer> | null,
|
|
100
|
+
signal: AbortSignal,
|
|
101
|
+
): Promise<Response> {
|
|
102
|
+
const firstByte = setTimeout(() => {
|
|
103
|
+
log.warn({ url: target.url }, 'relay: upstream sent no response in time');
|
|
104
|
+
}, FIRST_BYTE_MS);
|
|
105
|
+
firstByte.unref?.();
|
|
106
|
+
try {
|
|
107
|
+
return await fetch(target.url, {
|
|
108
|
+
method: req.method,
|
|
109
|
+
headers: upstreamHeaders(req, target.headers),
|
|
110
|
+
...(body === null ? {} : { body }),
|
|
111
|
+
signal,
|
|
112
|
+
redirect: 'manual',
|
|
113
|
+
});
|
|
114
|
+
} finally {
|
|
115
|
+
clearTimeout(firstByte);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
interface Keepalive {
|
|
120
|
+
touch: () => void;
|
|
121
|
+
stop: () => void;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function startKeepalive(res: ServerResponse): Keepalive {
|
|
125
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
126
|
+
const arm = (): void => {
|
|
127
|
+
timer = setTimeout(() => {
|
|
128
|
+
try {
|
|
129
|
+
res.write(': keepalive\n\n');
|
|
130
|
+
} catch {
|
|
131
|
+
log.debug('relay: keepalive write failed');
|
|
132
|
+
}
|
|
133
|
+
arm();
|
|
134
|
+
}, keepaliveMs());
|
|
135
|
+
timer.unref?.();
|
|
136
|
+
};
|
|
137
|
+
arm();
|
|
138
|
+
return {
|
|
139
|
+
touch: (): void => {
|
|
140
|
+
if (timer) clearTimeout(timer);
|
|
141
|
+
arm();
|
|
142
|
+
},
|
|
143
|
+
stop: (): void => {
|
|
144
|
+
if (timer) clearTimeout(timer);
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function pumpBody(
|
|
150
|
+
res: ServerResponse,
|
|
151
|
+
body: ReadableStream<Uint8Array>,
|
|
152
|
+
touch: () => void,
|
|
153
|
+
): Promise<void> {
|
|
154
|
+
const reader = body.getReader();
|
|
155
|
+
for (;;) {
|
|
156
|
+
const { done, value } = await reader.read();
|
|
157
|
+
if (done) return;
|
|
158
|
+
touch();
|
|
159
|
+
const ok = res.write(value);
|
|
160
|
+
if (!ok)
|
|
161
|
+
await new Promise((resolve) => {
|
|
162
|
+
res.once('drain', resolve);
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function pipe(res: ServerResponse, upstream: Response): Promise<void> {
|
|
168
|
+
const headers: Record<string, string> = {};
|
|
169
|
+
for (const name of PASS_RES) {
|
|
170
|
+
const value = upstream.headers.get(name);
|
|
171
|
+
if (value !== null) headers[name] = value;
|
|
172
|
+
}
|
|
173
|
+
const sse = (headers['content-type'] ?? '').includes('text/event-stream');
|
|
174
|
+
if (sse) headers['x-accel-buffering'] = 'no';
|
|
175
|
+
res.writeHead(upstream.status, headers);
|
|
176
|
+
if (upstream.body === null) {
|
|
177
|
+
res.end();
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
const keepalive = sse ? startKeepalive(res) : null;
|
|
181
|
+
try {
|
|
182
|
+
await pumpBody(res, upstream.body, () => keepalive?.touch());
|
|
183
|
+
} finally {
|
|
184
|
+
keepalive?.stop();
|
|
185
|
+
}
|
|
186
|
+
res.end();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const authFailed = (status: number): boolean =>
|
|
190
|
+
status === 401 || status === 403;
|
|
191
|
+
|
|
192
|
+
type Exchanged =
|
|
193
|
+
| { kind: 'response'; upstream: Response }
|
|
194
|
+
| { kind: 'missing' }
|
|
195
|
+
| { kind: 'signin' };
|
|
196
|
+
|
|
197
|
+
async function exchange(
|
|
198
|
+
req: IncomingMessage,
|
|
199
|
+
collectionId: string,
|
|
200
|
+
connectorId: string,
|
|
201
|
+
deps: RelayApiDeps,
|
|
202
|
+
body: Uint8Array<ArrayBuffer> | null,
|
|
203
|
+
signal: AbortSignal,
|
|
204
|
+
): Promise<Exchanged> {
|
|
205
|
+
const target = await deps.target(collectionId, connectorId, false);
|
|
206
|
+
if (target.kind !== 'ok') return { kind: target.kind };
|
|
207
|
+
let upstream = await forward(req, target, body, signal);
|
|
208
|
+
if (!authFailed(upstream.status)) return { kind: 'response', upstream };
|
|
209
|
+
await upstream.body?.cancel();
|
|
210
|
+
const fresh = await deps.target(collectionId, connectorId, true);
|
|
211
|
+
if (fresh.kind !== 'ok') return { kind: 'signin' };
|
|
212
|
+
upstream = await forward(req, fresh, body, signal);
|
|
213
|
+
if (!authFailed(upstream.status)) return { kind: 'response', upstream };
|
|
214
|
+
await upstream.body?.cancel();
|
|
215
|
+
return { kind: 'signin' };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function relayExchange(
|
|
219
|
+
req: IncomingMessage,
|
|
220
|
+
res: ServerResponse,
|
|
221
|
+
collectionId: string,
|
|
222
|
+
connectorId: string,
|
|
223
|
+
deps: RelayApiDeps,
|
|
224
|
+
): Promise<void> {
|
|
225
|
+
const control = new AbortController();
|
|
226
|
+
const bail = (): void => {
|
|
227
|
+
if (!res.writableEnded) control.abort();
|
|
228
|
+
};
|
|
229
|
+
res.once('close', bail);
|
|
230
|
+
res.socket?.once('close', bail);
|
|
231
|
+
res.once('finish', () => {
|
|
232
|
+
res.socket?.removeListener('close', bail);
|
|
233
|
+
});
|
|
234
|
+
const body = req.method === 'POST' ? await readCapped(req) : null;
|
|
235
|
+
const out = await exchange(
|
|
236
|
+
req,
|
|
237
|
+
collectionId,
|
|
238
|
+
connectorId,
|
|
239
|
+
deps,
|
|
240
|
+
body,
|
|
241
|
+
control.signal,
|
|
242
|
+
);
|
|
243
|
+
if (out.kind === 'missing') {
|
|
244
|
+
answer(res, 404, { error: 'no such connector' });
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
if (out.kind === 'signin') {
|
|
248
|
+
signinAnswer(res, connectorId);
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
if (out.upstream.status >= 300 && out.upstream.status < 400) {
|
|
252
|
+
await out.upstream.body?.cancel();
|
|
253
|
+
answer(res, 502, { error: 'the connector redirected; metro does not follow' });
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
await pipe(res, out.upstream);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function dispatch(
|
|
260
|
+
req: IncomingMessage,
|
|
261
|
+
res: ServerResponse,
|
|
262
|
+
connectorId: string,
|
|
263
|
+
deps: RelayApiDeps,
|
|
264
|
+
): void {
|
|
265
|
+
const who = cliIdentity(req);
|
|
266
|
+
if (who === null) {
|
|
267
|
+
answer(res, 401, { error: 'unauthorized' });
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
relayExchange(req, res, who.collectionId, connectorId, deps).catch(
|
|
271
|
+
(err: unknown) => {
|
|
272
|
+
if (err instanceof ApiError) {
|
|
273
|
+
answer(res, err.status, { error: err.message });
|
|
274
|
+
} else {
|
|
275
|
+
log.warn({ err: errMsg(err), connector: connectorId }, 'relay: failed');
|
|
276
|
+
answer(res, 502, { error: 'metro could not reach the connector' });
|
|
277
|
+
if (!res.writableEnded) res.end();
|
|
278
|
+
}
|
|
279
|
+
closeIfBodyUnread(req, res);
|
|
280
|
+
},
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export function handleRelayRequest(
|
|
285
|
+
req: IncomingMessage,
|
|
286
|
+
res: ServerResponse,
|
|
287
|
+
deps: RelayApiDeps,
|
|
288
|
+
): boolean {
|
|
289
|
+
const path = (req.url ?? '').split('?')[0] ?? '';
|
|
290
|
+
if (path !== '/relay' && !path.startsWith('/relay/')) return false;
|
|
291
|
+
const match = ID_PATH_RE.exec(path);
|
|
292
|
+
if (match?.[1] === undefined) {
|
|
293
|
+
answer(res, 404, { error: 'no such connector' });
|
|
294
|
+
return true;
|
|
295
|
+
}
|
|
296
|
+
if (!METHODS.has(req.method ?? '')) {
|
|
297
|
+
answer(res, 405, { error: 'method not allowed' });
|
|
298
|
+
return true;
|
|
299
|
+
}
|
|
300
|
+
dispatch(req, res, match[1], deps);
|
|
301
|
+
return true;
|
|
302
|
+
}
|
|
@@ -51,11 +51,16 @@ async function handleClaim(
|
|
|
51
51
|
return;
|
|
52
52
|
}
|
|
53
53
|
const body = await readJsonBody(req);
|
|
54
|
-
const
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
:
|
|
54
|
+
const raw = bodyField(body, 'code');
|
|
55
|
+
const code = typeof raw === 'string' ? raw.trim() : '';
|
|
56
|
+
if (!RUN_CODE_RE.test(code)) {
|
|
57
|
+
sendJson(req, res, 400, {
|
|
58
|
+
error:
|
|
59
|
+
'that does not look like a runtime code — metro start wants the mr_… code from the agent page',
|
|
60
|
+
});
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const taken = takeRunCode(code);
|
|
59
64
|
if (taken === undefined) {
|
|
60
65
|
sendJson(req, res, 400, {
|
|
61
66
|
error: 'that code has expired or was already used',
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
import { handleCollectionApiRequest } from './collection-api.js';
|
|
9
9
|
import { handleCliPairRequest } from './cli-pair-api.js';
|
|
10
10
|
import { handleRunApiRequest, type RunApiDeps } from './run-api.js';
|
|
11
|
+
import type { RelayApiDeps } from './relay.js';
|
|
11
12
|
import {
|
|
12
13
|
handleProjectApiRequest,
|
|
13
14
|
type ProjectApiDeps,
|
|
@@ -18,6 +19,7 @@ export interface SessionApis {
|
|
|
18
19
|
connectorApi?: ConnectorApiDeps;
|
|
19
20
|
projectApi?: ProjectApiDeps;
|
|
20
21
|
runApi?: RunApiDeps;
|
|
22
|
+
relayApi?: RelayApiDeps;
|
|
21
23
|
}
|
|
22
24
|
|
|
23
25
|
export function handleSessionApis(
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { and, eq } from 'drizzle-orm';
|
|
2
|
+
import { oauthExpired, refreshOAuth } from '../daemon/connector-oauth.js';
|
|
3
|
+
import {
|
|
4
|
+
parseConnectorUrl,
|
|
5
|
+
type ConnectorAuth,
|
|
6
|
+
type OAuthAuth,
|
|
7
|
+
} from '../daemon/connector-verify.js';
|
|
8
|
+
import { errMsg, log } from '../daemon/log.js';
|
|
9
|
+
import { readConfig, type ConnectorConfig } from './connector-config.js';
|
|
10
|
+
import { getDb } from './client.js';
|
|
11
|
+
import { collectionItems, connectors } from './schema.js';
|
|
12
|
+
|
|
13
|
+
export type RelayTarget =
|
|
14
|
+
| { kind: 'ok'; url: string; headers: Record<string, string> }
|
|
15
|
+
| { kind: 'missing' }
|
|
16
|
+
| { kind: 'signin' };
|
|
17
|
+
|
|
18
|
+
type ConnectorRow = typeof connectors.$inferSelect;
|
|
19
|
+
|
|
20
|
+
export const bearerHeaders = (token: string): Record<string, string> => ({
|
|
21
|
+
Authorization: `Bearer ${token}`,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
export function staleUsable(auth: OAuthAuth, now = Date.now()): boolean {
|
|
25
|
+
return auth.expiresAt === undefined || auth.expiresAt > now;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function headerAuthHeaders(
|
|
29
|
+
auth: ConnectorAuth,
|
|
30
|
+
): Record<string, string> | null {
|
|
31
|
+
return auth.kind === 'header' ? { [auth.name]: auth.value } : null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function memberRow(
|
|
35
|
+
collectionId: string,
|
|
36
|
+
connectorId: string,
|
|
37
|
+
): Promise<ConnectorRow | undefined> {
|
|
38
|
+
const rows = await getDb()
|
|
39
|
+
.select({ connector: connectors })
|
|
40
|
+
.from(collectionItems)
|
|
41
|
+
.innerJoin(connectors, eq(collectionItems.connectorId, connectors.id))
|
|
42
|
+
.where(
|
|
43
|
+
and(
|
|
44
|
+
eq(collectionItems.collectionId, collectionId),
|
|
45
|
+
eq(collectionItems.connectorId, connectorId),
|
|
46
|
+
),
|
|
47
|
+
);
|
|
48
|
+
return rows[0]?.connector;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const inflight = new Map<string, Promise<OAuthAuth>>();
|
|
52
|
+
|
|
53
|
+
function refreshOnce(
|
|
54
|
+
row: ConnectorRow,
|
|
55
|
+
config: ConnectorConfig,
|
|
56
|
+
auth: OAuthAuth,
|
|
57
|
+
): Promise<OAuthAuth> {
|
|
58
|
+
const running = inflight.get(row.id);
|
|
59
|
+
if (running !== undefined) return running;
|
|
60
|
+
const resource = parseConnectorUrl(row.url).toString();
|
|
61
|
+
const job = refreshOAuth(auth, resource)
|
|
62
|
+
.then(async (fresh) => {
|
|
63
|
+
await getDb()
|
|
64
|
+
.update(connectors)
|
|
65
|
+
.set({ config: { ...config, auth: fresh } })
|
|
66
|
+
.where(eq(connectors.id, row.id));
|
|
67
|
+
return fresh;
|
|
68
|
+
})
|
|
69
|
+
.finally(() => {
|
|
70
|
+
inflight.delete(row.id);
|
|
71
|
+
});
|
|
72
|
+
inflight.set(row.id, job);
|
|
73
|
+
return job;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function unrefreshedTarget(
|
|
77
|
+
url: string,
|
|
78
|
+
auth: OAuthAuth,
|
|
79
|
+
force: boolean,
|
|
80
|
+
now = Date.now(),
|
|
81
|
+
): RelayTarget {
|
|
82
|
+
if (force) return { kind: 'signin' };
|
|
83
|
+
if (staleUsable(auth, now))
|
|
84
|
+
return { kind: 'ok', url, headers: bearerHeaders(auth.accessToken) };
|
|
85
|
+
return { kind: 'ok', url, headers: {} };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function oauthTarget(
|
|
89
|
+
row: ConnectorRow,
|
|
90
|
+
config: ConnectorConfig,
|
|
91
|
+
auth: OAuthAuth,
|
|
92
|
+
force: boolean,
|
|
93
|
+
): Promise<RelayTarget> {
|
|
94
|
+
if (!force && !oauthExpired(auth))
|
|
95
|
+
return { kind: 'ok', url: row.url, headers: bearerHeaders(auth.accessToken) };
|
|
96
|
+
try {
|
|
97
|
+
const fresh = await refreshOnce(row, config, auth);
|
|
98
|
+
return { kind: 'ok', url: row.url, headers: bearerHeaders(fresh.accessToken) };
|
|
99
|
+
} catch (err) {
|
|
100
|
+
log.warn({ id: row.id, err: errMsg(err) }, 'relay: token refresh failed');
|
|
101
|
+
return unrefreshedTarget(row.url, auth, force);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function fixedTarget(
|
|
106
|
+
url: string,
|
|
107
|
+
auth: Exclude<ConnectorAuth, OAuthAuth>,
|
|
108
|
+
force: boolean,
|
|
109
|
+
): RelayTarget {
|
|
110
|
+
if (force) return { kind: 'signin' };
|
|
111
|
+
return { kind: 'ok', url, headers: headerAuthHeaders(auth) ?? {} };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function relayTarget(
|
|
115
|
+
collectionId: string,
|
|
116
|
+
connectorId: string,
|
|
117
|
+
force: boolean,
|
|
118
|
+
): Promise<RelayTarget> {
|
|
119
|
+
const row = await memberRow(collectionId, connectorId);
|
|
120
|
+
if (row === undefined) return { kind: 'missing' };
|
|
121
|
+
parseConnectorUrl(row.url);
|
|
122
|
+
const config = readConfig(row.config);
|
|
123
|
+
const auth = config.auth;
|
|
124
|
+
if (auth.kind === 'oauth') return oauthTarget(row, config, auth, force);
|
|
125
|
+
return fixedTarget(row.url, auth, force);
|
|
126
|
+
}
|
|
@@ -22,7 +22,6 @@ import {
|
|
|
22
22
|
type ConnectorConfig,
|
|
23
23
|
type ConnectorSignIn,
|
|
24
24
|
} from './connector-config.js';
|
|
25
|
-
import { errMsg, log } from '../daemon/log.js';
|
|
26
25
|
import { newId } from './ids.js';
|
|
27
26
|
import { getDb } from './client.js';
|
|
28
27
|
import { connectors, type ConnectorTransport } from './schema.js';
|
|
@@ -131,15 +130,6 @@ export async function listConnectorsForEmail(
|
|
|
131
130
|
return (await connectorRowsFor(email, project)).map(toConnector);
|
|
132
131
|
}
|
|
133
132
|
|
|
134
|
-
async function withFreshToken(row: ConnectorRow): Promise<Connector> {
|
|
135
|
-
const config = readConfig(row.config);
|
|
136
|
-
if (config.auth.kind !== 'oauth' || !oauthExpired(config.auth))
|
|
137
|
-
return toConnector(row);
|
|
138
|
-
const url = parseConnectorUrl(row.url);
|
|
139
|
-
const auth = await refreshOAuth(config.auth, url.toString());
|
|
140
|
-
return saveConfig(row, { ...config, auth });
|
|
141
|
-
}
|
|
142
|
-
|
|
143
133
|
async function rowsByIds(ids: string[]): Promise<ConnectorRow[]> {
|
|
144
134
|
if (ids.length === 0) return [];
|
|
145
135
|
return getDb()
|
|
@@ -149,21 +139,10 @@ async function rowsByIds(ids: string[]): Promise<ConnectorRow[]> {
|
|
|
149
139
|
.orderBy(asc(connectors.id));
|
|
150
140
|
}
|
|
151
141
|
|
|
152
|
-
export async function
|
|
142
|
+
export async function connectorNamesByIds(
|
|
153
143
|
ids: string[],
|
|
154
|
-
): Promise<
|
|
155
|
-
|
|
156
|
-
return Promise.all(
|
|
157
|
-
rows.map(async (row) =>
|
|
158
|
-
withFreshToken(row).catch((err: unknown) => {
|
|
159
|
-
log.warn(
|
|
160
|
-
{ id: row.id, err: errMsg(err) },
|
|
161
|
-
'connectors: could not refresh the access token',
|
|
162
|
-
);
|
|
163
|
-
return toConnector(row);
|
|
164
|
-
}),
|
|
165
|
-
),
|
|
166
|
-
);
|
|
144
|
+
): Promise<{ id: string; name: string }[]> {
|
|
145
|
+
return (await rowsByIds(ids)).map((row) => ({ id: row.id, name: row.name }));
|
|
167
146
|
}
|
|
168
147
|
|
|
169
148
|
export async function getConnectorForEmail(
|
package/runtime/runtime.json
CHANGED