@stage-labs/metro 0.1.0-beta.291 → 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 +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-control.ts +104 -29
- 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 +25 -7
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-source.ts +18 -2
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-state.ts +20 -3
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/oauth.ts +2 -1
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/store.ts +11 -9
- package/runtime/node_modules/@metro-labs/http/package.json +2 -1
- package/runtime/node_modules/@metro-labs/http/src/connector-grant.ts +59 -0
- package/runtime/node_modules/@metro-labs/http/src/connector-mcp.ts +2 -0
- package/runtime/node_modules/@metro-labs/http/src/connector-migration.ts +23 -16
- 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 }),
|
|
@@ -7,7 +7,7 @@ import { publicOAuthResource } from '@metro-labs/http/connector-public-url';
|
|
|
7
7
|
import type { ConnectorMigrations, MigrationAction } from './migration-control.js';
|
|
8
8
|
|
|
9
9
|
const PATH = '/api/connectors/migrations';
|
|
10
|
-
const actions: readonly MigrationAction[] = ['prepare', 'reconcile', 'cutover', 'rollback', 'resolve'];
|
|
10
|
+
const actions: readonly MigrationAction[] = ['move', 'prepare', 'reconcile', 'cutover', 'rollback', 'resolve'];
|
|
11
11
|
export type MigrationApiDeps = Pick<ConnectorMigrations, 'status' | 'run'>;
|
|
12
12
|
|
|
13
13
|
interface MigrationInput {
|
|
@@ -9,17 +9,19 @@ import { agentsDir } from '../agents/files.js';
|
|
|
9
9
|
import { readAgentKey, type AgentKey } from './agent-key.js';
|
|
10
10
|
import { agentCall, METRO_API, MetroApiRefusal, type MetroApi } from './metro-api.js';
|
|
11
11
|
import { migrationSource } from './migration-preview.js';
|
|
12
|
-
import { migrationPayload } from './migration-source.js';
|
|
12
|
+
import { MigrationBlocked, migrationPayload } from './migration-source.js';
|
|
13
13
|
import { localMigrationIdle } from './migration-guard.js';
|
|
14
|
-
import { migrationUncertaintyReceipt, resolveMigrationUncertainty } from './migration-operations.js';
|
|
15
|
-
import { localRollbackMigrationSource } from './store.js';
|
|
14
|
+
import { migrationUncertaintyReceipt, migrationRefreshUncertain, migrationOperationsIdle, resolveMigrationUncertainty } from './migration-operations.js';
|
|
15
|
+
import { flushMigrationSource, localRollbackMigrationSource } from './store.js';
|
|
16
16
|
import { freshOrgConnectors, orgConnectorMigrationIdle, orgConnectorSyncStatus, refreshOrgConnectorRuntime } from './org-sync-watch.js';
|
|
17
17
|
import { migrationRecord, migrationRecords, saveMigration, type MigrationRecord } from './migration-state.js';
|
|
18
18
|
|
|
19
|
-
export type MigrationAction = 'prepare' | 'reconcile' | 'cutover' | 'rollback' | 'resolve';
|
|
19
|
+
export type MigrationAction = 'move' | 'prepare' | 'reconcile' | 'cutover' | 'rollback' | 'resolve';
|
|
20
20
|
interface Context { key: AgentKey; owner: string; agentId: string; scope: string }
|
|
21
21
|
export interface MigrationView extends Pick<MigrationRecord, 'id' | 'sourceId' | 'name' | 'destinationId' | 'phase' | 'auth' | 'reason'> {
|
|
22
22
|
uncertainty: string | null;
|
|
23
|
+
move: boolean;
|
|
24
|
+
grantImported: boolean;
|
|
23
25
|
}
|
|
24
26
|
export interface MigrationStatus {
|
|
25
27
|
scope: string;
|
|
@@ -44,12 +46,23 @@ interface MigrationDeps {
|
|
|
44
46
|
|
|
45
47
|
const conflict = (message: string): ApiError => new ApiError(message, 409);
|
|
46
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);
|
|
47
|
-
const viewOf = ({ id, sourceId, name, destinationId, phase, auth, reason }: MigrationRecord, uncertainty: string | null): MigrationView => ({ id, sourceId, name, destinationId, phase, auth, reason, uncertainty });
|
|
48
|
-
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' };
|
|
49
51
|
const samePolicy = (a: MigrationRecord['policy'], b: MigrationRecord['policy']): boolean =>
|
|
50
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());
|
|
51
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
|
+
|
|
52
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.');
|
|
53
66
|
if (action === 'prepare' && error instanceof MetroApiRefusal && error.upstreamStatus === 400 && error.code === 'oauth_resource_required') {
|
|
54
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);
|
|
55
68
|
}
|
|
@@ -82,7 +95,7 @@ export class ConnectorMigrations {
|
|
|
82
95
|
const keyId = this.key()?.keyId;
|
|
83
96
|
const migrations = migrationRecords(this.dir).filter((row) => row.owner === owner).map((row) => {
|
|
84
97
|
const uncertainty = migrationUncertaintyReceipt(row.sourceId, this.dir);
|
|
85
|
-
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';
|
|
86
99
|
return viewOf({ ...row, reason: row.agentId !== agentId || row.keyId !== keyId ? 'identity_changed' : reason }, uncertainty);
|
|
87
100
|
});
|
|
88
101
|
return { scope, owner, agentId, enabled, enrolled, migrations };
|
|
@@ -111,12 +124,12 @@ export class ConnectorMigrations {
|
|
|
111
124
|
}
|
|
112
125
|
|
|
113
126
|
private source(record: MigrationRecord): ReturnType<typeof migrationPayload> {
|
|
114
|
-
const payload = migrationPayload(migrationSource(record.sourceId, this.dir), record.oauthResource);
|
|
127
|
+
const payload = migrationPayload(migrationSource(record.sourceId, this.dir, record.move === true && record.captured === true), record.oauthResource, record.move === true && record.captured === true);
|
|
115
128
|
if (payload.sourceStamp !== record.sourceStamp) throw conflict('The retained local connector changed. Restore its original settings before continuing; Metro will not overwrite it.');
|
|
116
129
|
return payload;
|
|
117
130
|
}
|
|
118
131
|
|
|
119
|
-
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> {
|
|
120
133
|
this.check(context, record);
|
|
121
134
|
let response: MigrationResponse;
|
|
122
135
|
try {
|
|
@@ -127,9 +140,7 @@ export class ConnectorMigrations {
|
|
|
127
140
|
throw callFailure(error, action);
|
|
128
141
|
}
|
|
129
142
|
this.check(context, record);
|
|
130
|
-
|
|
131
|
-
if (remote.operationId !== record.id || remote.sourceId !== record.sourceId || (record.destinationId !== null && remote.destinationId !== record.destinationId)) throw uncertain();
|
|
132
|
-
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);
|
|
133
144
|
return response;
|
|
134
145
|
}
|
|
135
146
|
|
|
@@ -137,14 +148,14 @@ export class ConnectorMigrations {
|
|
|
137
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))) {
|
|
138
149
|
return this.save(context, { ...record, destinationId: remote.destinationId, reason: 'destination_changed' });
|
|
139
150
|
}
|
|
140
|
-
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] });
|
|
141
152
|
}
|
|
142
153
|
|
|
143
|
-
private create(context: Context, id: string, oauthResource?: string): MigrationRecord {
|
|
154
|
+
private create(context: Context, id: string, oauthResource?: string, move = false): MigrationRecord {
|
|
144
155
|
const payload = migrationPayload(migrationSource(id, this.dir), oauthResource);
|
|
145
156
|
return this.save(context, {
|
|
146
157
|
id: newId(), owner: context.owner, agentId: context.agentId, keyId: context.key.keyId,
|
|
147
|
-
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,
|
|
148
159
|
sourceStamp: payload.sourceStamp, policy: payload.policy, reason: null,
|
|
149
160
|
...(payload.oauthResource === undefined ? {} : { oauthResource: payload.oauthResource }),
|
|
150
161
|
});
|
|
@@ -154,25 +165,53 @@ export class ConnectorMigrations {
|
|
|
154
165
|
const payload = this.source(record);
|
|
155
166
|
const begun = await this.call(context, record, 'begin');
|
|
156
167
|
record = this.accept(context, record, begun.migration);
|
|
157
|
-
if (begun.migration.phase === 'prepared') {
|
|
168
|
+
if (begun.migration.phase === 'prepared' && !upgrading(record, begun.migration)) {
|
|
158
169
|
this.save(context, { ...record, phase: 'prepared' });
|
|
159
170
|
return;
|
|
160
171
|
}
|
|
161
|
-
if (begun.migration.phase
|
|
172
|
+
if (!['preparing', 'prepared'].includes(begun.migration.phase) || begun.receiving === undefined) throw uncertain();
|
|
162
173
|
const receiving = begun.receiving;
|
|
163
174
|
const expected = { owner: context.owner, agent: context.agentId, keyId: context.key.keyId, operationId: record.id, sourceId: record.sourceId, destinationId: record.destinationId };
|
|
164
175
|
for (const [key, value] of Object.entries(expected)) if (receiving.context[key as keyof typeof receiving.context] !== value) throw uncertain();
|
|
165
176
|
const sealed = sealConnectorMigration(receiving.publicKey, receiving.context, payload);
|
|
166
|
-
const { migration } = await this.call(context, record, 'prepare', sealed);
|
|
177
|
+
const { migration } = await this.call(context, record, grantTransfer(record) ? 'handover' : 'prepare', sealed);
|
|
167
178
|
record = this.accept(context, record, migration);
|
|
168
179
|
if (migration.phase !== 'prepared') throw uncertain();
|
|
169
180
|
this.save(context, { ...record, phase: 'prepared' });
|
|
170
181
|
}
|
|
171
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
|
+
|
|
172
211
|
private waiting(context: Context, record: MigrationRecord, includeOrganization = false): boolean {
|
|
173
212
|
const uncertain = migrationUncertaintyReceipt(record.sourceId, this.dir) !== null;
|
|
174
213
|
if (!uncertain && this.localIdle(record.sourceId) && (!includeOrganization || this.orgIdle(record.sourceId))) return false;
|
|
175
|
-
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' });
|
|
176
215
|
return true;
|
|
177
216
|
}
|
|
178
217
|
|
|
@@ -202,7 +241,15 @@ export class ConnectorMigrations {
|
|
|
202
241
|
}
|
|
203
242
|
|
|
204
243
|
private async rollback(context: Context, record: MigrationRecord): Promise<void> {
|
|
205
|
-
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);
|
|
206
253
|
record = this.save(context, { ...record, phase: 'rollback' });
|
|
207
254
|
if (this.waiting(context, record, true)) return;
|
|
208
255
|
const { migration } = await this.call(context, record, 'rollback');
|
|
@@ -211,15 +258,23 @@ export class ConnectorMigrations {
|
|
|
211
258
|
return;
|
|
212
259
|
}
|
|
213
260
|
this.check(context, record);
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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 });
|
|
217
270
|
}
|
|
218
271
|
|
|
219
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);
|
|
220
276
|
if (record.phase === 'preparing') return this.prepare(context, record);
|
|
221
277
|
if (record.phase === 'cutover') return this.cutover(context, record);
|
|
222
|
-
if (record.phase === 'rollback') return this.rollback(context, record);
|
|
223
278
|
if (record.phase === 'rolled_back') return;
|
|
224
279
|
const { migration } = await this.call(context, record, 'status');
|
|
225
280
|
this.accept(context, record, migration);
|
|
@@ -228,25 +283,45 @@ export class ConnectorMigrations {
|
|
|
228
283
|
private obtain(context: Context, sourceId: string, action: MigrationAction, oauthResource?: string): MigrationRecord | null {
|
|
229
284
|
const record = migrationRecord(sourceId, this.dir);
|
|
230
285
|
if (record !== null) this.check(context, record);
|
|
231
|
-
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' });
|
|
232
288
|
if (record === null && action !== 'reconcile') throw conflict('Prepare this connector before moving it.');
|
|
233
289
|
return record;
|
|
234
290
|
}
|
|
235
291
|
|
|
236
292
|
private perform(context: Context, record: MigrationRecord, action: MigrationAction): Promise<void> {
|
|
293
|
+
if (action === 'move') return this.move(context, record);
|
|
237
294
|
if (action === 'prepare' && record.phase === 'preparing') return this.prepare(context, record);
|
|
238
295
|
if (action === 'cutover' && (record.phase === 'prepared' || record.phase === 'cutover')) return this.cutover(context, record);
|
|
239
296
|
if (action === 'rollback' && record.phase !== 'rolled_back') return this.rollback(context, record);
|
|
240
297
|
return this.reconcile(context, record);
|
|
241
298
|
}
|
|
242
299
|
|
|
243
|
-
private resolve(context: Context, record: MigrationRecord, receipt: string | null): void {
|
|
300
|
+
private async resolve(context: Context, record: MigrationRecord, receipt: string | null): Promise<void> {
|
|
244
301
|
this.check(context, record);
|
|
245
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);
|
|
246
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.');
|
|
247
305
|
this.save(context, { ...record, reason: record.phase === 'prepared' ? 'not_verified' : null });
|
|
248
306
|
}
|
|
249
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
|
+
|
|
250
325
|
private validateResource(sourceId: string, action: MigrationAction, oauthResource: string | undefined): void {
|
|
251
326
|
if (oauthResource === undefined) return;
|
|
252
327
|
const resource = publicOAuthResource(oauthResource);
|
|
@@ -262,14 +337,14 @@ export class ConnectorMigrations {
|
|
|
262
337
|
|
|
263
338
|
async run(sourceId: string, action: MigrationAction, scope: string, operationId: string | null, uncertainty: string | null = null, oauthResource?: string): Promise<MigrationStatus> {
|
|
264
339
|
if (this.running) throw conflict('Another migration request is still running on this agent. Refresh after it finishes.');
|
|
265
|
-
const context = this.context(scope,
|
|
340
|
+
const context = this.context(scope, ['move', 'prepare', 'cutover'].includes(action));
|
|
266
341
|
this.validateIntent(sourceId, action, operationId, uncertainty);
|
|
267
342
|
this.validateResource(sourceId, action, oauthResource);
|
|
268
343
|
this.running = true;
|
|
269
344
|
try {
|
|
270
345
|
const record = this.obtain(context, sourceId, action, oauthResource);
|
|
271
346
|
if (record !== null) {
|
|
272
|
-
if (action === 'resolve') this.resolve(context, record, uncertainty);
|
|
347
|
+
if (action === 'resolve') await this.resolve(context, record, uncertainty);
|
|
273
348
|
else await this.perform(context, record, action);
|
|
274
349
|
}
|
|
275
350
|
this.check(context);
|
|
@@ -277,7 +352,7 @@ export class ConnectorMigrations {
|
|
|
277
352
|
} catch (error) {
|
|
278
353
|
const record = migrationRecord(sourceId, this.dir);
|
|
279
354
|
if (record !== null) {
|
|
280
|
-
try { this.save(context, { ...record, reason: 'unavailable' }); } catch {
|
|
355
|
+
try { this.save(context, { ...record, reason: error instanceof MigrationBlocked ? error.reason : 'unavailable' }); } catch {
|
|
281
356
|
log.warn('connector migration: the interrupted operation needs reconciliation in its original enrollment');
|
|
282
357
|
}
|
|
283
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,6 +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 { portableGrant } from '@metro-labs/http/connector-grant';
|
|
8
9
|
import { connectorPolicyOf } from '@metro-labs/http/connector-delivery';
|
|
9
10
|
import { readConfig } from './config.js';
|
|
10
11
|
import type { LocalConnectorRow } from './store.js';
|
|
@@ -12,9 +13,10 @@ import { agentsDir } from '../agents/files.js';
|
|
|
12
13
|
import { normalizePolicy, type ToolPolicy } from '../policy/policy.js';
|
|
13
14
|
import { connectorSyncScope, syncOptedIn, syncSetting } from './org-sync-setting.js';
|
|
14
15
|
import { readAgentKey } from './agent-key.js';
|
|
16
|
+
import { MigrationBlocked } from './migration-source.js';
|
|
15
17
|
import { migrationOrigin } from './migration-url.js';
|
|
16
18
|
|
|
17
|
-
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';
|
|
18
20
|
|
|
19
21
|
export interface MigrationConnector {
|
|
20
22
|
id: string;
|
|
@@ -29,6 +31,7 @@ export interface MigrationConnector {
|
|
|
29
31
|
export interface ConnectorMigrationPreview {
|
|
30
32
|
migrationSupported: true;
|
|
31
33
|
oauthResourceSupported: true;
|
|
34
|
+
moveAllSupported: true;
|
|
32
35
|
enabled: boolean;
|
|
33
36
|
enrolled: boolean;
|
|
34
37
|
agentId: string | null;
|
|
@@ -54,7 +57,7 @@ function validClient(raw: unknown): boolean {
|
|
|
54
57
|
}
|
|
55
58
|
|
|
56
59
|
function oauthAuth(raw: Record<string, unknown>): 'oauth' {
|
|
57
|
-
if (!onlyKeys(raw, ['kind', 'accessToken', 'refreshToken', 'clientId', 'clientSecret', 'tokenEndpoint', 'issuer', 'scope', 'expiresAt'])) invalid();
|
|
60
|
+
if (!onlyKeys(raw, ['kind', 'accessToken', 'refreshToken', 'clientId', 'clientSecret', 'tokenEndpoint', 'issuer', 'scope', 'expiresAt', 'resource', 'tokenEndpointAuthMethod'])) invalid();
|
|
58
61
|
return 'oauth';
|
|
59
62
|
}
|
|
60
63
|
|
|
@@ -145,8 +148,17 @@ function rowIdentity(row: Record<string, unknown>, index: number): { id: string;
|
|
|
145
148
|
return { id: id ?? `invalid-${String(index + 1)}`, name: name ?? `Connector ${String(index + 1)}`, valid: id !== null && name !== null };
|
|
146
149
|
}
|
|
147
150
|
|
|
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
|
+
|
|
148
160
|
function authIssues(row: Record<string, unknown>, auth: MigrationConnector['auth']): MigrationIssue[] {
|
|
149
|
-
if (auth === 'oauth') return
|
|
161
|
+
if (auth === 'oauth') return grantIssues(row);
|
|
150
162
|
const client = bodyField(row.config, 'client');
|
|
151
163
|
return (auth === 'none' || auth === 'header') && client !== undefined && client !== null ? ['unused_oauth_client'] : [];
|
|
152
164
|
}
|
|
@@ -200,10 +212,16 @@ function previewRows(dir: string): MigrationConnector[] {
|
|
|
200
212
|
return rows;
|
|
201
213
|
}
|
|
202
214
|
|
|
203
|
-
|
|
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 {
|
|
204
221
|
const source = previewRows(dir).find((row) => row.id === id);
|
|
205
222
|
if (source === undefined) throw new ApiError('The local connector is missing.', 404);
|
|
206
|
-
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);
|
|
207
225
|
const raw = readRows(dir).find((row) => isRecord(row) && row.id === id);
|
|
208
226
|
if (!isRecord(raw) || typeof raw.url !== 'string') throw unavailable();
|
|
209
227
|
return { id, name: source.name, url: raw.url, config: { ...readConfig(raw.config), oauth: source.auth === 'oauth', policy: source.policy } };
|
|
@@ -212,8 +230,8 @@ export function migrationSource(id: string, dir = agentsDir()): LocalConnectorRo
|
|
|
212
230
|
export function connectorMigrationPreview(owner: string | null, dir = agentsDir()): ConnectorMigrationPreview {
|
|
213
231
|
const enabled = syncOptedIn(dir) && syncSetting(dir, connectorSyncScope(owner, readAgentKey(dir))).enabled;
|
|
214
232
|
const liveEnabled = process.env.METRO_CONNECTORS_LIVE_ENABLED === 'true';
|
|
215
|
-
if (!enabled) return { migrationSupported: true, oauthResourceSupported: 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: [] };
|
|
216
234
|
const enrollment = owner === null ? null : readAgentKey(dir)?.enrollment;
|
|
217
235
|
const agentId = enrollment?.organization === owner ? enrollment?.server ?? null : null;
|
|
218
|
-
return { migrationSupported: true, oauthResourceSupported: 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) };
|
|
219
237
|
}
|
|
@@ -2,12 +2,17 @@ import { createHash } from 'node:crypto';
|
|
|
2
2
|
import type { MigrationPayload } from '@metro-labs/http/connector-migration';
|
|
3
3
|
import { connectorPolicyOf } from '@metro-labs/http/connector-delivery';
|
|
4
4
|
import { ApiError } from '@metro-labs/http/api-error';
|
|
5
|
+
import { portableGrant } from '@metro-labs/http/connector-grant';
|
|
5
6
|
import { boundOAuthResource } from '@metro-labs/http/connector-public-url';
|
|
6
7
|
import type { LocalConnectorRow } from './store.js';
|
|
7
8
|
|
|
8
|
-
export
|
|
9
|
+
export class MigrationBlocked extends ApiError {
|
|
10
|
+
constructor(readonly reason: string, message: string) { super(message, 409); }
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function migrationPayload(row: LocalConnectorRow, oauthResource?: string, transfer = false): MigrationPayload {
|
|
9
14
|
const oauth = row.config.oauth || row.config.auth.kind === 'oauth';
|
|
10
|
-
const auth =
|
|
15
|
+
const auth = sourceAuth(row, oauth, transfer);
|
|
11
16
|
if (!oauth && oauthResource !== undefined) throw new ApiError('Only an OAuth connector accepts a public OAuth resource.', 400);
|
|
12
17
|
return stamped({
|
|
13
18
|
name: row.name, url: row.url, auth, client: row.config.client, policy: connectorPolicyOf(row.config.policy ?? {}),
|
|
@@ -15,6 +20,11 @@ export function migrationPayload(row: LocalConnectorRow, oauthResource?: string)
|
|
|
15
20
|
});
|
|
16
21
|
}
|
|
17
22
|
|
|
23
|
+
function sourceAuth(row: LocalConnectorRow, oauth: boolean, transfer: boolean): MigrationPayload['auth'] {
|
|
24
|
+
if (!oauth) return row.config.auth;
|
|
25
|
+
return transfer ? { kind: 'oauth', grant: transferableGrant(row) } : { kind: 'oauth' };
|
|
26
|
+
}
|
|
27
|
+
|
|
18
28
|
function stamped(value: Omit<MigrationPayload, 'sourceStamp'>): MigrationPayload {
|
|
19
29
|
const policy = value.policy;
|
|
20
30
|
const canonical = {
|
|
@@ -24,3 +34,9 @@ function stamped(value: Omit<MigrationPayload, 'sourceStamp'>): MigrationPayload
|
|
|
24
34
|
const sourceStamp = createHash('sha256').update(JSON.stringify(canonical)).digest('base64url');
|
|
25
35
|
return { ...value, sourceStamp };
|
|
26
36
|
}
|
|
37
|
+
|
|
38
|
+
function transferableGrant(row: LocalConnectorRow): ReturnType<typeof portableGrant> {
|
|
39
|
+
if (row.config.auth.kind !== 'oauth') throw new MigrationBlocked('sign_in', 'This connector has no saved OAuth grant. Sign in locally before moving it.');
|
|
40
|
+
try { return portableGrant(row.config.auth, row.url); }
|
|
41
|
+
catch { throw new MigrationBlocked('unsupported_grant', 'This OAuth grant has unsupported client authentication or provider binding. Keep it local or sign in separately.'); }
|
|
42
|
+
}
|
|
@@ -10,7 +10,7 @@ import { publicOAuthResource } from '@metro-labs/http/connector-public-url';
|
|
|
10
10
|
import { agentsDir } from '../agents/files.js';
|
|
11
11
|
import { captureMigrationOperations } from './migration-operations.js';
|
|
12
12
|
|
|
13
|
-
export type MigrationPhase = 'preparing' | 'prepared' | 'cutover' | 'active' | 'rollback' | 'rolled_back';
|
|
13
|
+
export type MigrationPhase = 'recovering' | 'freezing' | 'preparing' | 'prepared' | 'cutover' | 'active' | 'rollback' | 'rolled_back';
|
|
14
14
|
export type MigrationAuth = 'none' | 'header' | 'oauth';
|
|
15
15
|
|
|
16
16
|
export interface MigrationRecord {
|
|
@@ -24,14 +24,18 @@ export interface MigrationRecord {
|
|
|
24
24
|
phase: MigrationPhase;
|
|
25
25
|
auth: MigrationAuth;
|
|
26
26
|
oauthResource?: string;
|
|
27
|
+
move?: boolean;
|
|
28
|
+
grantImported?: boolean;
|
|
29
|
+
captured?: boolean;
|
|
30
|
+
recoveryReceipt?: string;
|
|
27
31
|
sourceStamp: string;
|
|
28
32
|
policy: ConnectorPolicy;
|
|
29
33
|
reason: string | null;
|
|
30
34
|
}
|
|
31
35
|
|
|
32
|
-
const phases: readonly MigrationPhase[] = ['preparing', 'prepared', 'cutover', 'active', 'rollback', 'rolled_back'];
|
|
36
|
+
const phases: readonly MigrationPhase[] = ['recovering', 'freezing', 'preparing', 'prepared', 'cutover', 'active', 'rollback', 'rolled_back'];
|
|
33
37
|
const auths: readonly MigrationAuth[] = ['none', 'header', 'oauth'];
|
|
34
|
-
const fields = ['id', 'owner', 'agentId', 'keyId', 'sourceId', 'name', 'destinationId', 'phase', 'auth', 'oauthResource', 'sourceStamp', 'policy', 'reason'];
|
|
38
|
+
const fields = ['id', 'owner', 'agentId', 'keyId', 'sourceId', 'name', 'destinationId', 'phase', 'auth', 'oauthResource', 'sourceStamp', 'policy', 'reason', 'move', 'grantImported', 'captured', 'recoveryReceipt'];
|
|
35
39
|
const unavailable = (): ApiError => new ApiError('Protected connector migration records cannot be read. Restore their file or permissions before using connectors.', 503);
|
|
36
40
|
const DIGEST = /^[A-Za-z0-9_-]{43}$/;
|
|
37
41
|
const pathOf = (dir: string): string => join(dir, 'connector-migrations.json');
|
|
@@ -48,12 +52,25 @@ function field(value: unknown, pattern: RegExp): string {
|
|
|
48
52
|
return value;
|
|
49
53
|
}
|
|
50
54
|
|
|
55
|
+
function movementOf(value: Record<string, unknown>): Pick<MigrationRecord, 'move' | 'grantImported' | 'captured' | 'recoveryReceipt'> {
|
|
56
|
+
const result: Pick<MigrationRecord, 'move' | 'grantImported' | 'captured' | 'recoveryReceipt'> = {};
|
|
57
|
+
for (const key of ['move', 'grantImported', 'captured'] as const) {
|
|
58
|
+
const flag = value[key];
|
|
59
|
+
if (flag === undefined) continue;
|
|
60
|
+
if (typeof flag !== 'boolean') throw unavailable();
|
|
61
|
+
result[key] = flag;
|
|
62
|
+
}
|
|
63
|
+
if (value.recoveryReceipt !== undefined) result.recoveryReceipt = field(value.recoveryReceipt, /^[a-f0-9]{64}$/);
|
|
64
|
+
return result;
|
|
65
|
+
}
|
|
66
|
+
|
|
51
67
|
function recordOf(value: unknown): MigrationRecord {
|
|
52
68
|
if (!isRecord(value) || Object.keys(value).some((key) => !fields.includes(key))) throw unavailable();
|
|
53
69
|
const phase = phases.find((item) => item === value.phase);
|
|
54
70
|
const auth = auths.find((item) => item === value.auth);
|
|
55
71
|
if (phase === undefined || auth === undefined || (value.oauthResource !== undefined && auth !== 'oauth')) throw unavailable();
|
|
56
72
|
return {
|
|
73
|
+
...movementOf(value),
|
|
57
74
|
...(value.oauthResource === undefined ? {} : { oauthResource: publicOAuthResource(value.oauthResource) }),
|
|
58
75
|
id: requiredId(value.id), sourceId: requiredId(value.sourceId), agentId: requiredId(value.agentId),
|
|
59
76
|
owner: field(value.owner, /^org_[A-Za-z0-9]{1,128}$/), keyId: field(value.keyId, DIGEST), sourceStamp: field(value.sourceStamp, DIGEST),
|
|
@@ -102,6 +102,7 @@ function authOf(pendingAuth: PendingAuth, tokens: OAuthTokens): OAuthAuth {
|
|
|
102
102
|
kind: 'oauth',
|
|
103
103
|
issuer: pendingAuth.server.issuer,
|
|
104
104
|
tokenEndpoint: pendingAuth.server.tokenEndpoint,
|
|
105
|
+
resource: pendingAuth.resource,
|
|
105
106
|
clientId: pendingAuth.client.clientId,
|
|
106
107
|
...(pendingAuth.client.clientSecret === undefined
|
|
107
108
|
? {}
|
|
@@ -156,7 +157,7 @@ export async function refreshOAuth(
|
|
|
156
157
|
server,
|
|
157
158
|
client,
|
|
158
159
|
auth.refreshToken,
|
|
159
|
-
resource,
|
|
160
|
+
auth.resource ?? resource,
|
|
160
161
|
auth.scope,
|
|
161
162
|
send,
|
|
162
163
|
);
|
|
@@ -327,8 +327,10 @@ function refreshOnce(row: LocalConnectorRow, auth: OAuthAuth, dir: string): Prom
|
|
|
327
327
|
const running = inflight.get(key);
|
|
328
328
|
if (running !== undefined) return running;
|
|
329
329
|
const job = withLocalMigrationOperation(row.id, dir, async (operation) => {
|
|
330
|
-
const fresh = await refreshOAuth(auth, parseConnectorUrl(row.url).toString(), operation.fetch);
|
|
330
|
+
const fresh = await refreshOAuth(auth, parseConnectorUrl(row.url).toString(), (input, init) => operation.dispatch(() => fetch(input, init), { refresh: true }));
|
|
331
331
|
updateRow(dir, row.id, { auth: fresh }, true);
|
|
332
|
+
flushMigrationSource(dir);
|
|
333
|
+
operation.persistedRefresh();
|
|
332
334
|
return fresh;
|
|
333
335
|
}).finally(() => { inflight.delete(key); });
|
|
334
336
|
inflight.set(key, job);
|
|
@@ -369,11 +371,7 @@ async function oauthTarget(row: LocalConnectorRow, auth: OAuthAuth, force: boole
|
|
|
369
371
|
}
|
|
370
372
|
}
|
|
371
373
|
|
|
372
|
-
export async function localRelayTarget(
|
|
373
|
-
connectorId: string,
|
|
374
|
-
force: boolean,
|
|
375
|
-
dir = agentsDir(),
|
|
376
|
-
): Promise<RelayTarget> {
|
|
374
|
+
export async function localRelayTarget(connectorId: string, force: boolean, dir = agentsDir()): Promise<RelayTarget> {
|
|
377
375
|
return withLocalMigrationOperation(connectorId, dir, async (operation) => {
|
|
378
376
|
const row = readLocalConnectors(dir).find((r) => r.id === connectorId);
|
|
379
377
|
if (row === undefined) return { kind: 'missing' };
|
|
@@ -385,11 +383,15 @@ export async function localRelayTarget(
|
|
|
385
383
|
});
|
|
386
384
|
}
|
|
387
385
|
|
|
388
|
-
export function localRollbackMigrationSource(id: string, signOutOAuth: boolean, dir = agentsDir()): void {
|
|
389
|
-
if (migrationSource(id, dir)?.phase
|
|
386
|
+
export function localRollbackMigrationSource(id: string, signOutOAuth: boolean, dir = agentsDir(), discardUncertain = false): void {
|
|
387
|
+
if (!['rollback', 'recovering'].includes(migrationSource(id, dir)?.phase ?? '') || !localMigrationIdle(id, dir, discardUncertain && signOutOAuth)) throw new ConnectorError('The connector migration is not ready to roll back.', 409);
|
|
390
388
|
const row = rowOrThrow(id, dir);
|
|
391
389
|
if (signOutOAuth) replace(dir, signedOut(row), true);
|
|
392
|
-
if (signOutOAuth)
|
|
390
|
+
if (signOutOAuth) flushMigrationSource(dir);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
export function flushMigrationSource(dir = agentsDir()): void {
|
|
394
|
+
for (const path of [filePath(dir), dir]) {
|
|
393
395
|
const fd = openSync(path, 'r');
|
|
394
396
|
try { fsyncSync(fd); } finally { closeSync(fd); }
|
|
395
397
|
}
|
|
@@ -22,7 +22,8 @@
|
|
|
22
22
|
"./connector-oauth": "./src/connector-oauth.ts",
|
|
23
23
|
"./connector-input": "./src/connector-input.ts",
|
|
24
24
|
"./connector-delivery": "./src/connector-delivery.ts",
|
|
25
|
-
"./connector-migration": "./src/connector-migration.ts"
|
|
25
|
+
"./connector-migration": "./src/connector-migration.ts",
|
|
26
|
+
"./connector-grant": "./src/connector-grant.ts"
|
|
26
27
|
},
|
|
27
28
|
"scripts": {
|
|
28
29
|
"build": "tsc",
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { deliveryRecord } from './connector-delivery-validation.js';
|
|
2
|
+
import type { OAuthAuth } from './connector-mcp.js';
|
|
3
|
+
import { boundOAuthResource, parsePublicConnectorUrl } from './connector-public-url.js';
|
|
4
|
+
|
|
5
|
+
export interface PortableGrant extends OAuthAuth {
|
|
6
|
+
resource: string;
|
|
7
|
+
tokenEndpointAuthMethod: 'none' | 'client_secret_post';
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const invalid = (): never => { throw new Error('This OAuth grant cannot be transferred. Its client authentication or provider binding is unsupported. Sign in separately.'); };
|
|
11
|
+
|
|
12
|
+
function text(raw: unknown, max = 16384): string {
|
|
13
|
+
if (typeof raw !== 'string' || raw.length === 0 || raw.length > max || /[^\x21-\x7e]/.test(raw)) return invalid();
|
|
14
|
+
return raw;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function endpoint(raw: unknown): string {
|
|
18
|
+
const value = text(raw, 2048);
|
|
19
|
+
parsePublicConnectorUrl(value);
|
|
20
|
+
return value;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function grantResource(target: string, raw: unknown): string {
|
|
24
|
+
const transport = parsePublicConnectorUrl(target);
|
|
25
|
+
const resource = parsePublicConnectorUrl(raw);
|
|
26
|
+
if (resource.toString() === transport.toString() && typeof raw === 'string') return raw;
|
|
27
|
+
return boundOAuthResource(target, raw);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function binding(value: Record<string, unknown>): Pick<PortableGrant, 'clientId' | 'clientSecret' | 'tokenEndpointAuthMethod'> {
|
|
31
|
+
const clientSecret = value.clientSecret === undefined ? undefined : text(value.clientSecret);
|
|
32
|
+
const method = value.tokenEndpointAuthMethod ?? (clientSecret === undefined ? 'none' : 'client_secret_post');
|
|
33
|
+
if (method !== 'none' && method !== 'client_secret_post') return invalid();
|
|
34
|
+
if ((method === 'client_secret_post') !== (clientSecret !== undefined)) return invalid();
|
|
35
|
+
return { clientId: text(value.clientId), tokenEndpointAuthMethod: method, ...(clientSecret === undefined ? {} : { clientSecret }) };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function expiry(raw: unknown): Pick<PortableGrant, 'expiresAt'> {
|
|
39
|
+
if (raw === undefined) return {};
|
|
40
|
+
if (typeof raw !== 'number' || !Number.isSafeInteger(raw) || raw < 0) return invalid();
|
|
41
|
+
return { expiresAt: raw };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function scopeOf(raw: unknown): Pick<PortableGrant, 'scope'> {
|
|
45
|
+
if (raw === undefined) return {};
|
|
46
|
+
if (typeof raw !== 'string' || raw.length > 16384 || /[^\x20-\x7e]/.test(raw)) return invalid();
|
|
47
|
+
return { scope: raw };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function portableGrant(raw: unknown, target: string): PortableGrant {
|
|
51
|
+
const value = deliveryRecord(raw, ['kind', 'accessToken', 'refreshToken', 'expiresAt', 'clientId', 'clientSecret', 'issuer', 'tokenEndpoint', 'scope', 'resource', 'tokenEndpointAuthMethod']);
|
|
52
|
+
if (value.kind !== 'oauth') return invalid();
|
|
53
|
+
return {
|
|
54
|
+
kind: 'oauth', accessToken: text(value.accessToken, 16377), issuer: endpoint(value.issuer), tokenEndpoint: endpoint(value.tokenEndpoint),
|
|
55
|
+
resource: grantResource(target, value.resource ?? parsePublicConnectorUrl(target).toString()),
|
|
56
|
+
...binding(value), ...expiry(value.expiresAt), ...scopeOf(value.scope),
|
|
57
|
+
...(value.refreshToken === undefined ? {} : { refreshToken: text(value.refreshToken) }),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
@@ -4,6 +4,7 @@ import { sealingKeyOf } from './agent-signature.js';
|
|
|
4
4
|
import { deliveryBytes, deliveryRecord } from './connector-delivery-validation.js';
|
|
5
5
|
import { connectorAuth, connectorClient, connectorName } from './connector-input.js';
|
|
6
6
|
import type { OAuthClient } from './connector-oauth.js';
|
|
7
|
+
import { portableGrant, type PortableGrant } from './connector-grant.js';
|
|
7
8
|
import { connectorPolicyOf, type ConnectorPolicy } from './connector-policy.js';
|
|
8
9
|
import { boundOAuthResource, parsePublicConnectorUrl } from './connector-public-url.js';
|
|
9
10
|
import { openTransfer, sealTransfer, type SealedTransfer } from './transfer-crypto.js';
|
|
@@ -24,7 +25,7 @@ export interface MigrationContext {
|
|
|
24
25
|
export interface MigrationPayload {
|
|
25
26
|
name: string;
|
|
26
27
|
url: string;
|
|
27
|
-
auth: { kind: 'none' } | { kind: 'header'; name: string; value: string } | { kind: 'oauth' };
|
|
28
|
+
auth: { kind: 'none' } | { kind: 'header'; name: string; value: string } | { kind: 'oauth'; grant?: PortableGrant };
|
|
28
29
|
client: OAuthClient | null;
|
|
29
30
|
oauthResource?: string;
|
|
30
31
|
policy: ConnectorPolicy;
|
|
@@ -36,7 +37,8 @@ export interface MigrationView {
|
|
|
36
37
|
sourceId: string;
|
|
37
38
|
destinationId: string;
|
|
38
39
|
phase: 'preparing' | 'prepared' | 'active' | 'rolled_back';
|
|
39
|
-
readiness: 'pending' | 'ready' | 'signin' | 'changed' | 'unavailable';
|
|
40
|
+
readiness: 'pending' | 'ready' | 'signin' | 'changed' | 'unavailable' | 'refresh_uncertain';
|
|
41
|
+
grantImported?: boolean;
|
|
40
42
|
name: string | null;
|
|
41
43
|
auth: 'none' | 'header' | 'oauth' | null;
|
|
42
44
|
policy: ConnectorPolicy | null;
|
|
@@ -48,7 +50,7 @@ export interface MigrationResponse {
|
|
|
48
50
|
receiving?: { publicKey: string; context: MigrationContext };
|
|
49
51
|
}
|
|
50
52
|
|
|
51
|
-
export type MigrationRequest = { action: 'begin' | 'status' | 'activate' | 'rollback'; operationId: string; sourceId: string } | { action: 'prepare'; operationId: string; sourceId: string; sealed: unknown };
|
|
53
|
+
export type MigrationRequest = { action: 'begin' | 'status' | 'activate' | 'rollback'; operationId: string; sourceId: string } | { action: 'prepare' | 'handover'; operationId: string; sourceId: string; sealed: unknown };
|
|
52
54
|
|
|
53
55
|
const invalid = (): Error => new Error('Invalid connector migration.');
|
|
54
56
|
const idOf = (value: unknown): string => {
|
|
@@ -61,7 +63,7 @@ export function migrationRequestOf(raw: unknown): MigrationRequest {
|
|
|
61
63
|
const value = deliveryRecord(raw, ['action', 'operationId', 'sourceId', 'sealed']);
|
|
62
64
|
const operationId = idOf(value.operationId);
|
|
63
65
|
const sourceId = idOf(value.sourceId);
|
|
64
|
-
if (value.action === 'prepare' && Object.hasOwn(value, 'sealed')) return { action:
|
|
66
|
+
if ((value.action === 'prepare' || value.action === 'handover') && Object.hasOwn(value, 'sealed')) return { action: value.action, operationId, sourceId, sealed: value.sealed };
|
|
65
67
|
if (Object.hasOwn(value, 'sealed')) throw invalid();
|
|
66
68
|
if (value.action !== 'begin' && value.action !== 'status' && value.action !== 'activate' && value.action !== 'rollback') throw invalid();
|
|
67
69
|
return { action: value.action, operationId, sourceId };
|
|
@@ -79,15 +81,19 @@ function contextBytes(context: MigrationContext): Buffer {
|
|
|
79
81
|
return Buffer.from(JSON.stringify(['metro-connector-migration/v1', value.owner, value.agent, value.keyId, value.operationId, value.sourceId, value.destinationId]));
|
|
80
82
|
}
|
|
81
83
|
|
|
82
|
-
function
|
|
83
|
-
const
|
|
84
|
-
if (
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
84
|
+
function headerOf(value: Record<string, unknown>): MigrationPayload['auth'] {
|
|
85
|
+
const auth = connectorAuth(value.name, value.value);
|
|
86
|
+
if (auth.kind !== 'header' || auth.name !== value.name || auth.value !== value.value || ['__proto__', 'constructor', 'prototype'].includes(auth.name)) throw invalid();
|
|
87
|
+
return auth;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function authOf(raw: unknown, target: string): MigrationPayload['auth'] {
|
|
91
|
+
const value = deliveryRecord(raw, ['kind', 'name', 'value', 'grant']);
|
|
92
|
+
if (value.kind !== 'oauth' && Object.hasOwn(value, 'grant')) throw invalid();
|
|
93
|
+
if (value.kind === 'header') return headerOf(value);
|
|
89
94
|
if ((value.kind !== 'none' && value.kind !== 'oauth') || Object.hasOwn(value, 'name') || Object.hasOwn(value, 'value')) throw invalid();
|
|
90
|
-
return { kind: value.kind };
|
|
95
|
+
if (value.kind === 'none' || value.grant === undefined) return { kind: value.kind };
|
|
96
|
+
return { kind: 'oauth', grant: portableGrant(value.grant, target) };
|
|
91
97
|
}
|
|
92
98
|
|
|
93
99
|
function clientOf(raw: unknown): OAuthClient | null {
|
|
@@ -109,7 +115,7 @@ export function migrationPayloadOf(raw: unknown): MigrationPayload {
|
|
|
109
115
|
if (typeof value.name !== 'string' || connectorName(value.name) !== value.name || typeof value.url !== 'string' || typeof value.sourceStamp !== 'string') throw invalid();
|
|
110
116
|
parsePublicConnectorUrl(value.url);
|
|
111
117
|
deliveryBytes(value.sourceStamp, 32);
|
|
112
|
-
const auth = authOf(value.auth);
|
|
118
|
+
const auth = authOf(value.auth, value.url);
|
|
113
119
|
const client = migrationClient(auth, value);
|
|
114
120
|
const payload = { name: value.name, url: value.url, auth, client, ...(Object.hasOwn(value, 'oauthResource') ? { oauthResource: boundOAuthResource(value.url, value.oauthResource) } : {}), policy: connectorPolicyOf(value.policy), sourceStamp: value.sourceStamp };
|
|
115
121
|
if (Buffer.byteLength(JSON.stringify(payload)) > PAYLOAD_MAX) throw invalid();
|
|
@@ -122,7 +128,7 @@ function phaseOf(value: unknown): MigrationView['phase'] {
|
|
|
122
128
|
}
|
|
123
129
|
|
|
124
130
|
function readinessOf(value: unknown): MigrationView['readiness'] {
|
|
125
|
-
if (value !== 'pending' && value !== 'ready' && value !== 'signin' && value !== 'changed' && value !== 'unavailable') throw invalid();
|
|
131
|
+
if (value !== 'pending' && value !== 'ready' && value !== 'signin' && value !== 'changed' && value !== 'unavailable' && value !== 'refresh_uncertain') throw invalid();
|
|
126
132
|
return value;
|
|
127
133
|
}
|
|
128
134
|
|
|
@@ -145,8 +151,9 @@ function nullableStamp(value: unknown): string | null {
|
|
|
145
151
|
}
|
|
146
152
|
|
|
147
153
|
function viewOf(raw: unknown): MigrationView {
|
|
148
|
-
const view = deliveryRecord(raw, ['operationId', 'sourceId', 'destinationId', 'phase', 'readiness', 'name', 'auth', 'policy', 'sourceStamp']);
|
|
149
|
-
|
|
154
|
+
const view = deliveryRecord(raw, ['operationId', 'sourceId', 'destinationId', 'phase', 'readiness', 'name', 'auth', 'policy', 'sourceStamp', 'grantImported']);
|
|
155
|
+
if (view.grantImported !== undefined && typeof view.grantImported !== 'boolean') throw invalid();
|
|
156
|
+
return { ...(view.grantImported === undefined ? {} : { grantImported: view.grantImported }), operationId: idOf(view.operationId), sourceId: idOf(view.sourceId), destinationId: idOf(view.destinationId), phase: phaseOf(view.phase), readiness: readinessOf(view.readiness), name: nullableName(view.name), auth: authKindOf(view.auth), policy: view.policy === null ? null : connectorPolicyOf(view.policy), sourceStamp: nullableStamp(view.sourceStamp) };
|
|
150
157
|
}
|
|
151
158
|
|
|
152
159
|
export function migrationResponseOf(raw: unknown): MigrationResponse {
|
package/runtime/runtime.json
CHANGED