@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage-labs/metro",
3
- "version": "0.1.0-beta.291",
3
+ "version": "0.1.0-beta.292",
4
4
  "description": "The metro command line: run your agent on this machine, relaying its chat channels and connectors to Claude Code.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "metro",
3
- "version": "0.1.0-beta.291",
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
- const remote = response.migration;
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 !== 'preparing' || begun.receiving === undefined) throw uncertain();
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.source(record);
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
- if (this.deps.restore !== undefined) this.deps.restore(record.sourceId, record.auth === 'oauth');
215
- else localRollbackMigrationSource(record.sourceId, record.auth === 'oauth', this.dir);
216
- this.save(context, { ...record, phase: 'rolled_back', reason: record.auth === 'oauth' ? 'sign_in' : null });
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 (action === 'prepare' && (record === null || record.phase === 'rolled_back')) return this.create(context, sourceId, oauthResource);
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, action === 'prepare' || action === 'cutover');
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 = (phase: string): boolean => phase === 'cutover' || phase === 'active' || phase === 'rollback';
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.phase);
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.phase)) throw refused();
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'), source, dir, lease, durable: false, abandoned: false };
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 ['oauth_sign_in'];
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
- export function migrationSource(id: string, dir = agentsDir()): LocalConnectorRow {
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 function migrationPayload(row: LocalConnectorRow, oauthResource?: string): MigrationPayload {
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 = oauth ? { kind: 'oauth' as const } : row.config.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 !== 'rollback' || !localMigrationIdle(id, dir)) throw new ConnectorError('The connector migration is not ready to roll back.', 409);
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) for (const path of [filePath(dir), dir]) {
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
+ }
@@ -17,6 +17,8 @@ export interface OAuthAuth extends OAuthTokens {
17
17
  clientId: string;
18
18
  clientSecret?: string;
19
19
  scope?: string;
20
+ resource?: string;
21
+ tokenEndpointAuthMethod?: string;
20
22
  }
21
23
 
22
24
  export type ConnectorAuth =
@@ -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: 'prepare', operationId, sourceId, sealed: value.sealed };
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 authOf(raw: unknown): MigrationPayload['auth'] {
83
- const value = deliveryRecord(raw, ['kind', 'name', 'value']);
84
- if (value.kind === 'header') {
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
- }
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
- return { 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) };
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 {
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.291"
2
+ "version": "0.1.0-beta.292"
3
3
  }
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.1.0-beta.291",
2
+ "version": "0.1.0-beta.292",
3
3
  "dependencies": {
4
4
  "@anthropic-ai/claude-agent-sdk": "0.3.287",
5
5
  "@modelcontextprotocol/sdk": "^1.29.0",