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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (23) hide show
  1. package/package.json +1 -1
  2. package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
  3. package/runtime/node_modules/@metro-labs/daemon/src/connectors/config.ts +2 -0
  4. package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-api.ts +10 -5
  5. package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-control.ts +129 -36
  6. package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-guard.ts +6 -5
  7. package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-operations.ts +16 -6
  8. package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-preview.ts +29 -28
  9. package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-source.ts +25 -6
  10. package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-state.ts +24 -4
  11. package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-url.ts +2 -25
  12. package/runtime/node_modules/@metro-labs/daemon/src/connectors/oauth.ts +2 -1
  13. package/runtime/node_modules/@metro-labs/daemon/src/connectors/store.ts +12 -10
  14. package/runtime/node_modules/@metro-labs/http/package.json +4 -1
  15. package/runtime/node_modules/@metro-labs/http/src/connector-delivery-validation.ts +3 -3
  16. package/runtime/node_modules/@metro-labs/http/src/connector-grant.ts +59 -0
  17. package/runtime/node_modules/@metro-labs/http/src/connector-input.ts +1 -1
  18. package/runtime/node_modules/@metro-labs/http/src/connector-mcp.ts +2 -0
  19. package/runtime/node_modules/@metro-labs/http/src/connector-migration.ts +31 -29
  20. package/runtime/node_modules/@metro-labs/http/src/connector-public-url.ts +64 -0
  21. package/runtime/node_modules/@metro-labs/http/src/connector-resource.ts +108 -0
  22. package/runtime/runtime.json +1 -1
  23. package/runtime/sdk-runner/runner.json +1 -1
@@ -1,15 +1,28 @@
1
1
  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
+ import { ApiError } from '@metro-labs/http/api-error';
5
+ import { portableGrant } from '@metro-labs/http/connector-grant';
6
+ import { boundOAuthResource } from '@metro-labs/http/connector-public-url';
4
7
  import type { LocalConnectorRow } from './store.js';
5
8
 
6
- export function migrationPayload(row: LocalConnectorRow): 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 {
7
14
  const oauth = row.config.oauth || row.config.auth.kind === 'oauth';
8
- const auth = oauth ? { kind: 'oauth' as const } : row.config.auth;
9
- if (auth.kind === 'oauth') {
10
- return stamped({ name: row.name, url: row.url, auth: { kind: 'oauth' }, client: row.config.client, policy: connectorPolicyOf(row.config.policy ?? {}) });
11
- }
12
- return stamped({ name: row.name, url: row.url, auth, client: row.config.client, policy: connectorPolicyOf(row.config.policy ?? {}) });
15
+ const auth = sourceAuth(row, oauth, transfer);
16
+ if (!oauth && oauthResource !== undefined) throw new ApiError('Only an OAuth connector accepts a public OAuth resource.', 400);
17
+ return stamped({
18
+ name: row.name, url: row.url, auth, client: row.config.client, policy: connectorPolicyOf(row.config.policy ?? {}),
19
+ ...(oauthResource === undefined ? {} : { oauthResource: boundOAuthResource(row.url, oauthResource) }),
20
+ });
21
+ }
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' };
13
26
  }
14
27
 
15
28
  function stamped(value: Omit<MigrationPayload, 'sourceStamp'>): MigrationPayload {
@@ -21,3 +34,9 @@ function stamped(value: Omit<MigrationPayload, 'sourceStamp'>): MigrationPayload
21
34
  const sourceStamp = createHash('sha256').update(JSON.stringify(canonical)).digest('base64url');
22
35
  return { ...value, sourceStamp };
23
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
+ }
@@ -6,10 +6,11 @@ import { parseId } from '@metro-labs/core/ids';
6
6
  import { ensureSecureDir } from '@metro-labs/core/secure-fs';
7
7
  import { ApiError } from '@metro-labs/http/api-error';
8
8
  import { connectorPolicyOf, type ConnectorPolicy } from '@metro-labs/http/connector-delivery';
9
+ import { publicOAuthResource } from '@metro-labs/http/connector-public-url';
9
10
  import { agentsDir } from '../agents/files.js';
10
11
  import { captureMigrationOperations } from './migration-operations.js';
11
12
 
