@stage-labs/metro 0.1.0-beta.301 → 0.1.0-beta.303

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/package.json +1 -1
  2. package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
  3. package/runtime/node_modules/@metro-labs/daemon/src/agent-user/provision.ts +4 -1
  4. package/runtime/node_modules/@metro-labs/daemon/src/agents/bundle.ts +3 -11
  5. package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +5 -8
  6. package/runtime/node_modules/@metro-labs/daemon/src/claude/session.ts +3 -1
  7. package/runtime/node_modules/@metro-labs/daemon/src/connectors/gates.ts +1 -11
  8. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-adopt.ts +57 -0
  9. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-runtime.ts +39 -95
  10. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-control.ts +27 -45
  11. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-watch.ts +2 -17
  12. package/runtime/node_modules/@metro-labs/daemon/src/connectors/plugin-sync.ts +4 -34
  13. package/runtime/node_modules/@metro-labs/daemon/src/connectors/publication.ts +8 -29
  14. package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-session.ts +0 -1
  15. package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay.ts +33 -138
  16. package/runtime/node_modules/@metro-labs/daemon/src/connectors/server-keys.ts +22 -9
  17. package/runtime/node_modules/@metro-labs/daemon/src/routes/local-mode.ts +4 -58
  18. package/runtime/node_modules/@metro-labs/daemon/src/routes/session-apis.ts +0 -12
  19. package/runtime/node_modules/@metro-labs/daemon/src/server/update.ts +3 -3
  20. package/runtime/node_modules/@metro-labs/daemon/src/stations/materialize.ts +0 -7
  21. package/runtime/runtime.json +1 -1
  22. package/runtime/sdk-runner/runner.json +1 -1
  23. package/runtime/node_modules/@metro-labs/daemon/src/connectors/api.ts +0 -316
  24. package/runtime/node_modules/@metro-labs/daemon/src/connectors/config.ts +0 -105
  25. package/runtime/node_modules/@metro-labs/daemon/src/connectors/health.ts +0 -18
  26. package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-api.ts +0 -60
  27. package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-control.ts +0 -366
  28. package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-guard.ts +0 -95
  29. package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-operations.ts +0 -226
  30. package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-preview.ts +0 -235
  31. package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-source.ts +0 -42
  32. package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-state.ts +0 -142
  33. package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-url.ts +0 -9
  34. package/runtime/node_modules/@metro-labs/daemon/src/connectors/oauth-routes.ts +0 -178
  35. package/runtime/node_modules/@metro-labs/daemon/src/connectors/oauth.ts +0 -171
  36. package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-passive.ts +0 -96
  37. package/runtime/node_modules/@metro-labs/daemon/src/connectors/store.ts +0 -393
  38. package/runtime/node_modules/@metro-labs/daemon/src/connectors/transfer-api.ts +0 -116
  39. package/runtime/node_modules/@metro-labs/daemon/src/connectors/transfer-crypto.ts +0 -44
  40. package/runtime/node_modules/@metro-labs/daemon/src/connectors/transfer-store.ts +0 -46
