@stage-labs/metro 0.1.0-beta.289 → 0.1.0-beta.290

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 (31) 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/connectors/gates.ts +6 -4
  4. package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-api.ts +55 -0
  5. package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-control.ts +269 -0
  6. package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-guard.ts +90 -0
  7. package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-operations.ts +196 -0
  8. package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-preview.ts +46 -11
  9. package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-source.ts +23 -0
  10. package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-state.ts +122 -0
  11. package/runtime/node_modules/@metro-labs/daemon/src/connectors/oauth-routes.ts +68 -45
  12. package/runtime/node_modules/@metro-labs/daemon/src/connectors/oauth.ts +11 -4
  13. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-runtime-discovery.ts +4 -1
  14. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-runtime.ts +95 -41
  15. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-control.ts +37 -3
  16. package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-watch.ts +15 -1
  17. package/runtime/node_modules/@metro-labs/daemon/src/connectors/plugin-sync.ts +5 -3
  18. package/runtime/node_modules/@metro-labs/daemon/src/connectors/publication.ts +20 -5
  19. package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-session.ts +1 -0
  20. package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay.ts +26 -11
  21. package/runtime/node_modules/@metro-labs/daemon/src/connectors/store.ts +71 -50
  22. package/runtime/node_modules/@metro-labs/daemon/src/connectors/tools.ts +2 -1
  23. package/runtime/node_modules/@metro-labs/daemon/src/routes/local-mode.ts +5 -2
  24. package/runtime/node_modules/@metro-labs/daemon/src/routes/session-apis.ts +3 -0
  25. package/runtime/node_modules/@metro-labs/http/package.json +2 -1
  26. package/runtime/node_modules/@metro-labs/http/src/connector-delivery.ts +6 -41
  27. package/runtime/node_modules/@metro-labs/http/src/connector-migration.ts +176 -0
  28. package/runtime/node_modules/@metro-labs/http/src/connector-policy.ts +3 -0
  29. package/runtime/node_modules/@metro-labs/http/src/transfer-crypto.ts +44 -0
  30. package/runtime/runtime.json +1 -1
  31. package/runtime/sdk-runner/runner.json +1 -1
@@ -6,13 +6,16 @@ import { ApiError } from '@metro-labs/http/api-error';
6
6
  import { bodyField } from '@metro-labs/http/api-http';
7
7
  import { connectorAuth, connectorClient, connectorName } from '@metro-labs/http/connector-input';
8
8
  import { parseConnectorUrl } from '@metro-labs/http/connector-url';
9
+ import { connectorPolicyOf } from '@metro-labs/http/connector-delivery';
10
+ import { readConfig } from './config.js';
11
+ import type { LocalConnectorRow } from './store.js';
9
12
  import { agentsDir } from '../agents/files.js';
10
13
  import { normalizePolicy, type ToolPolicy } from '../policy/policy.js';
11
14
  import { connectorSyncScope, syncOptedIn, syncSetting } from './org-sync-setting.js';
12
15
  import { readAgentKey } from './agent-key.js';
13
16
  import { migrationOrigin } from './migration-url.js';
14
17
 
15
- export type MigrationIssue = 'invalid_config' | 'unsupported_url' | 'custom_policy' | 'oauth_sign_in' | 'header_reentry';
18
+ export type MigrationIssue = 'invalid_config' | 'unsupported_url' | 'custom_policy' | 'oauth_sign_in' | 'header_reentry' | 'unused_oauth_client' | 'oauth_private_resource';
16
19
 