12
- export type MigrationPhase = 'preparing' | 'prepared' | 'cutover' | 'active' | 'rollback' | 'rolled_back';
13
+ export type MigrationPhase = 'recovering' | 'freezing' | 'preparing' | 'prepared' | 'cutover' | 'active' | 'rollback' | 'rolled_back';
13
14
  export type MigrationAuth = 'none' | 'header' | 'oauth';
14
15
 
15
16
  export interface MigrationRecord {
@@ -22,14 +23,19 @@ export interface MigrationRecord {
22
23
  destinationId: string | null;
23
24
  phase: MigrationPhase;
24
25
  auth: MigrationAuth;
26
+ oauthResource?: string;
27
+ move?: boolean;
28
+ grantImported?: boolean;
29
+ captured?: boolean;
30
+ recoveryReceipt?: string;
25
31
  sourceStamp: string;
26
32
  policy: ConnectorPolicy;
27
33
  reason: string | null;
28
34
  }
29
35
 
30
- 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'];
31
37
  const auths: readonly MigrationAuth[] = ['none', 'header', 'oauth'];
32
- const fields = ['id', 'owner', 'agentId', 'keyId', 'sourceId', 'name', 'destinationId', 'phase', 'auth', 'sourceStamp', 'policy', 'reason'];
38
+ const fields = ['id', 'owner', 'agentId', 'keyId', 'sourceId', 'name', 'destinationId', 'phase', 'auth', 'oauthResource', 'sourceStamp', 'policy', 'reason', 'move', 'grantImported', 'captured', 'recoveryReceipt'];
33
39
  const unavailable = (): ApiError => new ApiError('Protected connector migration records cannot be read. Restore their file or permissions before using connectors.', 503);
34
40
  const DIGEST = /^[A-Za-z0-9_-]{43}$/;
35
41
  const pathOf = (dir: string): string => join(dir, 'connector-migrations.json');
@@ -46,12 +52,26 @@ function field(value: unknown, pattern: RegExp): string {
46
52
  return value;
47
53
  }
48
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
+
49
67
  function recordOf(value: unknown): MigrationRecord {
50
68
  if (!isRecord(value) || Object.keys(value).some((key) => !fields.includes(key))) throw unavailable();
51
69
  const phase = phases.find((item) => item === value.phase);
52
70
  const auth = auths.find((item) => item === value.auth);
53
- if (phase === undefined || auth === undefined) throw unavailable();
71
+ if (phase === undefined || auth === undefined || (value.oauthResource !== undefined && auth !== 'oauth')) throw unavailable();
54
72
  return {
73
+ ...movementOf(value),
74
+ ...(value.oauthResource === undefined ? {} : { oauthResource: publicOAuthResource(value.oauthResource) }),
55
75
  id: requiredId(value.id), sourceId: requiredId(value.sourceId), agentId: requiredId(value.agentId),
56
76
  owner: field(value.owner, /^org_[A-Za-z0-9]{1,128}$/), keyId: field(value.keyId, DIGEST), sourceStamp: field(value.sourceStamp, DIGEST),
57
77
  name: field(value.name, /^.{1,200}$/), destinationId: value.destinationId === null ? null : requiredId(value.destinationId),
@@ -1,31 +1,8 @@
1
- import { BlockList, isIP } from 'node:net';
2
- import { parseConnectorUrl } from '@metro-labs/http/connector-url';
3
-
4
- const blocked = new BlockList();
5
- for (const [address, prefix] of [
6
- ['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10], ['127.0.0.0', 8], ['169.254.0.0', 16], ['172.16.0.0', 12],
7
- ['192.0.0.0', 24], ['192.0.2.0', 24], ['192.88.99.0', 24], ['192.168.0.0', 16], ['198.18.0.0', 15],
8
- ['198.51.100.0', 24], ['203.0.113.0', 24], ['224.0.0.0', 4], ['240.0.0.0', 4],
9
- ] as const) blocked.addSubnet(address, prefix, 'ipv4');
10
- for (const [address, prefix] of [
11
- ['2001::', 32], ['2001:10::', 28], ['2001:20::', 28], ['2001:db8::', 32], ['2002::', 16],
12
- ] as const) blocked.addSubnet(address, prefix, 'ipv6');
13
- const globalV6 = new BlockList();
14
- globalV6.addSubnet('2000::', 3, 'ipv6');
15
-
16
- function publicHost(host: string): boolean {
17
- if (isIP(host) === 4) return !blocked.check(host, 'ipv4');
18
- if (isIP(host) === 6) return globalV6.check(host, 'ipv6') && !blocked.check(host, 'ipv6');
19
- const name = host.replace(/\.$/, '');
20
- if (/(^|\.)(localhost|local|internal|lan|home|invalid|test|onion)$/.test(name)) return false;
21
- return name.includes('.') && name.split('.').every((label) => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label));
22
- }
1
+ import { parsePublicConnectorUrl } from '@metro-labs/http/connector-public-url';
23
2
 
24
3
  export function migrationOrigin(raw: unknown): string | null {
25
4
  try {
26
- const url = parseConnectorUrl(raw);
27
- if (url.protocol !== 'https:' || !publicHost(url.hostname.replace(/^\[|\]$/g, ''))) return null;
28
- return url.origin;
5
+ return parsePublicConnectorUrl(raw).origin;
29
6
  } catch {
30
7
  return null;
31
8
  }
@@ -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
- if (signOutOAuth && row.config.auth.kind === 'oauth') replace(dir, signedOut(row), true);
392
- if (signOutOAuth) for (const path of [filePath(dir), dir]) {
389
+ if (signOutOAuth) replace(dir, signedOut(row), true);
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
  }
@@ -14,13 +14,16 @@
14
14
  "./sigv4": "./src/sigv4.ts",
15
15
  "./agent-signature": "./src/agent-signature.ts",
16
16
  "./connector-url": "./src/connector-url.ts",
17
+ "./connector-public-url": "./src/connector-public-url.ts",
18
+ "./connector-resource": "./src/connector-resource.ts",
17
19
  "./connector-reach": "./src/connector-reach.ts",
18
20
  "./connector-mcp": "./src/connector-mcp.ts",
19
21
  "./connector-discovery": "./src/connector-discovery.ts",
20
22
  "./connector-oauth": "./src/connector-oauth.ts",
21
23
  "./connector-input": "./src/connector-input.ts",
22
24
  "./connector-delivery": "./src/connector-delivery.ts",
23
- "./connector-migration": "./src/connector-migration.ts"
25
+ "./connector-migration": "./src/connector-migration.ts",
26
+ "./connector-grant": "./src/connector-grant.ts"
24
27
  },
25
28
  "scripts": {
26
29
  "build": "tsc",
@@ -2,7 +2,7 @@ import { parseId } from '@metro-labs/core/ids';
2
2
  import { isRecord } from '@metro-labs/core/is-record';
3
3
  import { connectorName } from './connector-input.js';
4
4
  import { connectorPolicyOf, type ConnectorPolicy } from './connector-policy.js';
5
- import { parseConnectorUrl } from './connector-url.js';
5
+ import { parsePublicConnectorUrl } from './connector-public-url.js';
6
6
  import { isOrganizationId } from './workos-token.js';
7
7
 
8
8
  export const CONNECTOR_DELIVERY_LEASE_MS = 90_000;
@@ -85,8 +85,8 @@ function stateOf(value: unknown, auth: DeliveredConnector['auth']): DeliveredCon
85
85
  }
86
86
 
87
87
  function urlOf(value: unknown): string {
88
- const url = parseConnectorUrl(value);
89
- if (url.protocol !== 'https:' || url.toString().length > 2048 || typeof value !== 'string') throw new Error('Invalid delivered connector URL.');
88
+ parsePublicConnectorUrl(value);
89
+ if (typeof value !== 'string') throw new Error('Invalid delivered connector URL.');
90
90
  return value;
91
91
  }
92
92
 
@@ -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
+ }
@@ -49,7 +49,7 @@ export function connectorAuth(
49
49
  400,
50
50
  );
51
51
  const name = header === '' ? DEFAULT_HEADER : header;
52
- if (!HEADER_NAME_RE.test(name))
52
+ if (!HEADER_NAME_RE.test(name) || ['__proto__', 'constructor', 'prototype'].includes(name))
53
53
  throw new ConnectorError('that is not a valid HTTP header name', 400);
54
54
  if (!HEADER_VALUE_RE.test(value))
55
55
  throw new ConnectorError('that header value is not sendable', 400);
@@ -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,8 +4,9 @@ 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
- import { parseConnectorUrl } from './connector-url.js';
9
+ import { boundOAuthResource, parsePublicConnectorUrl } from './connector-public-url.js';
9
10
  import { openTransfer, sealTransfer, type SealedTransfer } from './transfer-crypto.js';
10
11
  import { isOrganizationId } from './workos-token.js';
11
12
 
@@ -24,8 +25,9 @@ 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;
30
+ oauthResource?: string;
29
31
  policy: ConnectorPolicy;
30
32
  sourceStamp: string;
31
33
  }
@@ -35,7 +37,8 @@ export interface MigrationView {
35
37
  sourceId: string;
36
38
  destinationId: string;
37
39
  phase: 'preparing' | 'prepared' | 'active' | 'rolled_back';
38
- readiness: 'pending' | 'ready' | 'signin' | 'changed' | 'unavailable';
40
+ readiness: 'pending' | 'ready' | 'signin' | 'changed' | 'unavailable' | 'refresh_uncertain';
41
+ grantImported?: boolean;
39
42
  name: string | null;
40
43
  auth: 'none' | 'header' | 'oauth' | null;
41
44
  policy: ConnectorPolicy | null;
@@ -47,7 +50,7 @@ export interface MigrationResponse {
47
50
  receiving?: { publicKey: string; context: MigrationContext };
48
51
  }
49
52
 
50
- 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 };
51
54
 
52
55
  const invalid = (): Error => new Error('Invalid connector migration.');
53
56
  const idOf = (value: unknown): string => {
@@ -60,7 +63,7 @@ export function migrationRequestOf(raw: unknown): MigrationRequest {
60
63
  const value = deliveryRecord(raw, ['action', 'operationId', 'sourceId', 'sealed']);
61
64
  const operationId = idOf(value.operationId);
62
65
  const sourceId = idOf(value.sourceId);
63
- 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 };
64
67
  if (Object.hasOwn(value, 'sealed')) throw invalid();
65
68
  if (value.action !== 'begin' && value.action !== 'status' && value.action !== 'activate' && value.action !== 'rollback') throw invalid();
66
69
  return { action: value.action, operationId, sourceId };
@@ -78,15 +81,19 @@ function contextBytes(context: MigrationContext): Buffer {
78
81
  return Buffer.from(JSON.stringify(['metro-connector-migration/v1', value.owner, value.agent, value.keyId, value.operationId, value.sourceId, value.destinationId]));
79
82
  }
80
83
 
81
- function authOf(raw: unknown): MigrationPayload['auth'] {
82
- const value = deliveryRecord(raw, ['kind', 'name', 'value']);
83
- if (value.kind === 'header') {
84
- const auth = connectorAuth(value.name, value.value);
85
- if (auth.kind !== 'header' || auth.name !== value.name || auth.value !== value.value || ['__proto__', 'constructor', 'prototype'].includes(auth.name)) throw invalid();
86
- return auth;
87
- }
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);
88
94
  if ((value.kind !== 'none' && value.kind !== 'oauth') || Object.hasOwn(value, 'name') || Object.hasOwn(value, 'value')) throw invalid();
89
- 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) };
90
97
  }
