@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.
- package/package.json +1 -1
- package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/config.ts +2 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-api.ts +10 -5
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-control.ts +129 -36
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-guard.ts +6 -5
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-operations.ts +16 -6
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-preview.ts +29 -28
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-source.ts +25 -6
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-state.ts +24 -4
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-url.ts +2 -25
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/oauth.ts +2 -1
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/store.ts +12 -10
- package/runtime/node_modules/@metro-labs/http/package.json +4 -1
- package/runtime/node_modules/@metro-labs/http/src/connector-delivery-validation.ts +3 -3
- package/runtime/node_modules/@metro-labs/http/src/connector-grant.ts +59 -0
- package/runtime/node_modules/@metro-labs/http/src/connector-input.ts +1 -1
- package/runtime/node_modules/@metro-labs/http/src/connector-mcp.ts +2 -0
- package/runtime/node_modules/@metro-labs/http/src/connector-migration.ts +31 -29
- 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
|
@@ -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
|
|
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 =
|
|
9
|
-
if (
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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 {
|
|
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
|
}
|
|
@@ -102,6 +102,7 @@ function authOf(pendingAuth: PendingAuth, tokens: OAuthTokens): OAuthAuth {
|
|
|
102
102
|
kind: 'oauth',
|
|
103
103
|
issuer: pendingAuth.server.issuer,
|
|
104
104
|
tokenEndpoint: pendingAuth.server.tokenEndpoint,
|
|
105
|
+
resource: pendingAuth.resource,
|
|
105
106
|
clientId: pendingAuth.client.clientId,
|
|
106
107
|
...(pendingAuth.client.clientSecret === undefined
|
|
107
108
|
? {}
|
|
@@ -156,7 +157,7 @@ export async function refreshOAuth(
|
|
|
156
157
|
server,
|
|
157
158
|
client,
|
|
158
159
|
auth.refreshToken,
|
|
159
|
-
resource,
|
|
160
|
+
auth.resource ?? resource,
|
|
160
161
|
auth.scope,
|
|
161
162
|
send,
|
|
162
163
|
);
|
|
@@ -327,8 +327,10 @@ function refreshOnce(row: LocalConnectorRow, auth: OAuthAuth, dir: string): Prom
|
|
|
327
327
|
const running = inflight.get(key);
|
|
328
328
|
if (running !== undefined) return running;
|
|
329
329
|
const job = withLocalMigrationOperation(row.id, dir, async (operation) => {
|
|
330
|
-
const fresh = await refreshOAuth(auth, parseConnectorUrl(row.url).toString(), operation.fetch);
|
|
330
|
+
const fresh = await refreshOAuth(auth, parseConnectorUrl(row.url).toString(), (input, init) => operation.dispatch(() => fetch(input, init), { refresh: true }));
|
|
331
331
|
updateRow(dir, row.id, { auth: fresh }, true);
|
|
332
|
+
flushMigrationSource(dir);
|
|
333
|
+
operation.persistedRefresh();
|
|
332
334
|
return fresh;
|
|
333
335
|
}).finally(() => { inflight.delete(key); });
|
|
334
336
|
inflight.set(key, job);
|
|
@@ -369,11 +371,7 @@ async function oauthTarget(row: LocalConnectorRow, auth: OAuthAuth, force: boole
|
|
|
369
371
|
}
|
|
370
372
|
}
|
|
371
373
|
|
|
372
|
-
export async function localRelayTarget(
|
|
373
|
-
connectorId: string,
|
|
374
|
-
force: boolean,
|
|
375
|
-
dir = agentsDir(),
|
|
376
|
-
): Promise<RelayTarget> {
|
|
374
|
+
export async function localRelayTarget(connectorId: string, force: boolean, dir = agentsDir()): Promise<RelayTarget> {
|
|
377
375
|
return withLocalMigrationOperation(connectorId, dir, async (operation) => {
|
|
378
376
|
const row = readLocalConnectors(dir).find((r) => r.id === connectorId);
|
|
379
377
|
if (row === undefined) return { kind: 'missing' };
|
|
@@ -385,11 +383,15 @@ export async function localRelayTarget(
|
|
|
385
383
|
});
|
|
386
384
|
}
|
|
387
385
|
|
|
388
|
-
export function localRollbackMigrationSource(id: string, signOutOAuth: boolean, dir = agentsDir()): void {
|
|
389
|
-
if (migrationSource(id, dir)?.phase
|
|
386
|
+
export function localRollbackMigrationSource(id: string, signOutOAuth: boolean, dir = agentsDir(), discardUncertain = false): void {
|
|
387
|
+
if (!['rollback', 'recovering'].includes(migrationSource(id, dir)?.phase ?? '') || !localMigrationIdle(id, dir, discardUncertain && signOutOAuth)) throw new ConnectorError('The connector migration is not ready to roll back.', 409);
|
|
390
388
|
const row = rowOrThrow(id, dir);
|
|
391
|
-
if (signOutOAuth
|
|
392
|
-
if (signOutOAuth)
|
|
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 {
|
|
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
|
|
|
@@ -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);
|
|
@@ -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 {
|
|
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:
|
|
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
|
|
82
|
-
const
|
|
83
|
-
if (
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
+
}
|
package/runtime/runtime.json
CHANGED