17
20
  export interface MigrationConnector {
18
21
  id: string;
@@ -25,6 +28,7 @@ export interface MigrationConnector {
25
28
  }
26
29
 
27
30
  export interface ConnectorMigrationPreview {
31
+ migrationSupported: true;
28
32
  enabled: boolean;
29
33
  enrolled: boolean;
30
34
  agentId: string | null;
@@ -45,7 +49,8 @@ function validClient(raw: unknown): boolean {
45
49
  if (raw === undefined || raw === null) return true;
46
50
  if (!isRecord(raw) || !onlyKeys(raw, ['clientId', 'clientSecret'])) return false;
47
51
  if (!nonempty(raw.clientId) || !optionalStrings(raw, ['clientSecret'])) return false;
48
- return connectorClient(raw.clientId, raw.clientSecret) !== null;
52
+ const client = connectorClient(raw.clientId, raw.clientSecret);
53
+ return client !== null && client.clientId === raw.clientId && client.clientSecret === raw.clientSecret;
49
54
  }
50
55
 
51
56
  function oauthTokensValid(raw: Record<string, unknown>): boolean {
@@ -64,14 +69,18 @@ function oauthAuth(raw: Record<string, unknown>): 'oauth' {
64
69
  return 'oauth';
65
70
  }
66
71
 
72
+ function headerAuth(raw: Record<string, unknown>): 'header' {
73
+ if (raw.kind !== 'header' || !onlyKeys(raw, ['kind', 'name', 'value'])) return invalid();
74
+ if (!nonempty(raw.name) || !nonempty(raw.value)) return invalid();
75
+ const auth = connectorAuth(raw.name, raw.value);
76
+ return auth.kind === 'header' && auth.name === raw.name && auth.value === raw.value ? 'header' : invalid();
77
+ }
78
+
67
79
  function authKind(raw: unknown): Exclude<MigrationConnector['auth'], 'invalid'> {
68
80
  if (!isRecord(raw)) return invalid();
69
81
  if (raw.kind === 'oauth') return oauthAuth(raw);
70
82
  if (raw.kind === 'none' && onlyKeys(raw, ['kind'])) return 'none';
71
- if (raw.kind !== 'header' || !onlyKeys(raw, ['kind', 'name', 'value'])) return invalid();
72
- if (!nonempty(raw.name) || !nonempty(raw.value)) return invalid();
73
- const auth = connectorAuth(raw.name, raw.value);
74
- return auth.kind === 'header' ? 'header' : invalid();
83
+ return headerAuth(raw);
75
84
  }
76
85
 
77
86
  function metadataValid(config: Record<string, unknown>): boolean {
@@ -120,7 +129,13 @@ function strictPolicy(raw: unknown): ToolPolicy {
120
129
  function policyPreview(raw: unknown): Pick<MigrationConnector, 'policy' | 'policySupported'> & { valid: boolean } {
121
130
  try {
122
131
  const policy = strictPolicy(raw);
123
- const policySupported = (policy.read ?? 'allow') === 'allow' && policy.write !== 'ask' && Object.keys(policy.tools ?? {}).length === 0;
132
+ let policySupported = false;
133
+ try {
134
+ connectorPolicyOf(policy);
135
+ policySupported = true;
136
+ } catch {
137
+ policySupported = false;
138
+ }
124
139
  return { policy, policySupported, valid: true };
125
140
  } catch {
126
141
  return { policy: {}, policySupported: false, valid: false };
@@ -141,6 +156,18 @@ function rowIdentity(row: Record<string, unknown>, index: number): { id: string;
141
156
  return { id: id ?? `invalid-${String(index + 1)}`, name: name ?? `Connector ${String(index + 1)}`, valid: id !== null && name !== null };
142
157
  }
143
158
 
159
+ function authIssues(row: Record<string, unknown>, auth: MigrationConnector['auth'], origin: string | null): MigrationIssue[] {
160
+ if (auth === 'oauth') {
161
+ const issues: MigrationIssue[] = ['oauth_sign_in'];
162
+ if (origin === null) return issues;
163
+ const url = parseConnectorUrl(row.url);
164
+ if (![url.origin, `${url.origin}/`, `${url.origin}/mcp`].includes(String(row.url))) issues.push('oauth_private_resource');
165
+ return issues;
166
+ }
167
+ const client = bodyField(row.config, 'client');
168
+ return (auth === 'none' || auth === 'header') && client !== undefined && client !== null ? ['unused_oauth_client'] : [];
169
+ }
170
+
144
171
  function previewRow(raw: unknown, index: number): MigrationConnector {
145
172
  const row = isRecord(raw) ? raw : {};
146
173
  const identity = rowIdentity(row, index);
@@ -151,8 +178,7 @@ function previewRow(raw: unknown, index: number): MigrationConnector {
151
178
  if (!identity.valid || auth === 'invalid' || !valid) issues.push('invalid_config');
152
179
  if (origin === null) issues.push('unsupported_url');
153
180
  if (valid && !policySupported) issues.push('custom_policy');
154
- if (auth === 'oauth') issues.push('oauth_sign_in');
155
- if (auth === 'header') issues.push('header_reentry');
181
+ issues.push(...authIssues(row, auth, origin));
156
182
  return { id: identity.id, name: identity.name, auth, origin, policy, policySupported, issues };
157
183
  }
158
184
 
@@ -191,11 +217,20 @@ function previewRows(dir: string): MigrationConnector[] {
191
217
  return rows;
192
218
  }
193
219
 
220
+ export function migrationSource(id: string, dir = agentsDir()): LocalConnectorRow {
221
+ const source = previewRows(dir).find((row) => row.id === id);
222
+ if (source === undefined) throw new ApiError('The local connector is missing.', 404);
223
+ if (source.issues.some((issue) => issue !== 'oauth_sign_in')) throw new ApiError('This connector cannot be migrated without changing its settings or permissions. Resolve the preview blockers first.', 409);
224
+ const raw = readRows(dir).find((row) => isRecord(row) && row.id === id);
225
+ if (!isRecord(raw) || typeof raw.url !== 'string') throw unavailable();
226
+ return { id, name: source.name, url: raw.url, config: { ...readConfig(raw.config), policy: source.policy } };
227
+ }
228
+
194
229
  export function connectorMigrationPreview(owner: string | null, dir = agentsDir()): ConnectorMigrationPreview {
195
230
  const enabled = syncOptedIn(dir) && syncSetting(dir, connectorSyncScope(owner, readAgentKey(dir))).enabled;
196
231
  const liveEnabled = process.env.METRO_CONNECTORS_LIVE_ENABLED === 'true';
197
- if (!enabled) return { enabled, enrolled: false, agentId: null, owner, liveEnabled, connectors: [] };
232
+ if (!enabled) return { migrationSupported: true, enabled, enrolled: false, agentId: null, owner, liveEnabled, connectors: [] };
198
233
  const enrollment = owner === null ? null : readAgentKey(dir)?.enrollment;
199
234
  const agentId = enrollment?.organization === owner ? enrollment?.server ?? null : null;
200
- return { enabled, enrolled: agentId !== null, agentId, owner, liveEnabled, connectors: previewRows(dir) };
235
+ return { migrationSupported: true, enabled, enrolled: agentId !== null, agentId, owner, liveEnabled, connectors: previewRows(dir) };
201
236
  }
@@ -0,0 +1,23 @@
1
+ import { createHash } from 'node:crypto';
2
+ import type { MigrationPayload } from '@metro-labs/http/connector-migration';
3
+ import { connectorPolicyOf } from '@metro-labs/http/connector-delivery';
4
+ import type { LocalConnectorRow } from './store.js';
5
+
6
+ export function migrationPayload(row: LocalConnectorRow): MigrationPayload {
7
+ const oauth = row.config.oauth || row.config.auth.kind === 'oauth';
8
+ const auth = oauth ? { kind: 'oauth' as const } : row.config.auth;
9
+ if (auth.kind === 'oauth') {
10
+ return stamped({ name: row.name, url: row.url, auth: { kind: 'oauth' }, client: row.config.client, policy: connectorPolicyOf(row.config.policy ?? {}) });
11
+ }
12
+ return stamped({ name: row.name, url: row.url, auth, client: row.config.client, policy: connectorPolicyOf(row.config.policy ?? {}) });
13
+ }
14
+
15
+ function stamped(value: Omit<MigrationPayload, 'sourceStamp'>): MigrationPayload {
16
+ const policy = value.policy;
17
+ const canonical = {
18
+ ...value,
19
+ policy: { read: policy.read ?? 'allow', write: policy.write ?? 'allow', tools: Object.entries(policy.tools ?? {}).sort(([a], [b]) => a.localeCompare(b)) },
20
+ };
21
+ const sourceStamp = createHash('sha256').update(JSON.stringify(canonical)).digest('base64url');
22
+ return { ...value, sourceStamp };
23
+ }
@@ -0,0 +1,122 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { closeSync, existsSync, fsyncSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { isRecord } from '@metro-labs/core/is-record';
5
+ import { parseId } from '@metro-labs/core/ids';
6
+ import { ensureSecureDir } from '@metro-labs/core/secure-fs';
7
+ import { ApiError } from '@metro-labs/http/api-error';
8
+ import { connectorPolicyOf, type ConnectorPolicy } from '@metro-labs/http/connector-delivery';
9
+ import { agentsDir } from '../agents/files.js';
10
+ import { captureMigrationOperations } from './migration-operations.js';
11
+
12
+ export type MigrationPhase = 'preparing' | 'prepared' | 'cutover' | 'active' | 'rollback' | 'rolled_back';
13
+ export type MigrationAuth = 'none' | 'header' | 'oauth';
14
+
15
+ export interface MigrationRecord {
16
+ id: string;
17
+ owner: string;
18
+ agentId: string;
19
+ keyId: string;
20
+ sourceId: string;
21
+ name: string;
22
+ destinationId: string | null;
23
+ phase: MigrationPhase;
24
+ auth: MigrationAuth;
25
+ sourceStamp: string;
26
+ policy: ConnectorPolicy;
27
+ reason: string | null;
28
+ }
29
+
30
+ const phases: readonly MigrationPhase[] = ['preparing', 'prepared', 'cutover', 'active', 'rollback', 'rolled_back'];
31
+ const auths: readonly MigrationAuth[] = ['none', 'header', 'oauth'];
32
+ const fields = ['id', 'owner', 'agentId', 'keyId', 'sourceId', 'name', 'destinationId', 'phase', 'auth', 'sourceStamp', 'policy', 'reason'];
33
+ const unavailable = (): ApiError => new ApiError('Protected connector migration records cannot be read. Restore their file or permissions before using connectors.', 503);
34
+ const DIGEST = /^[A-Za-z0-9_-]{43}$/;
35
+ const pathOf = (dir: string): string => join(dir, 'connector-migrations.json');
36
+ const markerOf = (dir: string): string => join(dir, 'connector-migrations.initialized');
37
+
38
+ function requiredId(value: unknown): string {
39
+ const id = parseId(value);
40
+ if (id === null) throw unavailable();
41
+ return id;
42
+ }
43
+
44
+ function field(value: unknown, pattern: RegExp): string {
45
+ if (typeof value !== 'string' || !pattern.test(value)) throw unavailable();
46
+ return value;
47
+ }
48
+
49
+ function recordOf(value: unknown): MigrationRecord {
50
+ if (!isRecord(value) || Object.keys(value).some((key) => !fields.includes(key))) throw unavailable();
51
+ const phase = phases.find((item) => item === value.phase);
52
+ const auth = auths.find((item) => item === value.auth);
53
+ if (phase === undefined || auth === undefined) throw unavailable();
54
+ return {
55
+ id: requiredId(value.id), sourceId: requiredId(value.sourceId), agentId: requiredId(value.agentId),
56
+ owner: field(value.owner, /^org_[A-Za-z0-9]{1,128}$/), keyId: field(value.keyId, DIGEST), sourceStamp: field(value.sourceStamp, DIGEST),
57
+ name: field(value.name, /^.{1,200}$/), destinationId: value.destinationId === null ? null : requiredId(value.destinationId),
58
+ phase, auth, policy: connectorPolicyOf(value.policy), reason: value.reason === null ? null : field(value.reason, /^[a-z_]{1,64}$/),
59
+ };
60
+ }
61
+
62
+ function recordsOf(text: string): MigrationRecord[] {
63
+ const value: unknown = JSON.parse(text);
64
+ if (!isRecord(value) || value.version !== 1 || !Array.isArray(value.records) || value.records.length > 500) throw unavailable();
65
+ const records = value.records.map(recordOf);
66
+ if (new Set(records.map((record) => record.sourceId)).size !== records.length) throw unavailable();
67
+ return records;
68
+ }
69
+
70
+ export function migrationRecords(dir = agentsDir()): MigrationRecord[] {
71
+ try {
72
+ const marked = existsSync(markerOf(dir));
73
+ if (marked && readFileSync(markerOf(dir), 'utf8') !== '1\n') throw unavailable();
74
+ if (!existsSync(pathOf(dir))) {
75
+ if (marked) throw unavailable();
76
+ return [];
77
+ }
78
+ if (!marked) throw unavailable();
79
+ return recordsOf(readFileSync(pathOf(dir), 'utf8'));
80
+ } catch {
81
+ throw unavailable();
82
+ }
83
+ }
84
+
85
+ export function migrationRecord(id: string, dir = agentsDir()): MigrationRecord | null {
86
+ return migrationRecords(dir).find((record) => record.sourceId === id) ?? null;
87
+ }
88
+
89
+ function flush(path: string): void {
90
+ const fd = openSync(path, 'r');
91
+ try { fsyncSync(fd); } finally { closeSync(fd); }
92
+ }
93
+
94
+ function markInitialized(dir: string): void {
95
+ const path = markerOf(dir);
96
+ if (existsSync(path)) return;
97
+ writeFileSync(path, '1\n', { flag: 'wx', mode: 0o600 });
98
+ flush(path);
99
+ flush(dir);
100
+ }
101
+
102
+ export function saveMigration(record: MigrationRecord, dir = agentsDir()): void {
103
+ const valid = recordOf(record);
104
+ const previous = migrationRecords(dir);
105
+ const rows = previous.filter((row) => row.sourceId !== valid.sourceId);
106
+ if (rows.length >= 500) throw new ApiError('There are too many connector migrations on this agent.', 409);
107
+ rows.push(valid);
108
+ ensureSecureDir(dir);
109
+ markInitialized(dir);
110
+ captureMigrationOperations(valid.sourceId, dir, !previous.some((row) => row.sourceId === valid.sourceId));
111
+ const path = pathOf(dir);
112
+ const temporary = `${path}.${randomBytes(12).toString('hex')}.tmp`;
113
+ const fd = openSync(temporary, 'wx', 0o600);
114
+ try {
115
+ try {
116
+ writeFileSync(fd, `${JSON.stringify({ version: 1, records: rows })}\n`);
117
+ fsyncSync(fd);
118
+ } finally { closeSync(fd); }
119
+ renameSync(temporary, path);
120
+ flush(dir);
121
+ } finally { rmSync(temporary, { force: true }); }
122
+ }
@@ -8,10 +8,14 @@ import {
8
8
  takePending,
9
9
  type PendingAuth,
10
10
  } from './oauth.js';
11
- import { connectorClient } from '@metro-labs/http/connector-input';
11
+ import { connectorClient, connectorName } from '@metro-labs/http/connector-input';
12
12
  import { parseConnectorUrl } from '@metro-labs/http/connector-url';
13
13
  import type { OAuthAuth } from '@metro-labs/http/connector-mcp';
14
14
  import type { Connector, PendingConnectorInput } from './store.js';
15
+ import { agentsDir } from '../agents/files.js';
16
+ import { withLocalMigrationOperation } from './migration-guard.js';
17
+ import { migrationRecord } from './migration-state.js';
18
+ import { ApiError } from '@metro-labs/http/api-error';
15
19
 
16
20
  export function hostOf(url: string): string {
17
21
  try {
@@ -22,6 +26,7 @@ export function hostOf(url: string): string {
22
26
  }
23
27
 
24
28
  export interface OAuthRouteDeps {
29
+ migrationDir?: string;
25
30
  createPendingConnector: (input: PendingConnectorInput) => Promise<Connector>;
26
31
  reconnectConnector: (id: string, auth: OAuthAuth) => Promise<Connector>;
27
32
  getConnector: (id: string) => Promise<Connector>;
@@ -34,32 +39,37 @@ export async function startOAuth(
34
39
  body: unknown,
35
40
  payload: (row: Connector) => Record<string, unknown>,
36
41
  ): Promise<void> {
37
- const url = parseConnectorUrl(bodyField(body, 'url'));
38
- const returnTo = stringOf(bodyField(body, 'returnTo'));
39
- const clientId = bodyField(body, 'clientId');
40
- const clientSecret = bodyField(body, 'clientSecret');
41
- const prepared = await prepareOAuth({
42
- url,
43
- client: connectorClient(clientId, clientSecret),
44
- returnTo,
45
- });
46
- const row = await deps.createPendingConnector({
47
- name: bodyField(body, 'name'),
48
- url: bodyField(body, 'url'),
49
- clientId,
50
- clientSecret,
51
- });
52
- const authorize = beginOAuth(prepared, {
53
- name: row.name,
54
- url,
55
- returnTo,
56
- connectorId: row.id,
57
- });
58
- log.info(
59
- { id: row.id, host: url.hostname },
60
- 'connector-api: stored unconnected, sending the user to sign in',
61
- );
62
- sendJson(req, res, 201, { ...payload(row), authorizeUrl: authorize });
42
+ const name = connectorName(bodyField(body, 'name'));
43
+ await withLocalMigrationOperation('', deps.migrationDir ?? agentsDir(), async (operation) => {
44
+ const url = parseConnectorUrl(bodyField(body, 'url'));
45
+ const returnTo = stringOf(bodyField(body, 'returnTo'));
46
+ const clientId = bodyField(body, 'clientId');
47
+ const clientSecret = bodyField(body, 'clientSecret');
48
+ const prepared = await prepareOAuth({
49
+ url,
50
+ client: connectorClient(clientId, clientSecret),
51
+ returnTo,
52
+ }, operation.fetch);
53
+ const row = await deps.createPendingConnector({
54
+ name: bodyField(body, 'name'),
55
+ url: bodyField(body, 'url'),
56
+ clientId,
57
+ clientSecret,
58
+ });
59
+ operation.check();
60
+ const authorize = beginOAuth(prepared, {
61
+ name: row.name,
62
+ url,
63
+ returnTo,
64
+ connectorId: row.id,
65
+ migrationId: migrationRecord(row.id, deps.migrationDir)?.id ?? null,
66
+ });
67
+ log.info(
68
+ { id: row.id, host: url.hostname },
69
+ 'connector-api: stored unconnected, sending the user to sign in',
70
+ );
71
+ sendJson(req, res, 201, { ...payload(row), authorizeUrl: authorize });
72
+ }, true, name);
63
73
  }
64
74
 
65
75
  export async function handleConnect(
@@ -68,22 +78,26 @@ export async function handleConnect(
68
78
  deps: OAuthRouteDeps,
69
79
  id: string,
70
80
  ): Promise<void> {
71
- const row = await deps.getConnector(id);
72
- const url = parseConnectorUrl(row.url);
73
- const body = await readJsonBody(req);
74
- const returnTo = stringOf(bodyField(body, 'returnTo'));
75
- const prepared = await prepareOAuth({ url, client: row.client, returnTo });
76
- const authorize = beginOAuth(prepared, {
77
- name: row.name,
78
- url,
79
- returnTo,
80
- connectorId: row.id,
81
- });
82
- log.info(
83
- { id: row.id, host: url.hostname },
84
- 'connector-api: sending the user back to sign in',
85
- );
86
- sendJson(req, res, 202, { status: 'oauth', authorizeUrl: authorize });
81
+ await withLocalMigrationOperation(id, deps.migrationDir ?? agentsDir(), async (operation) => {
82
+ const row = await deps.getConnector(id);
83
+ const url = parseConnectorUrl(row.url);
84
+ const body = await readJsonBody(req);
85
+ const returnTo = stringOf(bodyField(body, 'returnTo'));
86
+ const prepared = await prepareOAuth({ url, client: row.client, returnTo }, operation.fetch);
87
+ operation.check();
88
+ const authorize = beginOAuth(prepared, {
89
+ name: row.name,
90
+ url,
91
+ returnTo,
92
+ connectorId: row.id,
93
+ migrationId: migrationRecord(row.id, deps.migrationDir)?.id ?? null,
94
+ });
95
+ log.info(
96
+ { id: row.id, host: url.hostname },
97
+ 'connector-api: sending the user back to sign in',
98
+ );
99
+ sendJson(req, res, 202, { status: 'oauth', authorizeUrl: authorize });
100
+ }, true);
87
101
  }
88
102
 
89
103
  function landing(entry: PendingAuth, id?: string): string {
@@ -111,6 +125,10 @@ async function saveOAuth(
111
125
  return deps.reconnectConnector(entry.connectorId, auth);
112
126
  }
113
127
 
128
+ function checkGeneration(entry: PendingAuth, dir: string | undefined): void {
129
+ if ((entry.migrationId ?? null) !== (migrationRecord(entry.connectorId, dir)?.id ?? null)) throw new ApiError('This sign-in predates the connector migration. Start a fresh sign-in.', 409);
130
+ }
131
+
114
132
  async function settleCallback(
115
133
  res: ServerResponse,
116
134
  deps: OAuthRouteDeps,
@@ -118,8 +136,13 @@ async function settleCallback(
118
136
  code: string,
119
137
  ): Promise<void> {
120
138
  try {
121
- const auth = await completeOAuth(entry, code);
122
- const saved = await saveOAuth(deps, entry, auth);
139
+ const saved = await withLocalMigrationOperation(entry.connectorId, deps.migrationDir ?? agentsDir(), async (operation) => {
140
+ checkGeneration(entry, deps.migrationDir);
141
+ const auth = await completeOAuth(entry, code, operation.fetch);
142
+ operation.check();
143
+ checkGeneration(entry, deps.migrationDir);
144
+ return saveOAuth(deps, entry, auth);
145
+ }, true);
123
146
  log.info(
124
147
  { id: saved.id, name: saved.name, host: hostOf(saved.url) },
125
148
  'connector-api: oauth sign-in completed',
@@ -12,6 +12,7 @@ import {
12
12
  } from '@metro-labs/http/connector-oauth';
13
13
  import { discoverOAuth, type OAuthServer } from '@metro-labs/http/connector-discovery';
14
14
  import { ticketStore } from '@metro-labs/core/tickets';
15
+ import type { FetchLike } from '@metro-labs/core/stations/oauth';
15
16
 
16
17
  export interface PendingAuth {
17
18
  name: string;
@@ -22,6 +23,7 @@ export interface PendingAuth {
22
23
  server: OAuthServer;
23
24
  client: OAuthClient;
24
25
  connectorId: string;
26
+ migrationId?: string | null;
25
27
  }
26
28
 
27
29
  const pending = ticketStore<PendingAuth>(10 * 60_000, 100);
@@ -53,17 +55,18 @@ export interface PreparedOAuth {
53
55
  redirectUri: string;
54
56
  }
55
57
 
56
- export async function prepareOAuth(input: PrepareInput): Promise<PreparedOAuth> {
58
+ export async function prepareOAuth(input: PrepareInput, send: FetchLike = fetch): Promise<PreparedOAuth> {
57
59
  if (!validateReturnTo(input.returnTo))
58
60
  throw refused('that return address is not one Metro will send you back to');
59
- const server = await discoverOAuth(input.url);
61
+ const server = await discoverOAuth(input.url, send);
60
62
  const redirectUri = callbackUri();
61
- const client = input.client ?? (await registerClient(server, redirectUri));
63
+ const client = input.client ?? (await registerClient(server, redirectUri, send));
62
64
  return { server, client, redirectUri };
63
65
  }
64
66
 
65
67
  export interface BeginInput {
66
68
  connectorId: string;
69
+ migrationId?: string | null;
67
70
  name: string;
68
71
  url: URL;
69
72
  returnTo: string;
@@ -82,6 +85,7 @@ export function beginOAuth(prepared: PreparedOAuth, input: BeginInput): string {
82
85
  server,
83
86
  client,
84
87
  connectorId: input.connectorId,
88
+ migrationId: input.migrationId ?? null,
85
89
  });
86
90
  return authorizeUrl({
87
91
  server,
@@ -112,13 +116,14 @@ function authOf(pendingAuth: PendingAuth, tokens: OAuthTokens): OAuthAuth {
112
116
  export async function completeOAuth(
113
117
  entry: PendingAuth,
114
118
  code: string,
119
+ send: FetchLike = fetch,
115
120
  ): Promise<OAuthAuth> {
116
121
  const tokens = await exchangeCode(entry.server, entry.client, {
117
122
  code,
118
123
  redirectUri: callbackUri(),
119
124
  verifier: entry.verifier,
120
125
  resource: entry.resource,
121
- });
126
+ }, send);
122
127
  return authOf(entry, tokens);
123
128
  }
124
129
 
@@ -129,6 +134,7 @@ export function oauthExpired(auth: OAuthAuth, now = Date.now()): boolean {
129
134
  export async function refreshOAuth(
130
135
  auth: OAuthAuth,
131
136
  resource: string,
137
+ send: FetchLike = fetch,
132
138
  ): Promise<OAuthAuth> {
133
139
  if (auth.refreshToken === undefined)
134
140
  throw refused('that connector needs signing in again');
@@ -152,6 +158,7 @@ export async function refreshOAuth(
152
158
  auth.refreshToken,
153
159
  resource,
154
160
  auth.scope,
161
+ send,
155
162
  );
156
163
  return {
157
164
  ...auth,
@@ -56,7 +56,10 @@ function requestInit(row: DeliveredConnector, init: RequestInit | undefined, sca
56
56
 
57
57
  async function discoveryResponse(response: Response, init: RequestInit | undefined, signal: AbortSignal): Promise<Response> {
58
58
  if (response.status >= 300) throw new ApiError('The organization connector refused tool discovery.', 502);
59
- if (init?.method === 'DELETE') return new Response(null, { status: response.status });
59
+ if (init?.method === 'DELETE') {
60
+ await boundedResponse(response, signal);
61
+ return new Response(null, { status: response.status });
62
+ }
60
63
  return boundedResponse(response, signal);
61
64
  }
62
65