@stage-labs/metro 0.1.0-beta.25 → 0.1.0-beta.26

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 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage-labs/metro",
3
- "version": "0.1.0-beta.25",
3
+ "version": "0.1.0-beta.26",
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
- freshConnectorsByIds: listFreshConnectorsByIds,
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 { mcpServersJson } from './connector-json.js';
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';
@@ -75,8 +77,13 @@ async function handleRead(
75
77
  sendJson(req, res, 200, { email: who.email, collection: collection.name });
76
78
  return;
77
79
  }
78
- const rows = await deps.freshConnectorsByIds(collection.connectorIds);
79
- sendJson(req, res, 200, { json: mcpServersJson(rows), collection: collection.name });
80
+ const entries = await deps.connectorNamesByIds(collection.connectorIds);
81
+ const json = relayServersJson(
82
+ entries,
83
+ publicBaseOrDefault(),
84
+ extractToken(req) ?? '',
85
+ );
86
+ sendJson(req, res, 200, { json, collection: collection.name });
80
87
  }
81
88
 
82
89
  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
- freshConnectorsByIds: (ids: string[]) => Promise<Connector[]>;
38
+ connectorNamesByIds: (ids: string[]) => Promise<RelayServerEntry[]>;
38
39
  listCollections: (
39
40
  email: string,
40
41
  project: string,
@@ -1,31 +1,21 @@
1
- import type { ConnectorTransport } from '../db/schema.js';
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
- function serverOf(entry: ConnectorEntry): Record<string, unknown> {
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 mcpServersJson(entries: ConnectorEntry[]): string {
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) mcpServers[entry.name] = serverOf(entry);
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 = 60_000;
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
+ }
@@ -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 listFreshConnectorsByIds(
142
+ export async function connectorNamesByIds(
153
143
  ids: string[],
154
- ): Promise<Connector[]> {
155
- const rows = await rowsByIds(ids);
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(
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.25"
2
+ "version": "0.1.0-beta.26"
3
3
  }