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

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.290",
3
+ "version": "0.1.0-beta.291",
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.290",
3
+ "version": "0.1.0-beta.291",
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",
@@ -3,6 +3,7 @@ import { isRecord } from '@metro-labs/core/is-record';
3
3
  import { parseId } from '@metro-labs/core/ids';
4
4
  import { ApiError } from '@metro-labs/http/api-error';
5
5
  import { readJsonBody, sessionRoute } from '@metro-labs/http/api-http';
6
+ import { publicOAuthResource } from '@metro-labs/http/connector-public-url';
6
7
  import type { ConnectorMigrations, MigrationAction } from './migration-control.js';
7
8
 
8
9
  const PATH = '/api/connectors/migrations';
@@ -15,6 +16,7 @@ interface MigrationInput {
15
16
  scope: string;
16
17
  operationId: string | null;
17
18
  uncertainty: string | null;
19
+ oauthResource?: string;
18
20
  }
19
21
 
20
22
  function operationOf(value: unknown): string | null {
@@ -34,13 +36,16 @@ function receiptOf(body: Record<string, unknown>, action: MigrationAction): stri
34
36
  }
35
37
 
36
38
  function inputOf(body: unknown): MigrationInput {
37
- if (!isRecord(body) || Object.keys(body).some((key) => !['scope', 'sourceId', 'action', 'operationId', 'uncertainty'].includes(key)) || typeof body.scope !== 'string' || !/^[a-f0-9]{64}$/.test(body.scope)) {
39
+ if (!isRecord(body) || Object.keys(body).some((key) => !['scope', 'sourceId', 'action', 'operationId', 'uncertainty', 'oauthResource'].includes(key)) || typeof body.scope !== 'string' || !/^[a-f0-9]{64}$/.test(body.scope)) {
38
40
  throw new ApiError('A migration needs its connector, action and current enrollment scope.', 400);
39
41
  }
40
42
  const sourceId = parseId(body.sourceId);
41
43
  const action = actions.find((value) => value === body.action);
42
44
  if (sourceId === null || action === undefined) throw new ApiError('A migration needs a valid connector and action.', 400);
43
- return { sourceId, action, scope: body.scope, operationId: operationOf(body.operationId), uncertainty: receiptOf(body, action) };
45
+ return {
46
+ sourceId, action, scope: body.scope, operationId: operationOf(body.operationId), uncertainty: receiptOf(body, action),
47
+ ...(body.oauthResource === undefined ? {} : { oauthResource: publicOAuthResource(body.oauthResource) }),
48
+ };
44
49
  }
45
50
 
46
51
  export function handleMigrationRequest(req: IncomingMessage, res: ServerResponse, deps: MigrationApiDeps): boolean {
@@ -49,7 +54,7 @@ export function handleMigrationRequest(req: IncomingMessage, res: ServerResponse
49
54
  const current = deps.status();
50
55
  if (current.owner === null || current.owner !== session.subject) throw new ApiError('This agent belongs to another organization.', 403);
51
56
  if (req.method !== 'POST') return current;
52
- const { sourceId, action, scope, operationId, uncertainty } = inputOf(body);
53
- return deps.run(sourceId, action, scope, operationId, uncertainty);
57
+ const { sourceId, action, scope, operationId, uncertainty, oauthResource } = inputOf(body);
58
+ return deps.run(sourceId, action, scope, operationId, uncertainty, oauthResource);
54
59
  });
55
60
  }
@@ -2,11 +2,12 @@ import { newId } from '@metro-labs/core/ids';
2
2
  import { log } from '@metro-labs/core/log';
3
3
  import type { ConnectorSyncStatus } from '@metro-labs/core/connector-sync';
4
4
  import { ApiError } from '@metro-labs/http/api-error';
5
+ import { publicOAuthResource } from '@metro-labs/http/connector-public-url';
5
6
  import type { DeliverySnapshot } from '@metro-labs/http/connector-delivery';
6
7
  import { migrationResponseOf, sealConnectorMigration, type MigrationResponse, type MigrationView as RemoteMigration } from '@metro-labs/http/connector-migration';
7
8
  import { agentsDir } from '../agents/files.js';