91
98
 
92
99
  function clientOf(raw: unknown): OAuthClient | null {
@@ -97,26 +104,20 @@ function clientOf(raw: unknown): OAuthClient | null {
97
104
  return client;
98
105
  }
99
106
 
100
- function canonicalResource(raw: unknown, url: URL): boolean {
101
- return raw === `${url.origin}${url.pathname}` || (url.pathname === '/' && raw === url.origin);
102
- }
103
-
104
- function migrationClient(auth: MigrationPayload['auth'], url: URL, input: Record<string, unknown>): OAuthClient | null {
107
+ function migrationClient(auth: MigrationPayload['auth'], input: Record<string, unknown>): OAuthClient | null {
105
108
  const client = clientOf(input.client);
106
- if (auth.kind !== 'oauth' && client !== null) throw invalid();
107
- if (auth.kind === 'oauth' && (url.search !== '' || !['/', '/mcp'].includes(url.pathname) || !canonicalResource(input.url, url))) throw invalid();
109
+ if (auth.kind !== 'oauth' && (client !== null || Object.hasOwn(input, 'oauthResource'))) throw invalid();
108
110
  return client;
109
111
  }
110
112
 
111
113
  export function migrationPayloadOf(raw: unknown): MigrationPayload {
112
- const value = deliveryRecord(raw, ['name', 'url', 'auth', 'client', 'policy', 'sourceStamp']);
114
+ const value = deliveryRecord(raw, ['name', 'url', 'auth', 'client', 'oauthResource', 'policy', 'sourceStamp']);
113
115
  if (typeof value.name !== 'string' || connectorName(value.name) !== value.name || typeof value.url !== 'string' || typeof value.sourceStamp !== 'string') throw invalid();
114
- const url = parseConnectorUrl(value.url);
115
- if (url.protocol !== 'https:' || value.url.length > 2048) throw invalid();
116
+ parsePublicConnectorUrl(value.url);
116
117
  deliveryBytes(value.sourceStamp, 32);
117
- const auth = authOf(value.auth);
118
- const client = migrationClient(auth, url, value);
119
- const payload = { name: value.name, url: value.url, auth, client, policy: connectorPolicyOf(value.policy), sourceStamp: value.sourceStamp };
118
+ const auth = authOf(value.auth, value.url);
119
+ const client = migrationClient(auth, value);
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 };
120
121
  if (Buffer.byteLength(JSON.stringify(payload)) > PAYLOAD_MAX) throw invalid();
121
122
  return payload;
122
123
  }
@@ -127,7 +128,7 @@ function phaseOf(value: unknown): MigrationView['phase'] {
127
128
  }
128
129
 
129
130
  function readinessOf(value: unknown): MigrationView['readiness'] {
130
- 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();
131
132
  return value;
132
133
  }
133
134
 
@@ -150,8 +151,9 @@ function nullableStamp(value: unknown): string | null {
150
151
  }
151
152
 
152
153
  function viewOf(raw: unknown): MigrationView {
153
- const view = deliveryRecord(raw, ['operationId', 'sourceId', 'destinationId', 'phase', 'readiness', 'name', 'auth', 'policy', 'sourceStamp']);
154
- 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) };
155
157
  }