@@ -1,393 +0,0 @@
1
- import { join } from 'node:path';
2
- import { closeSync, fsyncSync, openSync } from 'node:fs';
3
- import { publishLocalConnectors } from './publication.js';
4
- import { persistentServerKeys } from './server-keys.js';
5
- import { assertLocalMigrationEdit, localMigrationEnabled, localMigrationIdle, migrationSource, withLocalMigrationOperation } from './migration-guard.js';
6
- import type { FetchLike } from '@metro-labs/core/stations/oauth';
7
- import type { RelayTarget } from './relay-target.js';
8
- export type { RelayTarget } from './relay-target.js';
9
- import type { ToolPolicy } from '../policy/policy.js';
10
- import type { ToolGroup } from '@metro-labs/core/stations/types';
11
- import { listRemoteTools, type RemoteTool } from './tools.js';
12
- import { readJson, writeSecure } from '@metro-labs/core/secure-fs';
13
- import { errMsg, log } from '@metro-labs/core/log';
14
- import {
15
- authHeaders,
16
- verifyRemoteMcp,
17
- type ConnectorAuth,
18
- type OAuthAuth,
19
- type VerifiedRecord,
20
- } from '@metro-labs/http/connector-mcp';
21
- import { connectorUrlText, ConnectorVerifyError, parseConnectorUrl } from '@metro-labs/http/connector-url';
22
- import { oauthExpired, refreshOAuth } from './oauth.js';
23
- import { advertisesOAuth } from '@metro-labs/http/connector-discovery';
24
- import { ConnectorError, connectorAuth, connectorClient, connectorName } from '@metro-labs/http/connector-input';
25
- import {
26
- readConfig,
27
- signInState,
28
- stamp,
29
- type ConnectorConfig,
30
- type ConnectorSignIn,
31
- } from './config.js';
32
- import type { OAuthClient } from '@metro-labs/http/connector-oauth';
33
- import { agentsDir } from '../agents/files.js';
34
- import { newId } from '@metro-labs/core/ids';
35
- import type { LoadedConnector } from '../stations/materialize.js';
36
-
37
- export interface LocalConnectorRow {
38
- id: string;
39
- name: string;
40
- url: string;
41
- config: ConnectorConfig;
42
- }
43
-
44
- export interface Connector {
45
- id: string;
46
- name: string;
47
- url: string;
48
- auth: ConnectorAuth['kind'];
49
- header: string | null;
50
- signIn: ConnectorSignIn;
51
- verified: VerifiedRecord;
52
- client: OAuthClient | null;
53
- policy: ToolPolicy;
54
- }
55
-
56
- export interface PendingConnectorInput {
57
- name: unknown;
58
- url: unknown;
59
- clientId: unknown;
60
- clientSecret: unknown;
61
- }
62
-
63
- export interface ConnectorInput extends PendingConnectorInput {
64
- header: unknown;
65
- value: unknown;
66
- }
67
-
68
- export type ConnectorCheck =
69
- | { id: string; name: string; ok: true; verified: VerifiedRecord }
70
- | { id: string; name: string; ok: false; reason: string };
71
-
72
- export interface DeletedConnector {
73
- id: string;
74
- name: string;
75
- }
76
-
77
- const UNVERIFIED = { at: '', server: '' };
78
-
79
- function connectorFromRow(row: LocalConnectorRow): Connector {
80
- const auth = row.config.auth;
81
- return {
82
- id: row.id,
83
- name: row.name,
84
- url: row.url,
85
- auth: auth.kind,
86
- header: auth.kind === 'header' ? auth.name : null,
87
- signIn: signInState(row.config),
88
- verified: row.config.verified,
89
- client: row.config.client,
90
- policy: row.config.policy ?? {},
91
- };
92
- }
93
-
94
- const FILE = 'connectors.json';
95
- const filePath = (dir: string): string => join(dir, FILE);
96
-
97
- const isRow = (v: unknown): v is { id: string; name: string; url: string; config?: unknown } =>
98
- typeof v === 'object' &&
99
- v !== null &&
100
- typeof (v as { id?: unknown }).id === 'string' &&
101
- typeof (v as { name?: unknown }).name === 'string' &&
102
- typeof (v as { url?: unknown }).url === 'string';
103
-
104
- const asList = (value: unknown): unknown[] =>
105
- Array.isArray(value) ? value.map((item: unknown) => item) : [];
106
-
107
- export function readLocalConnectors(dir = agentsDir()): LocalConnectorRow[] {
108
- const raw = readJson<{ connectors?: unknown }>(filePath(dir), {}, {
109
- warn: 'connectors.json: malformed, ignoring',
110
- });
111
- return asList(raw.connectors).filter(isRow).map((r) => ({
112
- id: r.id,
113
- name: r.name,
114
- url: r.url,
115
- config: readConfig(r.config),
116
- }));
117
- }
118
-
119
- function writeRows(dir: string, rows: LocalConnectorRow[]): void {
120
- persistentServerKeys(readLocalConnectors(dir), dir);
121
- persistentServerKeys(rows, dir);
122
- writeSecure(filePath(dir), `${JSON.stringify({ version: 1, connectors: rows }, null, 2)}\n`);
123
- if (dir !== agentsDir()) return;
124
- publishLocalConnectors(rows);
125
- }
126
-
127
- const missing = (): ConnectorError => new ConnectorError('no such connector', 404);
128
-
129
- function rowOrThrow(id: string, dir: string): LocalConnectorRow {
130
- const row = readLocalConnectors(dir).find((r) => r.id === id);
131
- if (row === undefined) throw missing();
132
- return row;
133
- }
134
-
135
- function replace(dir: string, row: LocalConnectorRow, refresh = false): Connector {
136
- if (!refresh) assertLocalMigrationEdit(row.id, dir);
137
- writeRows(dir, readLocalConnectors(dir).map((r) => (r.id === row.id ? row : r)));
138
- return connectorFromRow(row);
139
- }
140
-
141
- async function oauthCapable(url: URL, auth: ConnectorAuth, send: FetchLike): Promise<boolean> {
142
- if (auth.kind === 'oauth') return true;
143
- if (auth.kind === 'header') return false;
144
- return advertisesOAuth(url, send);
145
- }
146
-
147
- export function localImportConnectors(
148
- rows: LoadedConnector[],
149
- dir = agentsDir(),
150
- mode: 'append' | 'overwrite' = 'overwrite',
151
- ): number {
152
- const current = readLocalConnectors(dir);
153
- const fresh =
154
- mode === 'overwrite'
155
- ? rows
156
- : rows.filter((row) => !current.some((r) => r.id === row.id));
157
- const imported: LocalConnectorRow[] = fresh.map((row) => ({
158
- id: row.id,
159
- name: row.name,
160
- url: row.url,
161
- config: readConfig(row.config),
162
- }));
163
- for (const row of imported) assertLocalMigrationEdit(row.id, dir);
164
- const ids = new Set(imported.map((r) => r.id));
165
- if (ids.size !== imported.length) throw new ConnectorError('The import contains duplicate connector IDs.', 400);
166
- writeRows(dir, [...current.filter((r) => !ids.has(r.id)), ...imported]);
167
- return imported.length;
168
- }
169
-
170
- export async function localListConnectors(dir = agentsDir()): Promise<Connector[]> {
171
- return Promise.resolve(readLocalConnectors(dir).map(connectorFromRow));
172
- }
173
-
174
- export async function localGetConnector(id: string, dir = agentsDir()): Promise<Connector> {
175
- return Promise.resolve(connectorFromRow(rowOrThrow(id, dir)));
176
- }
177
-
178
- function insert(dir: string, name: string, url: URL, config: ConnectorConfig): Connector {
179
- const row: LocalConnectorRow = { id: newId(), name, url: connectorUrlText(url), config };
180
- writeRows(dir, [...readLocalConnectors(dir), row]);
181
- return connectorFromRow(row);
182
- }
183
-
184
- export async function localCreateConnector(input: ConnectorInput, dir = agentsDir()): Promise<Connector> {
185
- const name = connectorName(input.name);
186
- const url = parseConnectorUrl(input.url);
187
- const auth = connectorAuth(input.header, input.value);
188
- const client = connectorClient(input.clientId, input.clientSecret);
189
- return withLocalMigrationOperation('', dir, async (operation) => {
190
- const verified = stamp(await verifyRemoteMcp(url, auth, operation.fetch));
191
- return insert(dir, name, url, { auth, createdAt: new Date().toISOString(), verified, oauth: await oauthCapable(url, auth, operation.fetch), client });
192
- }, true);
193
- }
194
-
195
- export async function localCreatePendingConnector(input: PendingConnectorInput, dir = agentsDir()): Promise<Connector> {
196
- const name = connectorName(input.name);
197
- const url = parseConnectorUrl(input.url);
198
- const client = connectorClient(input.clientId, input.clientSecret);
199
- return Promise.resolve(
200
- insert(dir, name, url, { auth: { kind: 'none' }, createdAt: new Date().toISOString(), verified: UNVERIFIED, oauth: true, client }),
201
- );
202
- }
203
-
204
- export async function localReconnectConnector(
205
- id: string,
206
- auth: OAuthAuth,
207
- dir = agentsDir(),
208
- ): Promise<Connector> {
209
- return withLocalMigrationOperation(id, dir, async (operation) => {
210
- const row = rowOrThrow(id, dir);
211
- const verified = stamp(await verifyRemoteMcp(parseConnectorUrl(row.url), auth, operation.fetch));
212
- return replace(dir, { ...row, config: { ...row.config, auth, verified, oauth: true } });
213
- }, true);
214
- }
215
-
216
- const signedOut = (row: LocalConnectorRow): LocalConnectorRow => ({ ...row, config: { ...row.config, auth: { kind: 'none' }, oauth: true } });
217
-
218
- export async function localDisconnectConnector(id: string, dir = agentsDir()): Promise<Connector> {
219
- const row = rowOrThrow(id, dir);
220
- if (row.config.auth.kind !== 'oauth') throw new ConnectorError('that connector is not signed in', 400);
221
- return Promise.resolve(replace(dir, signedOut(row)));
222
- }
223
-
224
- export function localMarkSignedOut(id: string, dir = agentsDir()): void {
225
- const row = readLocalConnectors(dir).find((r) => r.id === id);
226
- if (row?.config.auth.kind !== 'oauth') return;
227
- log.info({ id, name: row.name }, 'local relay: the vendor refused the token after a refresh; the connector is signed out');
228
- replace(dir, signedOut(row));
229
- }
230
-
231
- async function freshAuth(row: LocalConnectorRow, dir: string): Promise<ConnectorAuth> {
232
- const auth = row.config.auth;
233
- if (auth.kind !== 'oauth' || !oauthExpired(auth)) return auth;
234
- return refreshOnce(row, auth, dir);
235
- }
236
-
237
- function updateRow(dir: string, id: string, config: Partial<LocalConnectorRow['config']>, refresh = false): void {
238
- if (!refresh && migrationSource(id, dir) !== null) return;
239
- const now = readLocalConnectors(dir).find((r) => r.id === id);
240
- if (now !== undefined) replace(dir, { ...now, config: { ...now.config, ...config } }, refresh);
241
- }
242
-
243
- export async function localVerifyConnector(id: string, dir = agentsDir()): Promise<ConnectorCheck> {
244
- return withLocalMigrationOperation(id, dir, async (operation) => {
245
- const row = rowOrThrow(id, dir);
246
- try {
247
- const url = parseConnectorUrl(row.url);
248
- const auth = await freshAuth(row, dir);
249
- const verified = stamp(await verifyRemoteMcp(url, auth, operation.fetch));
250
- const oauth = row.config.oauth || (await oauthCapable(url, auth, operation.fetch));
251
- updateRow(dir, row.id, { auth, verified, oauth });
252
- return { id: row.id, name: row.name, ok: true, verified };
253
- } catch (err) {
254
- if (!(err instanceof ConnectorVerifyError)) throw err;
255
- return { id: row.id, name: row.name, ok: false, reason: err.message };
256
- }
257
- });
258
- }
259
-
260
- const groupsOf = (tools: RemoteTool[]): Record<string, ToolGroup> =>
261
- Object.fromEntries(tools.map((tool) => [tool.name, tool.readOnly ? 'read' : 'write'] as const));
262
-
263
- function keepGroups(dir: string, id: string, tools: RemoteTool[]): void {
264
- if (migrationSource(id, dir) !== null) return;
265
- const now = readLocalConnectors(dir).find((r) => r.id === id);
266
- const groups = groupsOf(tools);
267
- if (now === undefined || JSON.stringify(now.config.toolGroups ?? {}) === JSON.stringify(groups)) return;
268
- const config = { ...now.config };
269
- delete config.toolGroups;
270
- replace(dir, { ...now, config: Object.keys(groups).length > 0 ? { ...config, toolGroups: groups } : config });
271
- }
272
-
273
- export async function localConnectorTools(id: string, dir = agentsDir()): Promise<RemoteTool[]> {
274
- return withLocalMigrationOperation(id, dir, async (operation) => {
275
- const row = rowOrThrow(id, dir);
276
- const tools = await listRemoteTools(parseConnectorUrl(row.url), await freshAuth(row, dir), operation.fetch);
277
- keepGroups(dir, id, tools);
278
- return tools;
279
- });
280
- }
281
-
282
- async function refreshGroups(id: string, dir: string): Promise<void> {
283
- try {
284
- await localConnectorTools(id, dir);
285
- } catch (err) {
286
- log.warn({ id, err: errMsg(err) }, 'connector policy: could not list the tools; the last known read and write groups stay');
287
- }
288
- }
289
-
290
- export async function localSetConnectorPolicy(id: string, policy: ToolPolicy, dir = agentsDir()): Promise<Connector> {
291
- const row = rowOrThrow(id, dir);
292
- const config = { ...row.config };
293
- delete config.policy;
294
- const saved = replace(dir, { ...row, config: Object.keys(policy).length > 0 ? { ...config, policy } : config });
295
- await refreshGroups(id, dir);
296
- return saved;
297
- }
298
-
299
- export async function loadConnectorPolicies(dir = agentsDir()): Promise<void> {
300
- const rows = readLocalConnectors(dir);
301
- publishLocalConnectors(rows);
302
- for (const row of rows) if (row.config.policy !== undefined && localMigrationEnabled(row.id, dir)) await refreshGroups(row.id, dir);
303
- }
304
-
305
- export async function localRenameConnector(id: string, raw: string, dir = agentsDir()): Promise<Connector> {
306
- const name = connectorName(raw);
307
- const row = rowOrThrow(id, dir);
308
- return Promise.resolve(replace(dir, { ...row, name }));
309
- }
310
-
311
- export async function localDeleteConnector(id: string, dir = agentsDir()): Promise<DeletedConnector> {
312
- const row = rowOrThrow(id, dir);
313
- assertLocalMigrationEdit(id, dir);
314
- writeRows(dir, readLocalConnectors(dir).filter((r) => r.id !== id));
315
- return Promise.resolve({ id: row.id, name: row.name });
316
- }
317
-
318
- const inflight = new Map<string, Promise<OAuthAuth>>();
319
-
320
- function refreshOnce(row: LocalConnectorRow, auth: OAuthAuth, dir: string): Promise<OAuthAuth> {
321
- const key = JSON.stringify([dir, row.id]);
322
- const running = inflight.get(key);
323
- if (running !== undefined) return running;
324
- const job = withLocalMigrationOperation(row.id, dir, async (operation) => {
325
- const fresh = await refreshOAuth(auth, parseConnectorUrl(row.url).toString(), (input, init) => operation.dispatch(() => fetch(input, init), { refresh: true }));
326
- updateRow(dir, row.id, { auth: fresh }, true);
327
- flushMigrationSource(dir);
328
- operation.persistedRefresh();
329
- return fresh;
330
- }).finally(() => { inflight.delete(key); });
331
- inflight.set(key, job);
332
- return job;
333
- }
334
-
335
- function staleUsable(auth: OAuthAuth, now = Date.now()): boolean {
336
- return auth.expiresAt === undefined || auth.expiresAt > now;
337
- }
338
-
339
- function unrefreshedTarget(
340
- url: string,
341
- auth: OAuthAuth,
342
- force: boolean,
343
- now = Date.now(),
344
- ): RelayTarget {
345
- if (force) return { kind: 'signin' };
346
- return { kind: 'ok', url, headers: staleUsable(auth, now) ? authHeaders(auth) : {} };
347
- }
348
-
349
- function fixedTarget(
350
- url: string,
351
- auth: Exclude<ConnectorAuth, OAuthAuth>,
352
- force: boolean,
353
- ): RelayTarget {
354
- if (force) return { kind: 'signin' };
355
- return { kind: 'ok', url, headers: authHeaders(auth) };
356
- }
357
-
358
- async function oauthTarget(row: LocalConnectorRow, auth: OAuthAuth, force: boolean, dir: string): Promise<RelayTarget> {
359
- if (!force && !oauthExpired(auth)) return { kind: 'ok', url: row.url, headers: authHeaders(auth) };
360
- try {
361
- const fresh = await refreshOnce(row, auth, dir);
362
- return { kind: 'ok', url: row.url, headers: authHeaders(fresh) };
363
- } catch (err) {
364
- log.warn({ id: row.id, err: errMsg(err) }, 'local relay: token refresh failed');
365
- return unrefreshedTarget(row.url, auth, force);
366
- }
367
- }
368
-
369
- export async function localRelayTarget(connectorId: string, force: boolean, dir = agentsDir()): Promise<RelayTarget> {
370
- return withLocalMigrationOperation(connectorId, dir, async (operation) => {
371
- const row = readLocalConnectors(dir).find((r) => r.id === connectorId);
372
- if (row === undefined) return { kind: 'missing' };
373
- parseConnectorUrl(row.url);
374
- const auth = row.config.auth;
375
- const target = auth.kind === 'oauth' ? await oauthTarget(row, auth, force, dir) : fixedTarget(row.url, auth, force);
376
- operation.check();
377
- return target;
378
- });
379
- }
380
-
381
- export function localRollbackMigrationSource(id: string, signOutOAuth: boolean, dir = agentsDir(), discardUncertain = false): void {
382
- if (!['rollback', 'recovering'].includes(migrationSource(id, dir)?.phase ?? '') || !localMigrationIdle(id, dir, discardUncertain && signOutOAuth)) throw new ConnectorError('The connector migration is not ready to roll back.', 409);
383
- const row = rowOrThrow(id, dir);
384
- if (signOutOAuth) replace(dir, signedOut(row), true);
385
- if (signOutOAuth) flushMigrationSource(dir);
386
- }
387
-
388
- export function flushMigrationSource(dir = agentsDir()): void {
389
- for (const path of [filePath(dir), dir]) {
390
- const fd = openSync(path, 'r');
391
- try { fsyncSync(fd); } finally { closeSync(fd); }
392
- }
393
- }
@@ -1,116 +0,0 @@
1
- import type { IncomingMessage, ServerResponse } from 'node:http';
2
- import { randomUUID, type KeyObject } from 'node:crypto';
3
- import { isRecord } from '@metro-labs/core/is-record';
4
- import { parseId } from '@metro-labs/core/ids';
5
- import { log } from '@metro-labs/core/log';
6
- import { ApiError } from '@metro-labs/http/api-error';
7
- import { apiSession, cors, readJsonBody, requireAdmin, sendJson } from '@metro-labs/http/api-http';
8
- import { openTransfer, sealTransfer, transferKeys } from './transfer-crypto.js';
9
- import type { LocalConnectorRow } from './store.js';
10
- import type { ConnectorCopyResult } from './transfer-store.js';
11
-
12
- const PREFIX = '/api/connectors/transfer';
13
- const MAX_BODY = 4 * 1024 * 1024 + 4096;
14
- const TTL = 5 * 60_000;
15
-
16
- export interface ConnectorTransferDeps {
17
- read: () => LocalConnectorRow[];
18
- copy: (value: unknown) => ConnectorCopyResult[];
19
- now?: () => number;
20
- }
21
-
22
- interface Ticket {
23
- privateKey: KeyObject;
24
- organization: string;
25
- expiresAt: number;
26
- }
27
-
28
- function selectedIds(raw: unknown): string[] | null {
29
- if (raw === null) return null;
30
- if (!Array.isArray(raw) || raw.length === 0 || raw.length > 500) throw new ApiError('choose connectors to copy', 400);
31
- return [...new Set(raw.map((id: unknown) => {
32
- if (typeof id !== 'string' || parseId(id) === null) throw new ApiError('choose connectors to copy', 400);
33
- return id;
34
- }))];
35
- }
36
-
37
- export class ConnectorTransfers {
38
- private readonly tickets = new Map<string, Ticket>();
39
-
40
- constructor(private readonly deps: ConnectorTransferDeps) {}
41
-
42
- private prepare(organization: string): unknown {
43
- const now = (this.deps.now ?? Date.now)();
44
- for (const [id, ticket] of this.tickets) if (ticket.expiresAt <= now) this.tickets.delete(id);
45
- if (this.tickets.size >= 64) throw new ApiError('too many pending connector copies, try again later', 429);
46
- const { publicKey, privateKey } = transferKeys();
47
- const ticket = randomUUID();
48
- this.tickets.set(ticket, { privateKey, organization, expiresAt: now + TTL });
49
- return { ticket, publicKey };
50
- }
51
-
52
- private export(body: Record<string, unknown>): unknown {
53
- if (typeof body.publicKey !== 'string') throw new ApiError('invalid transfer key', 400);
54
- const ids = selectedIds(body.ids);
55
- const all = this.deps.read();
56
- const rows = ids === null ? all : all.filter((row) => ids.includes(row.id));
57
- const expected = ids?.length ?? rows.length;
58
- if (rows.length !== expected) throw new ApiError('a selected connector no longer exists', 409);
59
- if (rows.length > 500) throw new ApiError('copy at most 500 connectors at once', 413);
60
- try {
61
- return sealTransfer({ version: 1, connectors: rows }, body.publicKey);
62
- } catch (err) {
63
- if (err instanceof ApiError) throw err;
64
- throw new ApiError('invalid transfer key', 400);
65
- }
66
- }
67
-
68
- private receive(body: Record<string, unknown>, organization: string): unknown {
69
- const id = typeof body.ticket === 'string' ? body.ticket : '';
70
- const ticket = this.tickets.get(id);
71
- if (ticket?.organization !== organization || ticket.expiresAt <= (this.deps.now ?? Date.now)()) {
72
- this.tickets.delete(id);
73
- throw new ApiError('this connector copy has expired, start again', 410);
74
- }
75
- let value: unknown;
76
- try {
77
- value = openTransfer(body.envelope, ticket.privateKey);
78
- } catch {
79
- throw new ApiError('invalid encrypted connector copy', 400);
80
- }
81
- const results = this.deps.copy(value);
82
- this.tickets.delete(id);
83
- return { results };
84
- }
85
-
86
- private async answer(req: IncomingMessage, path: string): Promise<unknown> {
87
- const session = await apiSession(req);
88
- if (session === null) throw new ApiError('unauthorized', 401);
89
- requireAdmin(session);
90
- const body = await readJsonBody(req, MAX_BODY);
91
- if (!isRecord(body) || body.confirmed !== true) throw new ApiError('confirm copying saved logins and settings', 400);
92
- if (path === `${PREFIX}/prepare`) return this.prepare(session.subject);
93
- if (path === `${PREFIX}/export`) return this.export(body);
94
- return this.receive(body, session.subject);
95
- }
96
-
97
- handle(req: IncomingMessage, res: ServerResponse): boolean {
98
- const path = (req.url ?? '').split('?')[0] ?? '';
99
- if (![`${PREFIX}/prepare`, `${PREFIX}/export`, `${PREFIX}/receive`].includes(path)) return false;
100
- if (req.method === 'OPTIONS') {
101
- res.writeHead(204, cors(req)).end();
102
- return true;
103
- }
104
- if (req.method !== 'POST') {
105
- sendJson(req, res, 405, { error: 'method not allowed' });
106
- return true;
107
- }
108
- this.answer(req, path).then((body) => {
109
- sendJson(req, res, 200, body);
110
- }).catch((err: unknown) => {
111
- if (!(err instanceof ApiError)) log.warn('connector-transfer: request failed');
112
- sendJson(req, res, err instanceof ApiError ? err.status : 500, { error: err instanceof ApiError ? err.message : 'connector copy failed' });
113
- });
114
- return true;
115
- }
116
- }
@@ -1,44 +0,0 @@
1
- import { createCipheriv, createDecipheriv, createPublicKey, diffieHellman, generateKeyPairSync, hkdfSync, randomBytes, type KeyObject } from 'node:crypto';
2
- import { isRecord } from '@metro-labs/core/is-record';
3
- import { ApiError } from '@metro-labs/http/api-error';
4
-
5
- export interface TransferEnvelope {
6
- key: string;
7
- iv: string;
8
- tag: string;
9
- data: string;
10
- }
11
-
12
- export const transferKeys = (): { publicKey: string; privateKey: KeyObject } => {
13
- const pair = generateKeyPairSync('x25519');
14
- return { publicKey: pair.publicKey.export({ type: 'spki', format: 'pem' }).toString(), privateKey: pair.privateKey };
15
- };
16
-
17
- function sharedKey(privateKey: KeyObject, pem: string): ArrayBuffer {
18
- if (pem.length > 1024) throw new ApiError('invalid transfer key', 400);
19
- const publicKey = createPublicKey(pem);
20
- if (publicKey.asymmetricKeyType !== 'x25519') throw new ApiError('invalid transfer key', 400);
21
- return hkdfSync('sha256', diffieHellman({ privateKey, publicKey }), '', 'metro-connector-copy-v1', 32);
22
- }
23
-
24
- export function sealTransfer(value: unknown, publicKey: string): TransferEnvelope {
25
- const pair = transferKeys();
26
- const iv = randomBytes(12);
27
- const cipher = createCipheriv('aes-256-gcm', Buffer.from(sharedKey(pair.privateKey, publicKey)), iv);
28
- const plain = JSON.stringify(value);
29
- if (Buffer.byteLength(plain) > 3 * 1024 * 1024) throw new ApiError('too many connector settings for one copy', 413);
30
- const data = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]);
31
- return { key: pair.publicKey, iv: iv.toString('base64'), tag: cipher.getAuthTag().toString('base64'), data: data.toString('base64') };
32
- }
33
-
34
- export function openTransfer(raw: unknown, privateKey: KeyObject): unknown {
35
- if (!isRecord(raw) || typeof raw.key !== 'string' || typeof raw.iv !== 'string' || typeof raw.tag !== 'string' || typeof raw.data !== 'string')
36
- throw new ApiError('invalid encrypted transfer', 400);
37
- const iv = Buffer.from(raw.iv, 'base64');
38
- const tag = Buffer.from(raw.tag, 'base64');
39
- if (iv.length !== 12 || tag.length !== 16) throw new ApiError('invalid encrypted transfer', 400);
40
- const decipher = createDecipheriv('aes-256-gcm', Buffer.from(sharedKey(privateKey, raw.key)), iv);
41
- decipher.setAuthTag(tag);
42
- const plain = Buffer.concat([decipher.update(Buffer.from(raw.data, 'base64')), decipher.final()]);
43
- return JSON.parse(plain.toString('utf8')) as unknown;
44
- }
@@ -1,46 +0,0 @@
1
- import { isDeepStrictEqual } from 'node:util';
2
- import { isRecord } from '@metro-labs/core/is-record';
3
- import { newId, parseId } from '@metro-labs/core/ids';
4
- import { ApiError } from '@metro-labs/http/api-error';
5
- import { connectorAuth, connectorName } from '@metro-labs/http/connector-input';
6
- import { readConfig } from './config.js';
7
- import { parseConnectorUrl } from '@metro-labs/http/connector-url';
8
- import { normalizePolicy } from '../policy/policy.js';
9
- import { localImportConnectors, type LocalConnectorRow } from './store.js';
10
-
11
- export interface ConnectorCopyResult {
12
- sourceId: string | null;
13
- id?: string;
14
- status: 'copied' | 'skipped' | 'invalid';
15
- }
16
-
17
- function copyRow(raw: unknown): LocalConnectorRow {
18
- if (!isRecord(raw) || typeof raw.id !== 'string' || parseId(raw.id) === null || !isRecord(raw.config)) throw new ApiError('invalid connector', 400);
19
- const name = connectorName(raw.name);
20
- const url = parseConnectorUrl(raw.url).toString();
21
- if (raw.config.policy !== undefined) normalizePolicy(raw.config.policy);
22
- const config = readConfig(raw.config);
23
- if (!isDeepStrictEqual(config, raw.config)) throw new ApiError('invalid connector settings', 400);
24
- if (config.auth.kind === 'header') connectorAuth(config.auth.name, config.auth.value);
25
- return { id: raw.id, name, url, config };
26
- }
27
-
28
- export function copyConnectorRows(value: unknown, dir?: string): ConnectorCopyResult[] {
29
- if (!isRecord(value) || value.version !== 1 || !Array.isArray(value.connectors) || value.connectors.length > 500)
30
- throw new ApiError('invalid connector transfer', 400);
31
- const imported: LocalConnectorRow[] = [];
32
- const results: ConnectorCopyResult[] = value.connectors.map((raw: unknown) => {
33
- const sourceId = isRecord(raw) && typeof raw.id === 'string' ? parseId(raw.id) : null;
34
- let row: LocalConnectorRow;
35
- try {
36
- row = copyRow(raw);
37
- } catch {
38
- return { sourceId, status: 'invalid' };
39
- }
40
- const id = newId();
41
- imported.push({ ...row, id });
42
- return { sourceId, id, status: 'copied' };
43
- });
44
- if (imported.length > 0) localImportConnectors(imported.map((row) => ({ ...row, config: { ...row.config } })), dir, 'append');
45
- return results;
46
- }