8
9
  import { readAgentKey, type AgentKey } from './agent-key.js';
9
- import { agentCall, METRO_API, type MetroApi } from './metro-api.js';
10
+ import { agentCall, METRO_API, MetroApiRefusal, type MetroApi } from './metro-api.js';
10
11
  import { migrationSource } from './migration-preview.js';
11
12
  import { migrationPayload } from './migration-source.js';
12
13
  import { localMigrationIdle } from './migration-guard.js';
@@ -48,6 +49,13 @@ const reasons: Record<RemoteMigration['readiness'], string | null> = { pending:
48
49
  const samePolicy = (a: MigrationRecord['policy'], b: MigrationRecord['policy']): boolean =>
49
50
  (a.read ?? 'allow') === (b.read ?? 'allow') && (a.write ?? 'allow') === (b.write ?? 'allow') && JSON.stringify(Object.entries(a.tools ?? {}).sort()) === JSON.stringify(Object.entries(b.tools ?? {}).sort());
50
51
 
52
+ function callFailure(error: unknown, action: string): ApiError {
53
+ if (action === 'prepare' && error instanceof MetroApiRefusal && error.upstreamStatus === 400 && error.code === 'oauth_resource_required') {
54
+ 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
+ }
56
+ return uncertain();
57
+ }
58
+
51
59
  function received(record: MigrationRecord, snapshot: DeliverySnapshot): boolean {
52
60
  const delivered = snapshot.connectors.find((row) => row.id === record.destinationId);
53
61
  return delivered?.state === 'ready' && delivered.name === record.name && samePolicy(delivered.policy, record.policy);
@@ -103,7 +111,7 @@ export class ConnectorMigrations {
103
111
  }
104
112
 
105
113
  private source(record: MigrationRecord): ReturnType<typeof migrationPayload> {
106
- const payload = migrationPayload(migrationSource(record.sourceId, this.dir));
114
+ const payload = migrationPayload(migrationSource(record.sourceId, this.dir), record.oauthResource);
107
115
  if (payload.sourceStamp !== record.sourceStamp) throw conflict('The retained local connector changed. Restore its original settings before continuing; Metro will not overwrite it.');
108
116
  return payload;
109
117
  }
@@ -115,8 +123,8 @@ export class ConnectorMigrations {
115
123
  response = migrationResponseOf(await agentCall(this.api, context.key, '/api/agents/connector-migrations', {
116
124
  action, operationId: record.id, sourceId: record.sourceId, ...(sealed === undefined ? {} : { sealed }),
117
125
  }, { timeoutMs: 60_000 }));
118
- } catch {
119
- throw uncertain();
126
+ } catch (error) {
127
+ throw callFailure(error, action);
120
128
  }
121
129
  this.check(context, record);
122
130
  const remote = response.migration;
@@ -132,12 +140,13 @@ export class ConnectorMigrations {
132
140
  return this.save(context, { ...record, destinationId: remote.destinationId, reason: reasons[remote.readiness] });
133
141
  }
134
142
 
135
- private create(context: Context, id: string): MigrationRecord {
136
- const payload = migrationPayload(migrationSource(id, this.dir));
143
+ private create(context: Context, id: string, oauthResource?: string): MigrationRecord {
144
+ const payload = migrationPayload(migrationSource(id, this.dir), oauthResource);
137
145
  return this.save(context, {
138
146
  id: newId(), owner: context.owner, agentId: context.agentId, keyId: context.key.keyId,
139
147
  sourceId: id, name: payload.name, destinationId: null, phase: 'preparing', auth: payload.auth.kind,
140
148
  sourceStamp: payload.sourceStamp, policy: payload.policy, reason: null,
149
+ ...(payload.oauthResource === undefined ? {} : { oauthResource: payload.oauthResource }),
141
150
  });
142
151
  }
143
152
 
@@ -216,10 +225,10 @@ export class ConnectorMigrations {
216
225
  this.accept(context, record, migration);
217
226
  }
218
227
 
219
- private obtain(context: Context, sourceId: string, action: MigrationAction): MigrationRecord | null {
228
+ private obtain(context: Context, sourceId: string, action: MigrationAction, oauthResource?: string): MigrationRecord | null {
220
229
  const record = migrationRecord(sourceId, this.dir);
221
230
  if (record !== null) this.check(context, record);
222
- if (action === 'prepare' && (record === null || record.phase === 'rolled_back')) return this.create(context, sourceId);
231
+ if (action === 'prepare' && (record === null || record.phase === 'rolled_back')) return this.create(context, sourceId, oauthResource);
223
232
  if (record === null && action !== 'reconcile') throw conflict('Prepare this connector before moving it.');
224
233
  return record;
225
234
  }
@@ -238,18 +247,27 @@ export class ConnectorMigrations {
238
247
  this.save(context, { ...record, reason: record.phase === 'prepared' ? 'not_verified' : null });
239
248
  }
240
249
 
250
+ private validateResource(sourceId: string, action: MigrationAction, oauthResource: string | undefined): void {
251
+ if (oauthResource === undefined) return;
252
+ const resource = publicOAuthResource(oauthResource);
253
+ const record = migrationRecord(sourceId, this.dir);
254
+ if (action === 'prepare' && (record === null || record.phase === 'rolled_back')) return;
255
+ if (record?.oauthResource !== resource) throw conflict('The public OAuth resource is fixed for this preparation. Roll back, then prepare again to change it.');
256
+ }
257
+
241
258
  private validateIntent(sourceId: string, action: MigrationAction, operationId: string | null, receipt: string | null): void {
242
259
  if ((migrationRecord(sourceId, this.dir)?.id ?? null) !== operationId) throw conflict('This connector migration changed. Refresh before acting on its current operation.');
243
260
  if (action === 'resolve' && (receipt === null || receipt !== migrationUncertaintyReceipt(sourceId, this.dir))) throw conflict('The outstanding request receipt changed. Refresh before confirming that vendor requests have settled.');
244
261
  }
245
262
 
246
- async run(sourceId: string, action: MigrationAction, scope: string, operationId: string | null, uncertainty: string | null = null): Promise<MigrationStatus> {
263
+ async run(sourceId: string, action: MigrationAction, scope: string, operationId: string | null, uncertainty: string | null = null, oauthResource?: string): Promise<MigrationStatus> {
247
264
  if (this.running) throw conflict('Another migration request is still running on this agent. Refresh after it finishes.');
248
265
  const context = this.context(scope, action === 'prepare' || action === 'cutover');
249
266
  this.validateIntent(sourceId, action, operationId, uncertainty);
267
+ this.validateResource(sourceId, action, oauthResource);
250
268
  this.running = true;
251
269
  try {
252
- const record = this.obtain(context, sourceId, action);
270
+ const record = this.obtain(context, sourceId, action, oauthResource);
253
271
  if (record !== null) {
254
272
  if (action === 'resolve') this.resolve(context, record, uncertainty);
255
273
  else await this.perform(context, record, action);
@@ -5,7 +5,6 @@ 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 { parseConnectorUrl } from '@metro-labs/http/connector-url';
9
8
  import { connectorPolicyOf } from '@metro-labs/http/connector-delivery';
10
9
  import { readConfig } from './config.js';
11
10
  import type { LocalConnectorRow } from './store.js';
@@ -15,7 +14,7 @@ import { connectorSyncScope, syncOptedIn, syncSetting } from './org-sync-setting
15
14
  import { readAgentKey } from './agent-key.js';
16
15
  import { migrationOrigin } from './migration-url.js';
17
16
 
18
- export type MigrationIssue = 'invalid_config' | 'unsupported_url' | 'custom_policy' | 'oauth_sign_in' | 'header_reentry' | 'unused_oauth_client' | 'oauth_private_resource';
17
+ export type MigrationIssue = 'invalid_config' | 'unsupported_url' | 'custom_policy' | 'oauth_sign_in' | 'header_reentry' | 'unused_oauth_client';
19
18
 
20
19
  export interface MigrationConnector {
21
20
  id: string;
@@ -29,6 +28,7 @@ export interface MigrationConnector {
29
28
 
30
29
  export interface ConnectorMigrationPreview {
31
30
  migrationSupported: true;
31
+ oauthResourceSupported: true;
32
32
  enabled: boolean;
33
33
  enrolled: boolean;
34
34
  agentId: string | null;
@@ -53,19 +53,8 @@ function validClient(raw: unknown): boolean {
53
53
  return client !== null && client.clientId === raw.clientId && client.clientSecret === raw.clientSecret;
54
54
  }
55
55
 
56
- function oauthTokensValid(raw: Record<string, unknown>): boolean {
57
- const token = (value: unknown): boolean => typeof value === 'string' && /^[\x21-\x7e]+$/.test(value);
58
- return token(raw.accessToken) && (raw.refreshToken === undefined || token(raw.refreshToken));
59
- }
60
-
61
56
  function oauthAuth(raw: Record<string, unknown>): 'oauth' {
62
57
  if (!onlyKeys(raw, ['kind', 'accessToken', 'refreshToken', 'clientId', 'clientSecret', 'tokenEndpoint', 'issuer', 'scope', 'expiresAt'])) invalid();
63
- if (!oauthTokensValid(raw) || ![raw.clientId, raw.tokenEndpoint, raw.issuer].every(nonempty)) invalid();
64
- if (!optionalStrings(raw, ['refreshToken', 'clientSecret', 'scope'])) invalid();
65
- if (raw.expiresAt !== undefined && (typeof raw.expiresAt !== 'number' || !Number.isFinite(raw.expiresAt) || raw.expiresAt < 0)) invalid();
66
- connectorClient(raw.clientId, raw.clientSecret);
67
- parseConnectorUrl(raw.tokenEndpoint);
68
- parseConnectorUrl(raw.issuer);
69
58
  return 'oauth';
70
59
  }
71
60
 
@@ -156,14 +145,8 @@ function rowIdentity(row: Record<string, unknown>, index: number): { id: string;
156
145
  return { id: id ?? `invalid-${String(index + 1)}`, name: name ?? `Connector ${String(index + 1)}`, valid: id !== null && name !== null };
157
146
  }
158
147
 
159
- function authIssues(row: Record<string, unknown>, auth: MigrationConnector['auth'], origin: string | null): MigrationIssue[] {
160
- if (auth === 'oauth') {
161
- const issues: MigrationIssue[] = ['oauth_sign_in'];
162
- if (origin === null) return issues;
163
- const url = parseConnectorUrl(row.url);
164
- if (![url.origin, `${url.origin}/`, `${url.origin}/mcp`].includes(String(row.url))) issues.push('oauth_private_resource');
165
- return issues;
166
- }
148
+ function authIssues(row: Record<string, unknown>, auth: MigrationConnector['auth']): MigrationIssue[] {
149
+ if (auth === 'oauth') return ['oauth_sign_in'];
167
150
  const client = bodyField(row.config, 'client');
168
151
  return (auth === 'none' || auth === 'header') && client !== undefined && client !== null ? ['unused_oauth_client'] : [];
169
152
  }
@@ -178,7 +161,7 @@ function previewRow(raw: unknown, index: number): MigrationConnector {
178
161
  if (!identity.valid || auth === 'invalid' || !valid) issues.push('invalid_config');
179
162
  if (origin === null) issues.push('unsupported_url');
180
163
  if (valid && !policySupported) issues.push('custom_policy');
181
- issues.push(...authIssues(row, auth, origin));
164
+ issues.push(...authIssues(row, auth));
182
165
  return { id: identity.id, name: identity.name, auth, origin, policy, policySupported, issues };
183
166
  }
184
167
 
@@ -223,14 +206,14 @@ export function migrationSource(id: string, dir = agentsDir()): LocalConnectorRo
223
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);
224
207
  const raw = readRows(dir).find((row) => isRecord(row) && row.id === id);
225
208
  if (!isRecord(raw) || typeof raw.url !== 'string') throw unavailable();
226
- return { id, name: source.name, url: raw.url, config: { ...readConfig(raw.config), policy: source.policy } };
209
+ return { id, name: source.name, url: raw.url, config: { ...readConfig(raw.config), oauth: source.auth === 'oauth', policy: source.policy } };
227
210
  }
228
211
 
229
212
  export function connectorMigrationPreview(owner: string | null, dir = agentsDir()): ConnectorMigrationPreview {
230
213
  const enabled = syncOptedIn(dir) && syncSetting(dir, connectorSyncScope(owner, readAgentKey(dir))).enabled;
231
214
  const liveEnabled = process.env.METRO_CONNECTORS_LIVE_ENABLED === 'true';
232
- if (!enabled) return { migrationSupported: true, enabled, enrolled: false, agentId: null, owner, liveEnabled, connectors: [] };
215
+ if (!enabled) return { migrationSupported: true, oauthResourceSupported: true, enabled, enrolled: false, agentId: null, owner, liveEnabled, connectors: [] };
233
216
  const enrollment = owner === null ? null : readAgentKey(dir)?.enrollment;
234
217
  const agentId = enrollment?.organization === owner ? enrollment?.server ?? null : null;
235
- return { migrationSupported: true, enabled, enrolled: agentId !== null, agentId, owner, liveEnabled, connectors: previewRows(dir) };
218
+ return { migrationSupported: true, oauthResourceSupported: true, enabled, enrolled: agentId !== null, agentId, owner, liveEnabled, connectors: previewRows(dir) };
236
219
  }
@@ -1,15 +1,18 @@
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 { boundOAuthResource } from '@metro-labs/http/connector-public-url';
4
6
  import type { LocalConnectorRow } from './store.js';
5
7
 
6
- export function migrationPayload(row: LocalConnectorRow): MigrationPayload {
8
+ export function migrationPayload(row: LocalConnectorRow, oauthResource?: string): MigrationPayload {
7
9
  const oauth = row.config.oauth || row.config.auth.kind === 'oauth';
8
10
  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 ?? {}) });
11
+ if (!oauth && oauthResource !== undefined) throw new ApiError('Only an OAuth connector accepts a public OAuth resource.', 400);
12
+ return stamped({
13
+ name: row.name, url: row.url, auth, client: row.config.client, policy: connectorPolicyOf(row.config.policy ?? {}),
14
+ ...(oauthResource === undefined ? {} : { oauthResource: boundOAuthResource(row.url, oauthResource) }),
15
+ });
13
16
  }
14
17
 
15
18
  function stamped(value: Omit<MigrationPayload, 'sourceStamp'>): MigrationPayload {
@@ -6,6 +6,7 @@ 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
 
@@ -22,6 +23,7 @@ export interface MigrationRecord {
22
23
  destinationId: string | null;
23
24
  phase: MigrationPhase;
24
25
  auth: MigrationAuth;
26
+ oauthResource?: string;
25
27
  sourceStamp: string;
26
28
  policy: ConnectorPolicy;
27
29
  reason: string | null;
@@ -29,7 +31,7 @@ export interface MigrationRecord {
29
31
 
30
32
  const phases: readonly MigrationPhase[] = ['preparing', 'prepared', 'cutover', 'active', 'rollback', 'rolled_back'];
31
33
  const auths: readonly MigrationAuth[] = ['none', 'header', 'oauth'];
32
- const fields = ['id', 'owner', 'agentId', 'keyId', 'sourceId', 'name', 'destinationId', 'phase', 'auth', 'sourceStamp', 'policy', 'reason'];
34
+ const fields = ['id', 'owner', 'agentId', 'keyId', 'sourceId', 'name', 'destinationId', 'phase', 'auth', 'oauthResource', 'sourceStamp', 'policy', 'reason'];
33
35
  const unavailable = (): ApiError => new ApiError('Protected connector migration records cannot be read. Restore their file or permissions before using connectors.', 503);
34
36
  const DIGEST = /^[A-Za-z0-9_-]{43}$/;
35
37
  const pathOf = (dir: string): string => join(dir, 'connector-migrations.json');
@@ -50,8 +52,9 @@ function recordOf(value: unknown): MigrationRecord {
50
52
  if (!isRecord(value) || Object.keys(value).some((key) => !fields.includes(key))) throw unavailable();
51
53
  const phase = phases.find((item) => item === value.phase);
52
54
  const auth = auths.find((item) => item === value.auth);
53
- if (phase === undefined || auth === undefined) throw unavailable();
55
+ if (phase === undefined || auth === undefined || (value.oauthResource !== undefined && auth !== 'oauth')) throw unavailable();
54
56
  return {
57
+ ...(value.oauthResource === undefined ? {} : { oauthResource: publicOAuthResource(value.oauthResource) }),
55
58
  id: requiredId(value.id), sourceId: requiredId(value.sourceId), agentId: requiredId(value.agentId),
56
59
  owner: field(value.owner, /^org_[A-Za-z0-9]{1,128}$/), keyId: field(value.keyId, DIGEST), sourceStamp: field(value.sourceStamp, DIGEST),
57
60
  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
  }
@@ -388,7 +388,7 @@ export async function localRelayTarget(
388
388
  export function localRollbackMigrationSource(id: string, signOutOAuth: boolean, dir = agentsDir()): void {
389
389
  if (migrationSource(id, dir)?.phase !== 'rollback' || !localMigrationIdle(id, dir)) throw new ConnectorError('The connector migration is not ready to roll back.', 409);
390
390
  const row = rowOrThrow(id, dir);
391
- if (signOutOAuth && row.config.auth.kind === 'oauth') replace(dir, signedOut(row), true);
391
+ if (signOutOAuth) replace(dir, signedOut(row), true);
392
392
  if (signOutOAuth) for (const path of [filePath(dir), dir]) {
393
393
  const fd = openSync(path, 'r');
394
394
  try { fsyncSync(fd); } finally { closeSync(fd); }
@@ -14,6 +14,8 @@
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",
@@ -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
 
@@ -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);
@@ -5,7 +5,7 @@ import { deliveryBytes, deliveryRecord } from './connector-delivery-validation.j
5
5
  import { connectorAuth, connectorClient, connectorName } from './connector-input.js';
6
6
  import type { OAuthClient } from './connector-oauth.js';
7
7
  import { connectorPolicyOf, type ConnectorPolicy } from './connector-policy.js';
8
- import { parseConnectorUrl } from './connector-url.js';
8
+ import { boundOAuthResource, parsePublicConnectorUrl } from './connector-public-url.js';
9
9
  import { openTransfer, sealTransfer, type SealedTransfer } from './transfer-crypto.js';
10
10
  import { isOrganizationId } from './workos-token.js';
11
11
 
@@ -26,6 +26,7 @@ export interface MigrationPayload {
26
26
  url: string;
27
27
  auth: { kind: 'none' } | { kind: 'header'; name: string; value: string } | { kind: 'oauth' };
28
28
  client: OAuthClient | null;
29
+ oauthResource?: string;
29
30
  policy: ConnectorPolicy;
30
31
  sourceStamp: string;
31
32
  }
@@ -97,26 +98,20 @@ function clientOf(raw: unknown): OAuthClient | null {
97
98
  return client;
98
99
  }
99
100
 
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 {
101
+ function migrationClient(auth: MigrationPayload['auth'], input: Record<string, unknown>): OAuthClient | null {
105
102
  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();
103
+ if (auth.kind !== 'oauth' && (client !== null || Object.hasOwn(input, 'oauthResource'))) throw invalid();
108
104
  return client;
109
105
  }
110
106
 
111
107
  export function migrationPayloadOf(raw: unknown): MigrationPayload {
112
- const value = deliveryRecord(raw, ['name', 'url', 'auth', 'client', 'policy', 'sourceStamp']);
108
+ const value = deliveryRecord(raw, ['name', 'url', 'auth', 'client', 'oauthResource', 'policy', 'sourceStamp']);
113
109
  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();
110
+ parsePublicConnectorUrl(value.url);
116
111
  deliveryBytes(value.sourceStamp, 32);
117
112
  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 };
113
+ const client = migrationClient(auth, value);
114
+ 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
115
  if (Buffer.byteLength(JSON.stringify(payload)) > PAYLOAD_MAX) throw invalid();
121
116
  return payload;
122
117
  }
@@ -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.291"
3
3
  }
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.1.0-beta.290",
2
+ "version": "0.1.0-beta.291",
3
3
  "dependencies": {
4
4
  "@anthropic-ai/claude-agent-sdk": "0.3.287",
5
5
  "@modelcontextprotocol/sdk": "^1.29.0",