@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 +1 -1
- package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-api.ts +9 -4
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-control.ts +28 -10
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-preview.ts +8 -25
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-source.ts +8 -5
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-state.ts +5 -2
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-url.ts +2 -25
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/store.ts +1 -1
- package/runtime/node_modules/@metro-labs/http/package.json +2 -0
- package/runtime/node_modules/@metro-labs/http/src/connector-delivery-validation.ts +3 -3
- package/runtime/node_modules/@metro-labs/http/src/connector-input.ts +1 -1
- package/runtime/node_modules/@metro-labs/http/src/connector-migration.ts +8 -13
- package/runtime/node_modules/@metro-labs/http/src/connector-public-url.ts +64 -0
- package/runtime/node_modules/@metro-labs/http/src/connector-resource.ts +108 -0
- package/runtime/runtime.json +1 -1
- package/runtime/sdk-runner/runner.json +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "metro",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.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 {
|
|
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
|
|
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'
|
|
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']
|
|
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
|
|
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 (
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
89
|
-
if (
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
+
}
|
package/runtime/runtime.json
CHANGED