156
158
 
157
159
  export function migrationResponseOf(raw: unknown): MigrationResponse {
@@ -0,0 +1,64 @@
1
+ import { BlockList, isIP } from 'node:net';
2
+ import { parseConnectorUrl, refused } from './connector-url.js';
3
+
4
+ const blocked = new BlockList();
5
+ for (const [address, prefix] of [
6
+ ['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10], ['127.0.0.0', 8], ['169.254.0.0', 16], ['172.16.0.0', 12],
7
+ ['192.0.0.0', 24], ['192.0.2.0', 24], ['192.88.99.0', 24], ['192.168.0.0', 16], ['198.18.0.0', 15],
8
+ ['198.51.100.0', 24], ['203.0.113.0', 24], ['224.0.0.0', 4], ['240.0.0.0', 4],
9
+ ] as const) blocked.addSubnet(address, prefix, 'ipv4');
10
+ for (const [address, prefix] of [
11
+ ['2001::', 32], ['2001:10::', 28], ['2001:20::', 28], ['2001:db8::', 32], ['2002::', 16],
12
+ ] as const) blocked.addSubnet(address, prefix, 'ipv6');
13
+ const globalV6 = new BlockList();
14
+ globalV6.addSubnet('2000::', 3, 'ipv6');
15
+
16
+ export function publicConnectorAddress(address: string, family: number): boolean {
17
+ if (family === 4) return isIP(address) === 4 && !blocked.check(address, 'ipv4');
18
+ return isIP(address) === 6 && globalV6.check(address, 'ipv6') && !blocked.check(address, 'ipv6');
19
+ }
20
+
21
+ function publicHost(host: string): boolean {
22
+ const family = isIP(host);
23
+ if (family !== 0) return publicConnectorAddress(host, family);
24
+ if (/(^|\.)(localhost|local|internal|lan|home|invalid|test|onion)$/.test(host)) return false;
25
+ return host.includes('.') && host.split('.').every((label) => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label));
26
+ }
27
+
28
+ function invalidText(raw: string): boolean {
29
+ return raw.length > 2048 || /[\s\\#]/.test(raw) || /%(?![0-9a-f]{2})/i.test(raw);
30
+ }
31
+
32
+ export function parsePublicConnectorUrl(raw: unknown): URL {
33
+ const invalid = (): never => { throw refused('An organization endpoint must be a public HTTPS URL without user information or a fragment.'); };
34
+ if (typeof raw !== 'string' || invalidText(raw)) return invalid();
35
+ const authority = /^https:\/\/([^/ ?#]+)/i.exec(raw)?.[1];
36
+ if (authority === undefined || authority.includes('@')) return invalid();
37
+ let url: URL;
38
+ try { url = parseConnectorUrl(raw); } catch { return invalid(); }
39
+ if (url.toString().length > 2048 || !publicHost(url.hostname.replace(/^\[|\]$/g, ''))) return invalid();
40
+ return url;
41
+ }
42
+
43
+ export function publicOAuthResource(raw: unknown): string {
44
+ try {
45
+ const url = parsePublicConnectorUrl(raw);
46
+ if (typeof raw === 'string' && !raw.includes('?') && url.search === '') return raw;
47
+ } catch {
48
+ throw refused('The public OAuth resource must be a public HTTPS identifier without a query, user information or a fragment. Do not enter credentials.');
49
+ }
50
+ throw refused('The public OAuth resource must be a public HTTPS identifier without a query, user information or a fragment. Do not enter credentials.');
51
+ }
52
+
53
+ export function optionalOAuthResource(raw: unknown): string | undefined {
54
+ return raw === undefined || raw === '' ? undefined : publicOAuthResource(raw);
55
+ }
56
+
57
+ export function boundOAuthResource(target: string, raw: unknown): string {
58
+ const resource = publicOAuthResource(raw);
59
+ const transport = parsePublicConnectorUrl(target);
60
+ const url = new URL(resource);
61
+ const path = url.pathname.replace(/\/$/, '');
62
+ if (url.origin !== transport.origin || !(path === '' || transport.pathname === path || transport.pathname.startsWith(`${path}/`))) throw refused('The public OAuth resource does not identify this endpoint. Check the provider’s documented resource.');
63
+ return resource;
64
+ }
@@ -0,0 +1,108 @@
1
+ import { isRecord } from '@metro-labs/core/is-record';
2
+ import type { FetchLike } from '@metro-labs/core/stations/oauth';
3
+ import { authServerMetadataUrls, resourceMetadataUrls, type OAuthServer } from './connector-discovery.js';
4
+ import { boundOAuthResource, optionalOAuthResource, parsePublicConnectorUrl, publicOAuthResource } from './connector-public-url.js';
5
+ import { refused, ConnectorVerifyError } from './connector-url.js';
6
+
7
+ const METADATA_MAX = 1024 * 1024;
8
+ const metadataError = (): Error => refused('The provider returned invalid or unavailable public OAuth metadata. Check its public OAuth resource and try again.');
9
+ export class OAuthResourceRequired extends ConnectorVerifyError {
10
+ constructor() { super('Enter the provider’s public OAuth resource before continuing. It is shown in the sign-in page; never enter a private endpoint or credentials.', 400); }
11
+ }
12
+
13
+ async function metadataBody(res: Response): Promise<Record<string, unknown>> {
14
+ if (res.body === null) throw metadataError();
15
+ const reader = res.body.getReader();
16
+ const chunks: Uint8Array[] = [];
17
+ let size = 0;
18
+ try {
19
+ for (;;) {
20
+ const { done, value } = await reader.read();
21
+ if (done) break;
22
+ size += value.length;
23
+ if (size > METADATA_MAX) throw metadataError();
24
+ chunks.push(value);
25
+ }
26
+ const body: unknown = JSON.parse(Buffer.concat(chunks).toString('utf8'));
27
+ if (!isRecord(body)) throw metadataError();
28
+ return body;
29
+ } finally {
30
+ await reader.cancel().catch(() => undefined);
31
+ }
32
+ }
33
+
34
+ async function metadata(url: URL, send: FetchLike): Promise<Record<string, unknown> | null> {
35
+ try {
36
+ const res = await send(url.toString(), { redirect: 'manual', signal: AbortSignal.timeout(10_000), headers: { accept: 'application/json' } });
37
+ if (res.status === 404) {
38
+ await res.body?.cancel();
39
+ return null;
40
+ }
41
+ if (!res.ok) {
42
+ await res.body?.cancel();
43
+ throw metadataError();
44
+ }
45
+ return await metadataBody(res);
46
+ } catch {
47
+ throw metadataError();
48
+ }
49
+ }
50
+
51
+ function selectedResource(transport: URL, offered: string | undefined, body: Record<string, unknown> | null): string {
52
+ const advertised = body !== null && Object.hasOwn(body, 'resource') ? publicOAuthResource(body.resource) : undefined;
53
+ if (advertised !== undefined && offered !== undefined && advertised !== offered) throw metadataError();
54
+ const resource = advertised ?? offered;
55
+ if (resource === undefined) throw new OAuthResourceRequired();
56
+ return boundOAuthResource(transport.toString(), resource);
57
+ }
58
+
59
+ function issuerOf(body: Record<string, unknown> | null, transport: URL): string {
60
+ if (body === null || !Object.hasOwn(body, 'authorization_servers')) return transport.origin;
61
+ const servers = body.authorization_servers;
62
+ if (!Array.isArray(servers) || servers.length === 0 || servers.length > 20) throw metadataError();
63
+ const values = servers.map((value: unknown) => publicOAuthResource(value));
64
+ return values[0] ?? '';
65
+ }
66
+
67
+ function scopesOf(body: Record<string, unknown> | null): string[] {
68
+ if (body === null || !Object.hasOwn(body, 'scopes_supported')) return [];
69
+ const scopes = body.scopes_supported;
70
+ if (!Array.isArray(scopes) || scopes.length > 100 || scopes.some((scope: unknown) => typeof scope !== 'string' || !/^[\x21\x23-\x5b\x5d-\x7e]{1,128}$/.test(scope))) throw metadataError();
71
+ return [...new Set(scopes as string[])];
72
+ }
73
+
74
+ function serverOf(body: Record<string, unknown>, issuer: string, scopes: string[]): OAuthServer {
75
+ if (typeof body.issuer !== 'string' || publicOAuthResource(body.issuer) !== issuer) throw metadataError();
76
+ const endpoint = (raw: unknown): string => parsePublicConnectorUrl(raw).toString();
77
+ return {
78
+ issuer,
79
+ authorizationEndpoint: endpoint(body.authorization_endpoint),
80
+ tokenEndpoint: endpoint(body.token_endpoint),
81
+ registrationEndpoint: body.registration_endpoint === undefined ? null : endpoint(body.registration_endpoint),
82
+ supportsS256: Array.isArray(body.code_challenge_methods_supported) && body.code_challenge_methods_supported.includes('S256'),
83
+ scopes,
84
+ };
85
+ }
86
+
87
+ async function protectedMetadata(base: URL, send: FetchLike): Promise<Record<string, unknown> | null> {
88
+ for (const candidate of resourceMetadataUrls(base)) {
89
+ const body = await metadata(candidate, send);
90
+ if (body !== null) return body;
91
+ }
92
+ return null;
93
+ }
94
+
95
+ export async function discoverPublicOAuth(target: string, offered: unknown, send: FetchLike = fetch): Promise<{ server: OAuthServer; resource: string }> {
96
+ const transport = parsePublicConnectorUrl(target);
97
+ const explicit = optionalOAuthResource(offered);
98
+ if (explicit !== undefined) boundOAuthResource(target, explicit);
99
+ const body = await protectedMetadata(new URL(explicit ?? transport.origin), send);
100
+ const resource = selectedResource(transport, explicit, body);
101
+ const issuer = issuerOf(body, transport);
102
+ const scopes = scopesOf(body);
103
+ for (const candidate of authServerMetadataUrls(new URL(issuer))) {
104
+ const authorization = await metadata(candidate, send);
105
+ if (authorization !== null) return { server: serverOf(authorization, issuer, scopes), resource };
106
+ }
107
+ throw metadataError();
108
+ }
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.290"
2
+ "version": "0.1.0-beta.292"
3
3
  }