@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.
- package/package.json +1 -1
- package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/gates.ts +6 -4
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-api.ts +55 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-control.ts +269 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-guard.ts +90 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-operations.ts +196 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-preview.ts +46 -11
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-source.ts +23 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-state.ts +122 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/oauth-routes.ts +68 -45
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/oauth.ts +11 -4
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-runtime-discovery.ts +4 -1
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-runtime.ts +95 -41
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-control.ts +37 -3
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-watch.ts +15 -1
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/plugin-sync.ts +5 -3
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/publication.ts +20 -5
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-session.ts +1 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay.ts +26 -11
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/store.ts +71 -50
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/tools.ts +2 -1
- package/runtime/node_modules/@metro-labs/daemon/src/routes/local-mode.ts +5 -2
- package/runtime/node_modules/@metro-labs/daemon/src/routes/session-apis.ts +3 -0
- package/runtime/node_modules/@metro-labs/http/package.json +2 -1
- package/runtime/node_modules/@metro-labs/http/src/connector-delivery.ts +6 -41
- package/runtime/node_modules/@metro-labs/http/src/connector-migration.ts +176 -0
- package/runtime/node_modules/@metro-labs/http/src/connector-policy.ts +3 -0
- package/runtime/node_modules/@metro-labs/http/src/transfer-crypto.ts +44 -0
- package/runtime/runtime.json +1 -1
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
|
122
|
-
|
|
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')
|
|
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
|
|