@stage-labs/metro 0.1.0-beta.290 → 0.1.0-beta.292
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/config.ts +2 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-api.ts +10 -5
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-control.ts +129 -36
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-guard.ts +6 -5
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-operations.ts +16 -6
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-preview.ts +29 -28
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-source.ts +25 -6
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-state.ts +24 -4
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-url.ts +2 -25
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/oauth.ts +2 -1
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/store.ts +12 -10
- package/runtime/node_modules/@metro-labs/http/package.json +4 -1
- package/runtime/node_modules/@metro-labs/http/src/connector-delivery-validation.ts +3 -3
- package/runtime/node_modules/@metro-labs/http/src/connector-grant.ts +59 -0
- package/runtime/node_modules/@metro-labs/http/src/connector-input.ts +1 -1
- package/runtime/node_modules/@metro-labs/http/src/connector-mcp.ts +2 -0
- package/runtime/node_modules/@metro-labs/http/src/connector-migration.ts +31 -29
- package/runtime/node_modules/@metro-labs/http/src/connector-public-url.ts +64 -0
- package/runtime/node_modules/@metro-labs/http/src/connector-resource.ts +108 -0
- package/runtime/runtime.json +1 -1
- package/runtime/sdk-runner/runner.json +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "metro",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.292",
|
|
4
4
|
"description": "Metro for Claude Code, installed by the metro daemon on this machine: every connector the agent holds as its own MCP server relayed through the daemon, the orchestrator-only guard on the main thread, and the standing rules loaded at each session start. No vendor credential ever sits in a config file.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Bonustrack",
|
|
@@ -44,6 +44,8 @@ function readOAuth(raw: Record<string, unknown>): ConnectorAuth {
|
|
|
44
44
|
clientId,
|
|
45
45
|
tokenEndpoint,
|
|
46
46
|
issuer: stringOf(raw.issuer),
|
|
47
|
+
...(typeof raw.resource === 'string' ? { resource: raw.resource } : {}),
|
|
48
|
+
...(typeof raw.tokenEndpointAuthMethod === 'string' ? { tokenEndpointAuthMethod: raw.tokenEndpointAuthMethod } : {}),
|
|
47
49
|
...(refreshToken === '' ? {} : { refreshToken }),
|
|
48
50
|
...(clientSecret === '' ? {} : { clientSecret }),
|
|
49
51
|
...(scope === '' ? {} : { scope }),
|
|
@@ -3,10 +3,11 @@ import { isRecord } from '@metro-labs/core/is-record';
|
|
|
3
3
|
import { parseId } from '@metro-labs/core/ids';
|
|
4
4
|
import { ApiError } from '@metro-labs/http/api-error';
|
|
5
5
|
import { readJsonBody, sessionRoute } from '@metro-labs/http/api-http';
|
|
6
|
+
import { publicOAuthResource } from '@metro-labs/http/connector-public-url';
|
|
6
7
|
import type { ConnectorMigrations, MigrationAction } from './migration-control.js';
|
|
7
8
|
|
|
8
9
|
const PATH = '/api/connectors/migrations';
|
|
9
|
-
const actions: readonly MigrationAction[] = ['prepare', 'reconcile', 'cutover', 'rollback', 'resolve'];
|
|
10
|
+
const actions: readonly MigrationAction[] = ['move', 'prepare', 'reconcile', 'cutover', 'rollback', 'resolve'];
|
|
10
11
|
export type MigrationApiDeps = Pick<ConnectorMigrations, 'status' | 'run'>;
|
|
11
12
|
|
|
12
13
|
interface MigrationInput {
|
|
@@ -15,6 +16,7 @@ interface MigrationInput {
|
|
|
15
16
|
scope: string;
|
|
16
17
|
operationId: string | null;
|
|
17
18
|
uncertainty: string | null;
|
|
19
|
+
oauthResource?: string;
|
|
18
20
|
}
|
|
19
21
|
|
|
20
22
|
function operationOf(value: unknown): string | null {
|
|
@@ -34,13 +36,16 @@ function receiptOf(body: Record<string, unknown>, action: MigrationAction): stri
|
|
|
34
36
|
}
|
|
35
37
|
|
|
36
38
|
function inputOf(body: unknown): MigrationInput {
|
|
37
|
-
if (!isRecord(body) || Object.keys(body).some((key) => !['scope', 'sourceId', 'action', 'operationId', 'uncertainty'].includes(key)) || typeof body.scope !== 'string' || !/^[a-f0-9]{64}$/.test(body.scope)) {
|
|
39
|
+
if (!isRecord(body) || Object.keys(body).some((key) => !['scope', 'sourceId', 'action', 'operationId', 'uncertainty', 'oauthResource'].includes(key)) || typeof body.scope !== 'string' || !/^[a-f0-9]{64}$/.test(body.scope)) {
|
|
38
40
|
throw new ApiError('A migration needs its connector, action and current enrollment scope.', 400);
|
|
39
41
|
}
|
|
40
42
|
const sourceId = parseId(body.sourceId);
|
|
41
43
|
const action = actions.find((value) => value === body.action);
|
|
42
44
|
if (sourceId === null || action === undefined) throw new ApiError('A migration needs a valid connector and action.', 400);
|
|
43
|
-
return {
|
|
45
|
+
return {
|
|
46
|
+
sourceId, action, scope: body.scope, operationId: operationOf(body.operationId), uncertainty: receiptOf(body, action),
|
|
47
|
+
...(body.oauthResource === undefined ? {} : { oauthResource: publicOAuthResource(body.oauthResource) }),
|
|
48
|
+
};
|
|
44
49
|
}
|
|
45
50
|
|
|
46
51
|
export function handleMigrationRequest(req: IncomingMessage, res: ServerResponse, deps: MigrationApiDeps): boolean {
|
|
@@ -49,7 +54,7 @@ export function handleMigrationRequest(req: IncomingMessage, res: ServerResponse
|
|
|
49
54
|
const current = deps.status();
|
|
50
55
|
if (current.owner === null || current.owner !== session.subject) throw new ApiError('This agent belongs to another organization.', 403);
|
|
51
56
|
if (req.method !== 'POST') return current;
|
|
52
|
-
const { sourceId, action, scope, operationId, uncertainty } = inputOf(body);
|
|
53
|
-
return deps.run(sourceId, action, scope, operationId, uncertainty);
|
|
57
|
+
const { sourceId, action, scope, operationId, uncertainty, oauthResource } = inputOf(body);
|
|
58
|
+
return deps.run(sourceId, action, scope, operationId, uncertainty, oauthResource);
|
|
54
59
|
});
|
|
55
60
|
}
|
|
@@ -2,23 +2,26 @@ import { newId } from '@metro-labs/core/ids';
|
|
|
2
2
|
import { log } from '@metro-labs/core/log';
|
|
3
3
|
import type { ConnectorSyncStatus } from '@metro-labs/core/connector-sync';
|
|
4
4
|
import { ApiError } from '@metro-labs/http/api-error';
|
|
5
|
+
import { publicOAuthResource } from '@metro-labs/http/connector-public-url';
|
|
5
6
|
import type { DeliverySnapshot } from '@metro-labs/http/connector-delivery';
|
|
6
7
|
import { migrationResponseOf, sealConnectorMigration, type MigrationResponse, type MigrationView as RemoteMigration } from '@metro-labs/http/connector-migration';
|
|
7
8
|
import { agentsDir } from '../agents/files.js';
|
|
8
9
|
import { readAgentKey, type AgentKey } from './agent-key.js';
|
|
9
|
-
import { agentCall, METRO_API, type MetroApi } from './metro-api.js';
|
|
10
|
+
import { agentCall, METRO_API, MetroApiRefusal, type MetroApi } from './metro-api.js';
|
|
10
11
|
import { migrationSource } from './migration-preview.js';
|
|
11
|
-
import { migrationPayload } from './migration-source.js';
|
|
12
|
+
import { MigrationBlocked, migrationPayload } from './migration-source.js';
|
|
12
13
|
import { localMigrationIdle } from './migration-guard.js';
|
|
13
|
-
import { migrationUncertaintyReceipt, resolveMigrationUncertainty } from './migration-operations.js';
|
|
14
|
-
import { localRollbackMigrationSource } from './store.js';
|
|
14
|
+
import { migrationUncertaintyReceipt, migrationRefreshUncertain, migrationOperationsIdle, resolveMigrationUncertainty } from './migration-operations.js';
|
|
15
|
+
import { flushMigrationSource, localRollbackMigrationSource } from './store.js';
|
|
15
16
|
import { freshOrgConnectors, orgConnectorMigrationIdle, orgConnectorSyncStatus, refreshOrgConnectorRuntime } from './org-sync-watch.js';
|
|
16
17
|
import { migrationRecord, migrationRecords, saveMigration, type MigrationRecord } from './migration-state.js';
|
|
17
18
|
|
|
18
|
-
export type MigrationAction = 'prepare' | 'reconcile' | 'cutover' | 'rollback' | 'resolve';
|
|
19
|
+
export type MigrationAction = 'move' | 'prepare' | 'reconcile' | 'cutover' | 'rollback' | 'resolve';
|
|
19
20
|
interface Context { key: AgentKey; owner: string; agentId: string; scope: string }
|
|
20
21
|
export interface MigrationView extends Pick<MigrationRecord, 'id' | 'sourceId' | 'name' | 'destinationId' | 'phase' | 'auth' | 'reason'> {
|
|
21
22
|
uncertainty: string | null;
|
|
23
|
+
move: boolean;
|
|
24
|
+
grantImported: boolean;
|
|
22
25
|
}
|
|
23
26
|
export interface MigrationStatus {
|
|
24
27
|
scope: string;
|
|
@@ -43,11 +46,29 @@ interface MigrationDeps {
|
|
|
43
46
|
|
|
44
47
|
const conflict = (message: string): ApiError => new ApiError(message, 409);
|
|
45
48
|
const uncertain = (): ApiError => new ApiError('The migration request was not confirmed. Refresh and reconcile this connector before trying another action; an interrupted request may already have reached Metro.', 503);
|
|
46
|
-
const viewOf = ({ id, sourceId, name, destinationId, phase, auth, reason }: MigrationRecord, uncertainty: string | null): MigrationView => ({ id, sourceId, name, destinationId, phase, auth, reason, uncertainty });
|
|
47
|
-
const reasons: Record<RemoteMigration['readiness'], string | null> = { pending: 'not_verified', ready: null, signin: 'sign_in', changed: 'destination_changed', unavailable: 'unavailable' };
|
|
49
|
+
const viewOf = ({ id, sourceId, name, destinationId, phase, auth, reason, move, grantImported }: MigrationRecord, uncertainty: string | null): MigrationView => ({ id, sourceId, name, destinationId, phase, auth, reason, uncertainty, move: move === true, grantImported: grantImported === true });
|
|
50
|
+
const reasons: Record<RemoteMigration['readiness'], string | null> = { pending: 'not_verified', ready: null, signin: 'sign_in', changed: 'destination_changed', unavailable: 'unavailable', refresh_uncertain: 'refresh_uncertain' };
|
|
48
51
|
const samePolicy = (a: MigrationRecord['policy'], b: MigrationRecord['policy']): boolean =>
|
|
49
52
|
(a.read ?? 'allow') === (b.read ?? 'allow') && (a.write ?? 'allow') === (b.write ?? 'allow') && JSON.stringify(Object.entries(a.tools ?? {}).sort()) === JSON.stringify(Object.entries(b.tools ?? {}).sort());
|
|
50
53
|
|
|
54
|
+
const grantTransfer = (record: MigrationRecord): boolean => record.move === true && record.auth === 'oauth';
|
|
55
|
+
const upgrading = (record: MigrationRecord, remote: RemoteMigration): boolean => grantTransfer(record) && remote.grantImported !== true;
|
|
56
|
+
const canUpgrade = (record: MigrationRecord): boolean => record.move !== true && ['preparing', 'prepared'].includes(record.phase);
|
|
57
|
+
|
|
58
|
+
function checkResponse(record: MigrationRecord, remote: RemoteMigration): void {
|
|
59
|
+
if (remote.operationId !== record.id || remote.sourceId !== record.sourceId || (record.destinationId !== null && remote.destinationId !== record.destinationId)) throw uncertain();
|
|
60
|
+
if (remote.sourceStamp === null || remote.sourceStamp === record.sourceStamp || upgrading(record, remote)) return;
|
|
61
|
+
throw conflict('The prepared destination no longer matches this local connector. Reconcile its current operation before continuing.');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function callFailure(error: unknown, action: string): ApiError {
|
|
65
|
+
if (error instanceof MetroApiRefusal && error.code === 'migration_conflict') return new MigrationBlocked('destination_changed', 'The destination already has credentials, a name conflict or changed settings. Inspect it and reconcile; Metro will not overwrite credentials or create a duplicate.');
|
|
66
|
+
if (action === 'prepare' && error instanceof MetroApiRefusal && error.upstreamStatus === 400 && error.code === 'oauth_resource_required') {
|
|
67
|
+
return new ApiError("Enter the provider's public OAuth resource. Roll back this preparation, then prepare again with that public value; never enter credentials.", 400);
|
|
68
|
+
}
|
|
69
|
+
return uncertain();
|
|
70
|
+
}
|
|
71
|
+
|
|
51
72
|
function received(record: MigrationRecord, snapshot: DeliverySnapshot): boolean {
|
|
52
73
|
const delivered = snapshot.connectors.find((row) => row.id === record.destinationId);
|
|
53
74
|
return delivered?.state === 'ready' && delivered.name === record.name && samePolicy(delivered.policy, record.policy);
|
|
@@ -74,7 +95,7 @@ export class ConnectorMigrations {
|
|
|
74
95
|
const keyId = this.key()?.keyId;
|
|
75
96
|
const migrations = migrationRecords(this.dir).filter((row) => row.owner === owner).map((row) => {
|
|
76
97
|
const uncertainty = migrationUncertaintyReceipt(row.sourceId, this.dir);
|
|
77
|
-
const reason = uncertainty === null ? row.reason : 'requests_uncertain';
|
|
98
|
+
const reason = uncertainty === null ? row.reason : migrationRefreshUncertain(row.sourceId, this.dir) ? 'local_refresh_uncertain' : 'requests_uncertain';
|
|
78
99
|
return viewOf({ ...row, reason: row.agentId !== agentId || row.keyId !== keyId ? 'identity_changed' : reason }, uncertainty);
|
|
79
100
|
});
|
|
80
101
|
return { scope, owner, agentId, enabled, enrolled, migrations };
|
|
@@ -103,25 +124,23 @@ export class ConnectorMigrations {
|
|
|
103
124
|
}
|
|
104
125
|
|
|
105
126
|
private source(record: MigrationRecord): ReturnType<typeof migrationPayload> {
|
|
106
|
-
const payload = migrationPayload(migrationSource(record.sourceId, this.dir));
|
|
127
|
+
const payload = migrationPayload(migrationSource(record.sourceId, this.dir, record.move === true && record.captured === true), record.oauthResource, record.move === true && record.captured === true);
|
|
107
128
|
if (payload.sourceStamp !== record.sourceStamp) throw conflict('The retained local connector changed. Restore its original settings before continuing; Metro will not overwrite it.');
|
|
108
129
|
return payload;
|
|
109
130
|
}
|
|
110
131
|
|
|
111
|
-
private async call(context: Context, record: MigrationRecord, action: 'begin' | 'prepare' | 'status' | 'activate' | 'rollback', sealed?: ReturnType<typeof sealConnectorMigration>): Promise<MigrationResponse> {
|
|
132
|
+
private async call(context: Context, record: MigrationRecord, action: 'begin' | 'prepare' | 'handover' | 'status' | 'activate' | 'rollback', sealed?: ReturnType<typeof sealConnectorMigration>): Promise<MigrationResponse> {
|
|
112
133
|
this.check(context, record);
|
|
113
134
|
let response: MigrationResponse;
|
|
114
135
|
try {
|
|
115
136
|
response = migrationResponseOf(await agentCall(this.api, context.key, '/api/agents/connector-migrations', {
|
|
116
137
|
action, operationId: record.id, sourceId: record.sourceId, ...(sealed === undefined ? {} : { sealed }),
|
|
117
138
|
}, { timeoutMs: 60_000 }));
|
|
118
|
-
} catch {
|
|
119
|
-
throw
|
|
139
|
+
} catch (error) {
|
|
140
|
+
throw callFailure(error, action);
|
|
120
141
|
}
|
|
121
142
|
this.check(context, record);
|
|
122
|
-
|
|
123
|
-
if (remote.operationId !== record.id || remote.sourceId !== record.sourceId || (record.destinationId !== null && remote.destinationId !== record.destinationId)) throw uncertain();
|
|
124
|
-
if (remote.sourceStamp !== null && remote.sourceStamp !== record.sourceStamp) throw conflict('The prepared destination no longer matches this local connector. Roll back this preparation before trying again.');
|
|
143
|
+
checkResponse(record, response.migration);
|
|
125
144
|
return response;
|
|
126
145
|
}
|
|
127
146
|
|
|
@@ -129,15 +148,16 @@ export class ConnectorMigrations {
|
|
|
129
148
|
if (remote.phase !== 'preparing' && remote.phase !== 'rolled_back' && (remote.name !== record.name || remote.auth !== record.auth || remote.policy === null || !samePolicy(record.policy, remote.policy))) {
|
|
130
149
|
return this.save(context, { ...record, destinationId: remote.destinationId, reason: 'destination_changed' });
|
|
131
150
|
}
|
|
132
|
-
return this.save(context, { ...record, destinationId: remote.destinationId, reason: reasons[remote.readiness] });
|
|
151
|
+
return this.save(context, { ...record, destinationId: remote.destinationId, grantImported: remote.grantImported === true, reason: reasons[remote.readiness] });
|
|
133
152
|
}
|
|
134
153
|
|
|
135
|
-
private create(context: Context, id: string): MigrationRecord {
|
|
136
|
-
const payload = migrationPayload(migrationSource(id, this.dir));
|
|
154
|
+
private create(context: Context, id: string, oauthResource?: string, move = false): MigrationRecord {
|
|
155
|
+
const payload = migrationPayload(migrationSource(id, this.dir), oauthResource);
|
|
137
156
|
return this.save(context, {
|
|
138
157
|
id: newId(), owner: context.owner, agentId: context.agentId, keyId: context.key.keyId,
|
|
139
|
-
sourceId: id, name: payload.name, destinationId: null, phase: 'preparing', auth: payload.auth.kind,
|
|
158
|
+
sourceId: id, name: payload.name, destinationId: null, phase: move ? 'freezing' : 'preparing', move, auth: payload.auth.kind,
|
|
140
159
|
sourceStamp: payload.sourceStamp, policy: payload.policy, reason: null,
|
|
160
|
+
...(payload.oauthResource === undefined ? {} : { oauthResource: payload.oauthResource }),
|
|
141
161
|
});
|
|
142
162
|
}
|
|
143
163
|
|
|
@@ -145,25 +165,53 @@ export class ConnectorMigrations {
|
|
|
145
165
|
const payload = this.source(record);
|
|
146
166
|
const begun = await this.call(context, record, 'begin');
|
|
147
167
|
record = this.accept(context, record, begun.migration);
|
|
148
|
-
if (begun.migration.phase === 'prepared') {
|
|
168
|
+
if (begun.migration.phase === 'prepared' && !upgrading(record, begun.migration)) {
|
|
149
169
|
this.save(context, { ...record, phase: 'prepared' });
|
|
150
170
|
return;
|
|
151
171
|
}
|
|
152
|
-
if (begun.migration.phase
|
|
172
|
+
if (!['preparing', 'prepared'].includes(begun.migration.phase) || begun.receiving === undefined) throw uncertain();
|
|
153
173
|
const receiving = begun.receiving;
|
|
154
174
|
const expected = { owner: context.owner, agent: context.agentId, keyId: context.key.keyId, operationId: record.id, sourceId: record.sourceId, destinationId: record.destinationId };
|
|
155
175
|
for (const [key, value] of Object.entries(expected)) if (receiving.context[key as keyof typeof receiving.context] !== value) throw uncertain();
|
|
156
176
|
const sealed = sealConnectorMigration(receiving.publicKey, receiving.context, payload);
|
|
157
|
-
const { migration } = await this.call(context, record, 'prepare', sealed);
|
|
177
|
+
const { migration } = await this.call(context, record, grantTransfer(record) ? 'handover' : 'prepare', sealed);
|
|
158
178
|
record = this.accept(context, record, migration);
|
|
159
179
|
if (migration.phase !== 'prepared') throw uncertain();
|
|
160
180
|
this.save(context, { ...record, phase: 'prepared' });
|
|
161
181
|
}
|
|
162
182
|
|
|
183
|
+
private async freeze(context: Context, record: MigrationRecord): Promise<MigrationRecord | null> {
|
|
184
|
+
const until = Date.now() + 10_000;
|
|
185
|
+
while (!this.localIdle(record.sourceId) && migrationUncertaintyReceipt(record.sourceId, this.dir) === null && Date.now() < until) {
|
|
186
|
+
await new Promise((resolve) => { setTimeout(resolve, 100); });
|
|
187
|
+
this.check(context, record);
|
|
188
|
+
}
|
|
189
|
+
if (this.waiting(context, record)) return null;
|
|
190
|
+
flushMigrationSource(this.dir);
|
|
191
|
+
const payload = migrationPayload(migrationSource(record.sourceId, this.dir, true), record.oauthResource, true);
|
|
192
|
+
return this.save(context, { ...record, sourceStamp: payload.sourceStamp, captured: true, phase: 'preparing', reason: null });
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
private async move(context: Context, record: MigrationRecord): Promise<void> {
|
|
196
|
+
if (record.phase === 'recovering') return this.finishRecovery(context, record);
|
|
197
|
+
if (['active', 'rolled_back'].includes(record.phase)) return this.reconcile(context, record);
|
|
198
|
+
if (record.phase === 'rollback') return this.rollback(context, record);
|
|
199
|
+
if (record.phase === 'freezing') {
|
|
200
|
+
const captured = await this.freeze(context, record);
|
|
201
|
+
if (captured === null) return;
|
|
202
|
+
record = captured;
|
|
203
|
+
}
|
|
204
|
+
if (record.phase === 'preparing') {
|
|
205
|
+
await this.prepare(context, record);
|
|
206
|
+
record = migrationRecord(record.sourceId, this.dir) ?? record;
|
|
207
|
+
}
|
|
208
|
+
if (record.phase === 'prepared' || record.phase === 'cutover') await this.cutover(context, record);
|
|
209
|
+
}
|
|
210
|
+
|
|
163
211
|
private waiting(context: Context, record: MigrationRecord, includeOrganization = false): boolean {
|
|
164
212
|
const uncertain = migrationUncertaintyReceipt(record.sourceId, this.dir) !== null;
|
|
165
213
|
if (!uncertain && this.localIdle(record.sourceId) && (!includeOrganization || this.orgIdle(record.sourceId))) return false;
|
|
166
|
-
this.save(context, { ...record, reason: uncertain ? 'requests_uncertain' : 'waiting_for_local' });
|
|
214
|
+
this.save(context, { ...record, reason: uncertain ? migrationRefreshUncertain(record.sourceId, this.dir) ? 'local_refresh_uncertain' : 'requests_uncertain' : 'waiting_for_local' });
|
|
167
215
|
return true;
|
|
168
216
|
}
|
|
169
217
|
|
|
@@ -193,7 +241,15 @@ export class ConnectorMigrations {
|
|
|
193
241
|
}
|
|
194
242
|
|
|
195
243
|
private async rollback(context: Context, record: MigrationRecord): Promise<void> {
|
|
196
|
-
this.
|
|
244
|
+
if (record.phase === 'recovering') return this.finishRecovery(context, record);
|
|
245
|
+
if (record.grantImported === true) throw new MigrationBlocked('finish_forward', 'Central owns this OAuth grant. Finish forward or sign in centrally; the old local tokens cannot be restored.');
|
|
246
|
+
const begun = await this.call(context, record, 'begin');
|
|
247
|
+
if (begun.migration.grantImported === true) {
|
|
248
|
+
record = this.accept(context, record, begun.migration);
|
|
249
|
+
this.save(context, { ...record, phase: begun.migration.phase === 'active' ? 'cutover' : 'prepared', reason: 'finish_forward' });
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
if (record.phase !== 'freezing') this.source(record);
|
|
197
253
|
record = this.save(context, { ...record, phase: 'rollback' });
|
|
198
254
|
if (this.waiting(context, record, true)) return;
|
|
199
255
|
const { migration } = await this.call(context, record, 'rollback');
|
|
@@ -202,56 +258,93 @@ export class ConnectorMigrations {
|
|
|
202
258
|
return;
|
|
203
259
|
}
|
|
204
260
|
this.check(context, record);
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
261
|
+
this.restore(context, record);
|
|
262
|
+
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
private restore(context: Context, record: MigrationRecord): void {
|
|
266
|
+
const signOut = record.auth === 'oauth' && record.move !== true;
|
|
267
|
+
if (this.deps.restore !== undefined) this.deps.restore(record.sourceId, signOut);
|
|
268
|
+
else localRollbackMigrationSource(record.sourceId, signOut, this.dir);
|
|
269
|
+
this.save(context, { ...record, phase: 'rolled_back', reason: signOut ? 'sign_in' : null });
|
|
208
270
|
}
|
|
209
271
|
|
|
210
272
|
private async reconcile(context: Context, record: MigrationRecord): Promise<void> {
|
|
273
|
+
if (record.phase === 'recovering') return this.finishRecovery(context, record);
|
|
274
|
+
if (record.phase === 'rollback') return this.rollback(context, record);
|
|
275
|
+
if (record.move === true && record.phase !== 'active' && record.phase !== 'rolled_back') return this.move(context, record);
|
|
211
276
|
if (record.phase === 'preparing') return this.prepare(context, record);
|
|
212
277
|
if (record.phase === 'cutover') return this.cutover(context, record);
|
|
213
|
-
if (record.phase === 'rollback') return this.rollback(context, record);
|
|
214
278
|
if (record.phase === 'rolled_back') return;
|
|
215
279
|
const { migration } = await this.call(context, record, 'status');
|
|
216
280
|
this.accept(context, record, migration);
|
|
217
281
|
}
|
|
218
282
|
|
|
219
|
-
private obtain(context: Context, sourceId: string, action: MigrationAction): MigrationRecord | null {
|
|
283
|
+
private obtain(context: Context, sourceId: string, action: MigrationAction, oauthResource?: string): MigrationRecord | null {
|
|
220
284
|
const record = migrationRecord(sourceId, this.dir);
|
|
221
285
|
if (record !== null) this.check(context, record);
|
|
222
|
-
if (
|
|
286
|
+
if (['prepare', 'move'].includes(action) && (record === null || record.phase === 'rolled_back')) return this.create(context, sourceId, oauthResource, action === 'move');
|
|
287
|
+
if (action === 'move' && record !== null && canUpgrade(record)) return this.save(context, { ...record, move: true, phase: 'freezing' });
|
|
223
288
|
if (record === null && action !== 'reconcile') throw conflict('Prepare this connector before moving it.');
|
|
224
289
|
return record;
|
|
225
290
|
}
|
|
226
291
|
|
|
227
292
|
private perform(context: Context, record: MigrationRecord, action: MigrationAction): Promise<void> {
|
|
293
|
+
if (action === 'move') return this.move(context, record);
|
|
228
294
|
if (action === 'prepare' && record.phase === 'preparing') return this.prepare(context, record);
|
|
229
295
|
if (action === 'cutover' && (record.phase === 'prepared' || record.phase === 'cutover')) return this.cutover(context, record);
|
|
230
296
|
if (action === 'rollback' && record.phase !== 'rolled_back') return this.rollback(context, record);
|
|
231
297
|
return this.reconcile(context, record);
|
|
232
298
|
}
|
|
233
299
|
|
|
234
|
-
private resolve(context: Context, record: MigrationRecord, receipt: string | null): void {
|
|
300
|
+
private async resolve(context: Context, record: MigrationRecord, receipt: string | null): Promise<void> {
|
|
235
301
|
this.check(context, record);
|
|
236
302
|
if (receipt === null || receipt !== migrationUncertaintyReceipt(record.sourceId, this.dir)) throw conflict('The outstanding request receipt changed. Refresh before confirming that vendor requests have settled.');
|
|
303
|
+
if (migrationRefreshUncertain(record.sourceId, this.dir)) return this.recoverRefresh(context, record, receipt);
|
|
237
304
|
if (!resolveMigrationUncertainty(record.sourceId, receipt, this.dir)) throw conflict('Requests are still running or their receipt changed. Wait for vendor completion, then refresh before confirming settlement.');
|
|
238
305
|
this.save(context, { ...record, reason: record.phase === 'prepared' ? 'not_verified' : null });
|
|
239
306
|
}
|
|
240
307
|
|
|
308
|
+
private async recoverRefresh(context: Context, record: MigrationRecord, receipt: string): Promise<void> {
|
|
309
|
+
if (record.move !== true || !['freezing', 'rollback'].includes(record.phase) || !migrationOperationsIdle(record.sourceId, this.dir, false)) throw conflict('The uncertain local refresh must stop before aborting the handover.');
|
|
310
|
+
const { migration } = await this.call(context, record, 'rollback');
|
|
311
|
+
if (migration.phase !== 'rolled_back') throw uncertain();
|
|
312
|
+
record = this.save(context, { ...record, phase: 'recovering', recoveryReceipt: receipt });
|
|
313
|
+
await this.finishRecovery(context, record);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
private async finishRecovery(context: Context, record: MigrationRecord): Promise<void> {
|
|
317
|
+
const { migration } = await this.call(context, record, 'rollback');
|
|
318
|
+
if (migration.phase !== 'rolled_back' || record.recoveryReceipt === undefined) throw uncertain();
|
|
319
|
+
localRollbackMigrationSource(record.sourceId, true, this.dir, true);
|
|
320
|
+
const receipt = migrationUncertaintyReceipt(record.sourceId, this.dir);
|
|
321
|
+
if (receipt !== null && !resolveMigrationUncertainty(record.sourceId, record.recoveryReceipt, this.dir)) throw uncertain();
|
|
322
|
+
this.save(context, { ...record, phase: 'rolled_back', recoveryReceipt: undefined, reason: 'sign_in' });
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
private validateResource(sourceId: string, action: MigrationAction, oauthResource: string | undefined): void {
|
|
326
|
+
if (oauthResource === undefined) return;
|
|
327
|
+
const resource = publicOAuthResource(oauthResource);
|
|
328
|
+
const record = migrationRecord(sourceId, this.dir);
|
|
329
|
+
if (action === 'prepare' && (record === null || record.phase === 'rolled_back')) return;
|
|
330
|
+
if (record?.oauthResource !== resource) throw conflict('The public OAuth resource is fixed for this preparation. Roll back, then prepare again to change it.');
|
|
331
|
+
}
|
|
332
|
+
|
|
241
333
|
private validateIntent(sourceId: string, action: MigrationAction, operationId: string | null, receipt: string | null): void {
|
|
242
334
|
if ((migrationRecord(sourceId, this.dir)?.id ?? null) !== operationId) throw conflict('This connector migration changed. Refresh before acting on its current operation.');
|
|
243
335
|
if (action === 'resolve' && (receipt === null || receipt !== migrationUncertaintyReceipt(sourceId, this.dir))) throw conflict('The outstanding request receipt changed. Refresh before confirming that vendor requests have settled.');
|
|
244
336
|
}
|
|
245
337
|
|
|
246
|
-
async run(sourceId: string, action: MigrationAction, scope: string, operationId: string | null, uncertainty: string | null = null): Promise<MigrationStatus> {
|
|
338
|
+
async run(sourceId: string, action: MigrationAction, scope: string, operationId: string | null, uncertainty: string | null = null, oauthResource?: string): Promise<MigrationStatus> {
|
|
247
339
|
if (this.running) throw conflict('Another migration request is still running on this agent. Refresh after it finishes.');
|
|
248
|
-
const context = this.context(scope,
|
|
340
|
+
const context = this.context(scope, ['move', 'prepare', 'cutover'].includes(action));
|
|
249
341
|
this.validateIntent(sourceId, action, operationId, uncertainty);
|
|
342
|
+
this.validateResource(sourceId, action, oauthResource);
|
|
250
343
|
this.running = true;
|
|
251
344
|
try {
|
|
252
|
-
const record = this.obtain(context, sourceId, action);
|
|
345
|
+
const record = this.obtain(context, sourceId, action, oauthResource);
|
|
253
346
|
if (record !== null) {
|
|
254
|
-
if (action === 'resolve') this.resolve(context, record, uncertainty);
|
|
347
|
+
if (action === 'resolve') await this.resolve(context, record, uncertainty);
|
|
255
348
|
else await this.perform(context, record, action);
|
|
256
349
|
}
|
|
257
350
|
this.check(context);
|
|
@@ -259,7 +352,7 @@ export class ConnectorMigrations {
|
|
|
259
352
|
} catch (error) {
|
|
260
353
|
const record = migrationRecord(sourceId, this.dir);
|
|
261
354
|
if (record !== null) {
|
|
262
|
-
try { this.save(context, { ...record, reason: 'unavailable' }); } catch {
|
|
355
|
+
try { this.save(context, { ...record, reason: error instanceof MigrationBlocked ? error.reason : 'unavailable' }); } catch {
|
|
263
356
|
log.warn('connector migration: the interrupted operation needs reconciliation in its original enrollment');
|
|
264
357
|
}
|
|
265
358
|
}
|
|
@@ -6,7 +6,7 @@ import { migrationRecord, migrationRecords } from './migration-state.js';
|
|
|
6
6
|
import { assertMigrationOutcomeKnown, beginMigrationOperation, migrationOperationsIdle, migrationRequestEffectful, type MigrationOperation } from './migration-operations.js';
|
|
7
7
|
|
|
8
8
|
const locked = (phase: string): boolean => phase !== 'rolled_back';
|
|
9
|
-
const retired = (
|
|
9
|
+
const retired = (record: NonNullable<ReturnType<typeof migrationRecord>>): boolean => record.move === true || record.phase === 'cutover' || record.phase === 'active' || record.phase === 'rollback';
|
|
10
10
|
const refused = (): ApiError => new ApiError('This local connector is locked while it moves to the organization.', 409);
|
|
11
11
|
|
|
12
12
|
export function migrationSource(id: string, dir = agentsDir()): ReturnType<typeof migrationRecords>[number] | null {
|
|
@@ -16,13 +16,13 @@ export function migrationSource(id: string, dir = agentsDir()): ReturnType<typeo
|
|
|
16
16
|
|
|
17
17
|
export function localMigrationEnabled(id: string, dir = agentsDir()): boolean {
|
|
18
18
|
const record = migrationSource(id, dir);
|
|
19
|
-
return record === null || !retired(record
|
|
19
|
+
return record === null || !retired(record);
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
export function assertLocalMigrationUse(id: string, dir = agentsDir()): void {
|
|
23
23
|
const record = migrationSource(id, dir);
|
|
24
24
|
if (record === null) return;
|
|
25
|
-
if (retired(record
|
|
25
|
+
if (retired(record)) throw refused();
|
|
26
26
|
assertMigrationOutcomeKnown(id, dir);
|
|
27
27
|
}
|
|
28
28
|
|
|
@@ -35,8 +35,8 @@ export function migrationExportableRows<T extends { id: string; name: string }>(
|
|
|
35
35
|
return rows.filter((row) => !records.some((record) => record.sourceId === row.id || record.name === row.name));
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
export function localMigrationIdle(id: string, dir = agentsDir()): boolean {
|
|
39
|
-
return migrationOperationsIdle(id, dir, migrationSource(id, dir) !== null);
|
|
38
|
+
export function localMigrationIdle(id: string, dir = agentsDir(), allowUncertain = false): boolean {
|
|
39
|
+
return migrationOperationsIdle(id, dir, !allowUncertain && migrationSource(id, dir) !== null);
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
export interface LocalMigrationOperation extends MigrationOperation {
|
|
@@ -61,6 +61,7 @@ export function beginLocalMigrationOperation(id: string, dir = agentsDir(), edit
|
|
|
61
61
|
return {
|
|
62
62
|
check,
|
|
63
63
|
dispatch,
|
|
64
|
+
persistedRefresh: tracked.persistedRefresh,
|
|
64
65
|
fetch: (input, init) => dispatch(() => fetch(input, init), { effectful: migrationRequestEffectful(init?.method, init?.body) }),
|
|
65
66
|
close: () => { closed = true; tracked.close(); },
|
|
66
67
|
};
|
|
@@ -14,11 +14,13 @@ interface RequestOperation {
|
|
|
14
14
|
lease: string;
|
|
15
15
|
durable: boolean;
|
|
16
16
|
abandoned: boolean;
|
|
17
|
+
refresh: boolean;
|
|
17
18
|
}
|
|
18
19
|
|
|
19
20
|
export interface MigrationOperation {
|
|
20
|
-
dispatch: (send: () => Promise<Response>, options?: { protectedSource?: boolean; effectful?: boolean }) => Promise<Response>;
|
|
21
|
+
dispatch: (send: () => Promise<Response>, options?: { protectedSource?: boolean; effectful?: boolean; refresh?: boolean }) => Promise<Response>;
|
|
21
22
|
close: () => void;
|
|
23
|
+
persistedRefresh: () => void;
|
|
22
24
|
}
|
|
23
25
|
|
|
24
26
|
const leases = new Map<string, Set<string>>();
|
|
@@ -57,7 +59,7 @@ function pendingOf(text: string): string[] {
|
|
|
57
59
|
const value: unknown = JSON.parse(text);
|
|
58
60
|
if (!isRecord(value) || value.version !== 1 || !Array.isArray(value.pending) || value.pending.length > 10_000) throw unavailable();
|
|
59
61
|
const ids: unknown[] = value.pending;
|
|
60
|
-
if (!ids.every((id): id is string => typeof id === 'string' && /^[a-f0-9]{32}$/.test(id))) throw unavailable();
|
|
62
|
+
if (!ids.every((id): id is string => typeof id === 'string' && /^r?[a-f0-9]{32}$/.test(id))) throw unavailable();
|
|
61
63
|
if (new Set(ids).size !== ids.length) throw unavailable();
|
|
62
64
|
return ids;
|
|
63
65
|
}
|
|
@@ -114,7 +116,8 @@ export function resolveMigrationUncertainty(source: string, expectedReceipt: str
|
|
|
114
116
|
return true;
|
|
115
117
|
}
|
|
116
118
|
|
|
117
|
-
function finish(request: RequestOperation, completed: boolean): void {
|
|
119
|
+
function finish(request: RequestOperation, completed: boolean, persisted = false): void {
|
|
120
|
+
if (request.refresh && completed && !persisted) return;
|
|
118
121
|
if (request.abandoned) return;
|
|
119
122
|
requests.delete(request.id);
|
|
120
123
|
if (completed) held.delete(request.id);
|
|
@@ -153,10 +156,10 @@ function observed(response: Response, request: RequestOperation): Response {
|
|
|
153
156
|
return new Response(body, { status: response.status, statusText: response.statusText, headers: response.headers });
|
|
154
157
|
}
|
|
155
158
|
|
|
156
|
-
async function dispatch(source: string, dir: string, lease: string, protectedSource: boolean, effectful: boolean, send: () => Promise<Response>): Promise<Response> {
|
|
159
|
+
async function dispatch(source: string, dir: string, lease: string, protectedSource: boolean, effectful: boolean, refresh: boolean, send: () => Promise<Response>): Promise<Response> {
|
|
157
160
|
if (protectedSource) assertMigrationOutcomeKnown(source, dir);
|
|
158
161
|
if (!effectful) return send();
|
|
159
|
-
const request: RequestOperation = { id: randomBytes(16).toString('hex')
|
|
162
|
+
const request: RequestOperation = { id: `${refresh ? 'r' : ''}${randomBytes(16).toString('hex')}`, source, dir, lease, durable: false, abandoned: false, refresh };
|
|
160
163
|
if (protectedSource) {
|
|
161
164
|
const pending = readPending(source, dir);
|
|
162
165
|
if (pending.length >= 10_000) throw unavailable();
|
|
@@ -180,7 +183,10 @@ export function beginMigrationOperation(source: string, dir: string, protectedSo
|
|
|
180
183
|
return {
|
|
181
184
|
dispatch: (send, options = {}) => {
|
|
182
185
|
if (closed) return Promise.reject(uncertain());
|
|
183
|
-
return dispatch(source, dir, lease, options.protectedSource ?? protectedSource, options.effectful ?? true, send);
|
|
186
|
+
return dispatch(source, dir, lease, options.protectedSource ?? protectedSource, options.effectful ?? true, options.refresh ?? false, send);
|
|
187
|
+
},
|
|
188
|
+
persistedRefresh: () => {
|
|
189
|
+
for (const request of held.values()) if (request.lease === lease && request.refresh) finish(request, true, true);
|
|
184
190
|
},
|
|
185
191
|
close: () => {
|
|
186
192
|
if (closed) return;
|
|
@@ -194,3 +200,7 @@ export function beginMigrationOperation(source: string, dir: string, protectedSo
|
|
|
194
200
|
},
|
|
195
201
|
};
|
|
196
202
|
}
|
|
203
|
+
|
|
204
|
+
export function migrationRefreshUncertain(source: string, dir = agentsDir()): boolean {
|
|
205
|
+
return readPending(source, dir).some((id) => id.startsWith('r') && !requests.has(id));
|
|
206
|
+
}
|
|
@@ -5,7 +5,7 @@ import { isRecord } from '@metro-labs/core/is-record';
|
|
|
5
5
|
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
|
-
import {
|
|
8
|
+
import { portableGrant } from '@metro-labs/http/connector-grant';
|
|
9
9
|
import { connectorPolicyOf } from '@metro-labs/http/connector-delivery';
|
|
10
10
|
import { readConfig } from './config.js';
|
|
11
11
|
import type { LocalConnectorRow } from './store.js';
|
|
@@ -13,9 +13,10 @@ import { agentsDir } from '../agents/files.js';
|
|
|
13
13
|
import { normalizePolicy, type ToolPolicy } from '../policy/policy.js';
|
|
14
14
|
import { connectorSyncScope, syncOptedIn, syncSetting } from './org-sync-setting.js';
|
|
15
15
|
import { readAgentKey } from './agent-key.js';
|
|
16
|
+
import { MigrationBlocked } from './migration-source.js';
|
|
16
17
|
import { migrationOrigin } from './migration-url.js';
|
|
17
18
|
|
|
18
|
-
export type MigrationIssue = 'invalid_config' | 'unsupported_url' | 'custom_policy' | 'oauth_sign_in' | 'header_reentry' | 'unused_oauth_client' | '
|
|
19
|
+
export type MigrationIssue = 'invalid_config' | 'unsupported_url' | 'custom_policy' | 'oauth_sign_in' | 'header_reentry' | 'unused_oauth_client' | 'oauth_unsupported';
|
|
19
20
|
|
|
20
21
|
export interface MigrationConnector {
|
|
21
22
|
id: string;
|
|
@@ -29,6 +30,8 @@ export interface MigrationConnector {
|
|
|
29
30
|
|
|
30
31
|
export interface ConnectorMigrationPreview {
|
|
31
32
|
migrationSupported: true;
|
|
33
|
+
oauthResourceSupported: true;
|
|
34
|
+
moveAllSupported: true;
|
|
32
35
|
enabled: boolean;
|
|
33
36
|
enrolled: boolean;
|
|
34
37
|
agentId: string | null;
|
|
@@ -53,19 +56,8 @@ function validClient(raw: unknown): boolean {
|
|
|
53
56
|
return client !== null && client.clientId === raw.clientId && client.clientSecret === raw.clientSecret;
|
|
54
57
|
}
|
|
55
58
|
|
|
56
|
-
function oauthTokensValid(raw: Record<string, unknown>): boolean {
|
|
57
|
-
const token = (value: unknown): boolean => typeof value === 'string' && /^[\x21-\x7e]+$/.test(value);
|
|
58
|
-
return token(raw.accessToken) && (raw.refreshToken === undefined || token(raw.refreshToken));
|
|
59
|
-
}
|
|
60
|
-
|
|
61
59
|
function oauthAuth(raw: Record<string, unknown>): 'oauth' {
|
|
62
|
-
if (!onlyKeys(raw, ['kind', 'accessToken', 'refreshToken', 'clientId', 'clientSecret', 'tokenEndpoint', 'issuer', 'scope', 'expiresAt'])) invalid();
|
|
63
|
-
if (!oauthTokensValid(raw) || ![raw.clientId, raw.tokenEndpoint, raw.issuer].every(nonempty)) invalid();
|
|
64
|
-
if (!optionalStrings(raw, ['refreshToken', 'clientSecret', 'scope'])) invalid();
|
|
65
|
-
if (raw.expiresAt !== undefined && (typeof raw.expiresAt !== 'number' || !Number.isFinite(raw.expiresAt) || raw.expiresAt < 0)) invalid();
|
|
66
|
-
connectorClient(raw.clientId, raw.clientSecret);
|
|
67
|
-
parseConnectorUrl(raw.tokenEndpoint);
|
|
68
|
-
parseConnectorUrl(raw.issuer);
|
|
60
|
+
if (!onlyKeys(raw, ['kind', 'accessToken', 'refreshToken', 'clientId', 'clientSecret', 'tokenEndpoint', 'issuer', 'scope', 'expiresAt', 'resource', 'tokenEndpointAuthMethod'])) invalid();
|
|
69
61
|
return 'oauth';
|
|
70
62
|
}
|
|
71
63
|
|
|
@@ -156,14 +148,17 @@ function rowIdentity(row: Record<string, unknown>, index: number): { id: string;
|
|
|
156
148
|
return { id: id ?? `invalid-${String(index + 1)}`, name: name ?? `Connector ${String(index + 1)}`, valid: id !== null && name !== null };
|
|
157
149
|
}
|
|
158
150
|
|
|
159
|
-
function
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
151
|
+
function grantIssues(row: Record<string, unknown>): MigrationIssue[] {
|
|
152
|
+
const grant = bodyField(row.config, 'auth');
|
|
153
|
+
if (!isRecord(grant) || grant.kind !== 'oauth') return ['oauth_sign_in'];
|
|
154
|
+
try {
|
|
155
|
+
const portable = portableGrant(grant, String(row.url));
|
|
156
|
+
return portable.expiresAt !== undefined && portable.expiresAt <= Date.now() && portable.refreshToken === undefined ? ['oauth_sign_in'] : [];
|
|
157
|
+
} catch { return ['oauth_unsupported']; }
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function authIssues(row: Record<string, unknown>, auth: MigrationConnector['auth']): MigrationIssue[] {
|
|
161
|
+
if (auth === 'oauth') return grantIssues(row);
|
|
167
162
|
const client = bodyField(row.config, 'client');
|
|
168
163
|
return (auth === 'none' || auth === 'header') && client !== undefined && client !== null ? ['unused_oauth_client'] : [];
|
|
169
164
|
}
|
|
@@ -178,7 +173,7 @@ function previewRow(raw: unknown, index: number): MigrationConnector {
|
|
|
178
173
|
if (!identity.valid || auth === 'invalid' || !valid) issues.push('invalid_config');
|
|
179
174
|
if (origin === null) issues.push('unsupported_url');
|
|
180
175
|
if (valid && !policySupported) issues.push('custom_policy');
|
|
181
|
-
issues.push(...authIssues(row, auth
|
|
176
|
+
issues.push(...authIssues(row, auth));
|
|
182
177
|
return { id: identity.id, name: identity.name, auth, origin, policy, policySupported, issues };
|
|
183
178
|
}
|
|
184
179
|
|
|
@@ -217,20 +212,26 @@ function previewRows(dir: string): MigrationConnector[] {
|
|
|
217
212
|
return rows;
|
|
218
213
|
}
|
|
219
214
|
|
|
220
|
-
|
|
215
|
+
function assertTransferable(source: MigrationConnector): void {
|
|
216
|
+
if (source.issues.includes('oauth_sign_in')) throw new MigrationBlocked('sign_in', 'No usable local OAuth grant is saved. Sign in locally before moving it.');
|
|
217
|
+
if (source.issues.includes('oauth_unsupported')) throw new MigrationBlocked('unsupported_grant', 'This OAuth grant has unsupported client authentication or provider binding. Keep it local or sign in separately.');
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export function migrationSource(id: string, dir = agentsDir(), transfer = false): LocalConnectorRow {
|
|
221
221
|
const source = previewRows(dir).find((row) => row.id === id);
|
|
222
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);
|
|
223
|
+
if (source.issues.some((issue) => issue !== 'oauth_sign_in' && issue !== 'oauth_unsupported')) throw new ApiError('This connector cannot be migrated without changing its settings or permissions. Resolve the preview blockers first.', 409);
|
|
224
|
+
if (transfer) assertTransferable(source);
|
|
224
225
|
const raw = readRows(dir).find((row) => isRecord(row) && row.id === id);
|
|
225
226
|
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
|
+
return { id, name: source.name, url: raw.url, config: { ...readConfig(raw.config), oauth: source.auth === 'oauth', policy: source.policy } };
|
|
227
228
|
}
|
|
228
229
|
|
|
229
230
|
export function connectorMigrationPreview(owner: string | null, dir = agentsDir()): ConnectorMigrationPreview {
|
|
230
231
|
const enabled = syncOptedIn(dir) && syncSetting(dir, connectorSyncScope(owner, readAgentKey(dir))).enabled;
|
|
231
232
|
const liveEnabled = process.env.METRO_CONNECTORS_LIVE_ENABLED === 'true';
|
|
232
|
-
if (!enabled) return { migrationSupported: true, enabled, enrolled: false, agentId: null, owner, liveEnabled, connectors: [] };
|
|
233
|
+
if (!enabled) return { migrationSupported: true, oauthResourceSupported: true, moveAllSupported: true, enabled, enrolled: false, agentId: null, owner, liveEnabled, connectors: [] };
|
|
233
234
|
const enrollment = owner === null ? null : readAgentKey(dir)?.enrollment;
|
|
234
235
|
const agentId = enrollment?.organization === owner ? enrollment?.server ?? null : null;
|
|
235
|
-
return { migrationSupported: true, enabled, enrolled: agentId !== null, agentId, owner, liveEnabled, connectors: previewRows(dir) };
|
|
236
|
+
return { migrationSupported: true, oauthResourceSupported: true, moveAllSupported: true, enabled, enrolled: agentId !== null, agentId, owner, liveEnabled, connectors: previewRows(dir) };
|
|
236
237
|
}
|