@stage-labs/metro 0.1.0-beta.286 → 0.1.0-beta.288
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/marketplace/plugin/STAGE.md +11 -0
- package/runtime/marketplace/plugin/bin/policy.mjs +3 -2
- package/runtime/node_modules/@metro-labs/daemon/src/approvals/pending.ts +6 -0
- package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +7 -3
- package/runtime/node_modules/@metro-labs/daemon/src/claude/setup.ts +2 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/agent-key.ts +148 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/api.ts +14 -2
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/config.ts +2 -72
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/enroll-api.ts +101 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/gates.ts +40 -4
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/metro-api.ts +92 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-preview.ts +200 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/migration-url.ts +32 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/oauth-routes.ts +3 -3
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/oauth.ts +4 -7
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-relay.ts +61 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-runtime-discovery.ts +111 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-runtime.ts +246 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync-watch.ts +50 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/org-sync.ts +161 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/plugin-sync.ts +11 -1
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/publication.ts +33 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-id.ts +2 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-session.ts +10 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay-target.ts +4 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/relay.ts +25 -4
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/store.ts +10 -20
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/tools.ts +16 -13
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/transfer-store.ts +3 -2
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/frame-schema.ts +4 -0
- package/runtime/node_modules/@metro-labs/daemon/src/policy/policy.ts +2 -1
- package/runtime/node_modules/@metro-labs/daemon/src/routes/local-mode.ts +12 -3
- package/runtime/node_modules/@metro-labs/daemon/src/routes/session-apis.ts +3 -0
- package/runtime/node_modules/@metro-labs/http/package.json +10 -2
- package/runtime/node_modules/@metro-labs/http/src/agent-signature.ts +110 -0
- package/runtime/node_modules/@metro-labs/http/src/api-http.ts +6 -2
- package/runtime/node_modules/@metro-labs/http/src/connector-delivery-validation.ts +115 -0
- package/runtime/node_modules/@metro-labs/http/src/connector-delivery.ts +54 -0
- package/runtime/node_modules/@metro-labs/{daemon/src/connectors/oauth-discovery.ts → http/src/connector-discovery.ts} +31 -14
- package/runtime/node_modules/@metro-labs/http/src/connector-input.ts +73 -0
- package/runtime/node_modules/@metro-labs/{daemon/src/connectors/verify.ts → http/src/connector-mcp.ts} +16 -16
- package/runtime/node_modules/@metro-labs/{daemon/src/connectors/oauth-client.ts → http/src/connector-oauth.ts} +36 -13
- package/runtime/node_modules/@metro-labs/http/src/connector-policy.ts +53 -0
- package/runtime/node_modules/@metro-labs/{daemon/src/connectors/url.ts → http/src/connector-url.ts} +1 -1
- package/runtime/node_modules/@metro-labs/http/src/ed25519.ts +56 -0
- package/runtime/runtime.json +1 -1
- package/runtime/sdk-runner/runner.json +1 -1
- /package/runtime/node_modules/@metro-labs/{daemon/src/connectors/reach.ts → http/src/connector-reach.ts} +0 -0
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { parseConnectorUrl, refused } from './verify.js';
|
|
2
|
-
import { stringOf } from '@metro-labs/http/api-http';
|
|
3
1
|
import { isRecord } from '@metro-labs/core/is-record';
|
|
2
|
+
import type { FetchLike } from '@metro-labs/core/stations/oauth';
|
|
3
|
+
import { stringOf } from './api-http.js';
|
|
4
|
+
import { parseConnectorUrl, refused } from './connector-url.js';
|
|
4
5
|
|
|
5
6
|
const DISCOVERY_TIMEOUT_MS = 10_000;
|
|
6
7
|
|
|
@@ -18,10 +19,10 @@ interface ResourceMetadata {
|
|
|
18
19
|
scopes: string[];
|
|
19
20
|
}
|
|
20
21
|
|
|
21
|
-
async function getJson(url: URL): Promise<Record<string, unknown> | null> {
|
|
22
|
+
async function getJson(url: URL, fetchImpl: FetchLike): Promise<Record<string, unknown> | null> {
|
|
22
23
|
let res: Response;
|
|
23
24
|
try {
|
|
24
|
-
res = await
|
|
25
|
+
res = await fetchImpl(url.toString(), {
|
|
25
26
|
redirect: 'manual',
|
|
26
27
|
signal: AbortSignal.timeout(DISCOVERY_TIMEOUT_MS),
|
|
27
28
|
headers: { accept: 'application/json' },
|
|
@@ -55,9 +56,9 @@ export function resourceMetadataUrls(resource: URL): URL[] {
|
|
|
55
56
|
return urls;
|
|
56
57
|
}
|
|
57
58
|
|
|
58
|
-
export async function advertisesOAuth(resource: URL): Promise<boolean> {
|
|
59
|
+
export async function advertisesOAuth(resource: URL, fetchImpl: FetchLike = fetch): Promise<boolean> {
|
|
59
60
|
for (const candidate of resourceMetadataUrls(resource)) {
|
|
60
|
-
const body = await getJson(candidate);
|
|
61
|
+
const body = await getJson(candidate, fetchImpl);
|
|
61
62
|
const servers: unknown = body?.authorization_servers;
|
|
62
63
|
const first: unknown = Array.isArray(servers) ? servers[0] : undefined;
|
|
63
64
|
if (typeof first === 'string' && first !== '') return true;
|
|
@@ -76,9 +77,9 @@ function scopesOf(body: Record<string, unknown> | null): string[] {
|
|
|
76
77
|
return out;
|
|
77
78
|
}
|
|
78
79
|
|
|
79
|
-
async function resourceMetadata(resource: URL): Promise<ResourceMetadata> {
|
|
80
|
+
async function resourceMetadata(resource: URL, fetchImpl: FetchLike): Promise<ResourceMetadata> {
|
|
80
81
|
for (const candidate of resourceMetadataUrls(resource)) {
|
|
81
|
-
const body = await getJson(candidate);
|
|
82
|
+
const body = await getJson(candidate, fetchImpl);
|
|
82
83
|
const servers: unknown = body?.authorization_servers;
|
|
83
84
|
const first: unknown = Array.isArray(servers) ? servers[0] : undefined;
|
|
84
85
|
if (typeof first === 'string' && first !== '')
|
|
@@ -87,19 +88,35 @@ async function resourceMetadata(resource: URL): Promise<ResourceMetadata> {
|
|
|
87
88
|
return { issuer: parseConnectorUrl(resource.origin), scopes: [] };
|
|
88
89
|
}
|
|
89
90
|
|
|
91
|
+
const webAddress = (text: string): boolean => {
|
|
92
|
+
try {
|
|
93
|
+
parseConnectorUrl(text);
|
|
94
|
+
return true;
|
|
95
|
+
} catch {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
function endpointOf(raw: unknown, issuer: URL): string {
|
|
101
|
+
const text = stringOf(raw);
|
|
102
|
+
if (text !== '' && !webAddress(text)) throw refused(`${issuer.hostname} advertises a sign-in address Metro will not open.`);
|
|
103
|
+
return text;
|
|
104
|
+
}
|
|
105
|
+
|
|
90
106
|
function toServer(
|
|
91
107
|
body: Record<string, unknown>,
|
|
92
108
|
issuer: URL,
|
|
93
109
|
scopes: string[],
|
|
94
110
|
): OAuthServer {
|
|
95
|
-
const authorizationEndpoint =
|
|
96
|
-
const tokenEndpoint =
|
|
111
|
+
const authorizationEndpoint = endpointOf(body.authorization_endpoint, issuer);
|
|
112
|
+
const tokenEndpoint = endpointOf(body.token_endpoint, issuer);
|
|
97
113
|
if (authorizationEndpoint === '' || tokenEndpoint === '')
|
|
98
114
|
throw refused(
|
|
99
115
|
`${issuer.hostname} advertises OAuth but not where to sign in.`,
|
|
100
116
|
);
|
|
101
117
|
const methods = body.code_challenge_methods_supported;
|
|
102
|
-
const
|
|
118
|
+
const offered = stringOf(body.registration_endpoint);
|
|
119
|
+
const registration = webAddress(offered) ? offered : '';
|
|
103
120
|
return {
|
|
104
121
|
issuer: stringOf(body.issuer) === '' ? issuer.origin : stringOf(body.issuer),
|
|
105
122
|
authorizationEndpoint,
|
|
@@ -121,10 +138,10 @@ export function authServerMetadataUrls(issuer: URL): URL[] {
|
|
|
121
138
|
return [...aware, new URL(`${path}/.well-known/openid-configuration`, issuer.origin), ...urls];
|
|
122
139
|
}
|
|
123
140
|
|
|
124
|
-
export async function discoverOAuth(resource: URL): Promise<OAuthServer> {
|
|
125
|
-
const { issuer, scopes } = await resourceMetadata(resource);
|
|
141
|
+
export async function discoverOAuth(resource: URL, fetchImpl: FetchLike = fetch): Promise<OAuthServer> {
|
|
142
|
+
const { issuer, scopes } = await resourceMetadata(resource, fetchImpl);
|
|
126
143
|
for (const candidate of authServerMetadataUrls(issuer)) {
|
|
127
|
-
const body = await getJson(candidate);
|
|
144
|
+
const body = await getJson(candidate, fetchImpl);
|
|
128
145
|
if (body !== null) return toServer(body, issuer, scopes);
|
|
129
146
|
}
|
|
130
147
|
throw refused(
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { ApiError } from './api-error.js';
|
|
2
|
+
import { stringOf } from './api-http.js';
|
|
3
|
+
import type { ConnectorAuth } from './connector-mcp.js';
|
|
4
|
+
import type { OAuthClient } from './connector-oauth.js';
|
|
5
|
+
|
|
6
|
+
const CONNECTOR_NAME_MAX = 64;
|
|
7
|
+
const HEADER_NAME_RE = /^[A-Za-z0-9!#$%&'*+.^_`|~-]{1,64}$/;
|
|
8
|
+
const HEADER_VALUE_RE = /^[\x20-\x7e]{1,4096}$/;
|
|
9
|
+
const CLIENT_ID_RE = /^[\x21-\x7e]{1,256}$/;
|
|
10
|
+
const CLIENT_SECRET_RE = /^[\x21-\x7e]{1,1024}$/;
|
|
11
|
+
const DEFAULT_HEADER = 'Authorization';
|
|
12
|
+
|
|
13
|
+
export class ConnectorError extends ApiError {}
|
|
14
|
+
|
|
15
|
+
function hasControlChar(value: string): boolean {
|
|
16
|
+
for (const ch of value) {
|
|
17
|
+
const code = ch.codePointAt(0) ?? 0;
|
|
18
|
+
if (code < 0x20 || code === 0x7f) return true;
|
|
19
|
+
}
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function connectorName(raw: unknown): string {
|
|
24
|
+
const name = typeof raw === 'string' ? raw.trim() : '';
|
|
25
|
+
if (name === '') throw new ConnectorError('a connector needs a name', 400);
|
|
26
|
+
if (name.length > CONNECTOR_NAME_MAX)
|
|
27
|
+
throw new ConnectorError(
|
|
28
|
+
`a connector name must be ${String(CONNECTOR_NAME_MAX)} characters or fewer`,
|
|
29
|
+
400,
|
|
30
|
+
);
|
|
31
|
+
if (hasControlChar(name))
|
|
32
|
+
throw new ConnectorError(
|
|
33
|
+
'a connector name must not contain control characters',
|
|
34
|
+
400,
|
|
35
|
+
);
|
|
36
|
+
return name;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function connectorAuth(
|
|
40
|
+
rawHeader: unknown,
|
|
41
|
+
rawValue: unknown,
|
|
42
|
+
): ConnectorAuth {
|
|
43
|
+
const header = stringOf(rawHeader).trim();
|
|
44
|
+
const value = stringOf(rawValue).trim();
|
|
45
|
+
if (header === '' && value === '') return { kind: 'none' };
|
|
46
|
+
if (value === '')
|
|
47
|
+
throw new ConnectorError(
|
|
48
|
+
'that header has no value. Give both a header name and its value, or neither.',
|
|
49
|
+
400,
|
|
50
|
+
);
|
|
51
|
+
const name = header === '' ? DEFAULT_HEADER : header;
|
|
52
|
+
if (!HEADER_NAME_RE.test(name))
|
|
53
|
+
throw new ConnectorError('that is not a valid HTTP header name', 400);
|
|
54
|
+
if (!HEADER_VALUE_RE.test(value))
|
|
55
|
+
throw new ConnectorError('that header value is not sendable', 400);
|
|
56
|
+
return { kind: 'header', name, value };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function connectorClient(
|
|
60
|
+
rawId: unknown,
|
|
61
|
+
rawSecret: unknown,
|
|
62
|
+
): OAuthClient | null {
|
|
63
|
+
const clientId = stringOf(rawId).trim();
|
|
64
|
+
const clientSecret = stringOf(rawSecret).trim();
|
|
65
|
+
if (clientId === '' && clientSecret === '') return null;
|
|
66
|
+
if (clientId === '')
|
|
67
|
+
throw new ConnectorError('a client secret needs the client ID it belongs to', 400);
|
|
68
|
+
if (!CLIENT_ID_RE.test(clientId))
|
|
69
|
+
throw new ConnectorError('that is not a client ID Metro can send', 400);
|
|
70
|
+
if (clientSecret !== '' && !CLIENT_SECRET_RE.test(clientSecret))
|
|
71
|
+
throw new ConnectorError('that client secret is not sendable', 400);
|
|
72
|
+
return { clientId, ...(clientSecret === '' ? {} : { clientSecret }) };
|
|
73
|
+
}
|
|
@@ -1,14 +1,8 @@
|
|
|
1
1
|
import { isRecord } from '@metro-labs/core/is-record';
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
ConnectorUnauthorized,
|
|
7
|
-
ConnectorVerifyError,
|
|
8
|
-
connectorUrlText,
|
|
9
|
-
parseConnectorUrl,
|
|
10
|
-
refused,
|
|
11
|
-
} from './url.js';
|
|
2
|
+
import type { FetchLike } from '@metro-labs/core/stations/oauth';
|
|
3
|
+
import { ApiError } from './api-error.js';
|
|
4
|
+
import { whyUnreachable } from './connector-reach.js';
|
|
5
|
+
import { ConnectorUnauthorized, refused } from './connector-url.js';
|
|
12
6
|
|
|
13
7
|
export interface OAuthTokens {
|
|
14
8
|
accessToken: string;
|
|
@@ -63,10 +57,11 @@ export async function mcpPost(
|
|
|
63
57
|
url: URL,
|
|
64
58
|
auth: ConnectorAuth,
|
|
65
59
|
body: unknown,
|
|
66
|
-
opts: { timeoutMs: number; headers?: Record<string, string
|
|
60
|
+
opts: { timeoutMs: number; headers?: Record<string, string>; fetch?: FetchLike },
|
|
67
61
|
): Promise<Response> {
|
|
62
|
+
const send = opts.fetch ?? fetch;
|
|
68
63
|
try {
|
|
69
|
-
return await
|
|
64
|
+
return await send(url.toString(), {
|
|
70
65
|
method: 'POST',
|
|
71
66
|
redirect: 'manual',
|
|
72
67
|
signal: AbortSignal.timeout(opts.timeoutMs),
|
|
@@ -74,6 +69,7 @@ export async function mcpPost(
|
|
|
74
69
|
body: JSON.stringify(body),
|
|
75
70
|
});
|
|
76
71
|
} catch (err) {
|
|
72
|
+
if (err instanceof ApiError) throw err;
|
|
77
73
|
throw refused(`Metro could not reach ${url.hostname}: ${whyUnreachable(err)}`);
|
|
78
74
|
}
|
|
79
75
|
}
|
|
@@ -93,14 +89,18 @@ function serverNameOf(url: URL, text: string, contentType: string): string {
|
|
|
93
89
|
return typeof info.name === 'string' ? info.name : url.hostname;
|
|
94
90
|
}
|
|
95
91
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
const text = await res.text().catch(() => '');
|
|
99
|
-
if (res.status >= 300 && res.status < 400) throw refused('that url redirects — use the url it redirects to');
|
|
92
|
+
function refuseStatus(res: Response, url: URL, auth: ConnectorAuth): void {
|
|
93
|
+
if (res.status >= 300 && res.status < 400) throw refused('that url redirects. Use the url it redirects to.');
|
|
100
94
|
if (res.status === 401 || res.status === 403)
|
|
101
95
|
throw new ConnectorUnauthorized(auth.kind === 'none' ? `${url.hostname} requires authorization.` : `${url.hostname} rejected that credential.`, 400);
|
|
102
96
|
if (res.status === 404 || res.status === 405)
|
|
103
97
|
throw refused(`${url.hostname} did not accept an MCP initialize over HTTP POST. If it is a legacy SSE server, Metro cannot verify it yet.`);
|
|
104
98
|
if (!res.ok) throw refused(`${url.hostname} answered ${String(res.status)} instead of an MCP initialize.`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export async function verifyRemoteMcp(url: URL, auth: ConnectorAuth, fetchImpl: FetchLike = fetch): Promise<VerifiedServer> {
|
|
102
|
+
const res = await mcpPost(url, auth, INITIALIZE, { timeoutMs: PROBE_TIMEOUT_MS, fetch: fetchImpl });
|
|
103
|
+
const text = await res.text().catch(() => '');
|
|
104
|
+
refuseStatus(res, url, auth);
|
|
105
105
|
return { server: serverNameOf(url, text, res.headers.get('content-type') ?? '') };
|
|
106
106
|
}
|
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
import { createHash, randomBytes } from 'node:crypto';
|
|
2
|
-
import { whyUnreachable } from './reach.js';
|
|
3
|
-
import { refused, type OAuthTokens } from './verify.js';
|
|
4
|
-
import type { OAuthServer } from './oauth-discovery.js';
|
|
5
2
|
import { isRecord } from '@metro-labs/core/is-record';
|
|
3
|
+
import type { FetchLike } from '@metro-labs/core/stations/oauth';
|
|
4
|
+
import { whyUnreachable } from './connector-reach.js';
|
|
5
|
+
import { ConnectorVerifyError, refused } from './connector-url.js';
|
|
6
|
+
import type { OAuthTokens } from './connector-mcp.js';
|
|
7
|
+
import type { OAuthServer } from './connector-discovery.js';
|
|
6
8
|
|
|
7
9
|
const TOKEN_TIMEOUT_MS = 15_000;
|
|
8
10
|
const CLIENT_NAME = 'Metro';
|
|
9
11
|
|
|
12
|
+
export class OAuthInvalidGrant extends ConnectorVerifyError {}
|
|
13
|
+
|
|
10
14
|
export interface OAuthClient {
|
|
11
15
|
clientId: string;
|
|
12
16
|
clientSecret?: string;
|
|
@@ -23,6 +27,7 @@ export function challengeOf(verifier: string): string {
|
|
|
23
27
|
export async function registerClient(
|
|
24
28
|
server: OAuthServer,
|
|
25
29
|
redirectUri: string,
|
|
30
|
+
fetchImpl: FetchLike = fetch,
|
|
26
31
|
): Promise<OAuthClient> {
|
|
27
32
|
if (server.registrationEndpoint === null)
|
|
28
33
|
throw refused(
|
|
@@ -32,7 +37,7 @@ export async function registerClient(
|
|
|
32
37
|
);
|
|
33
38
|
let res: Response;
|
|
34
39
|
try {
|
|
35
|
-
res = await
|
|
40
|
+
res = await fetchImpl(server.registrationEndpoint, {
|
|
36
41
|
method: 'POST',
|
|
37
42
|
redirect: 'manual',
|
|
38
43
|
signal: AbortSignal.timeout(TOKEN_TIMEOUT_MS),
|
|
@@ -81,18 +86,32 @@ export function authorizeUrl(input: AuthorizeInput): string {
|
|
|
81
86
|
return url.toString();
|
|
82
87
|
}
|
|
83
88
|
|
|
89
|
+
function validateOptionalTokens(body: Record<string, unknown>): void {
|
|
90
|
+
const refresh = body.refresh_token;
|
|
91
|
+
if (Object.hasOwn(body, 'refresh_token') && (typeof refresh !== 'string' || !/^[\x21-\x7e]{1,16384}$/.test(refresh))) throw refused('That server returned an invalid refresh token.');
|
|
92
|
+
if (Object.hasOwn(body, 'token_type') && (typeof body.token_type !== 'string' || body.token_type.toLowerCase() !== 'bearer')) throw refused('That server returned an unsupported token type.');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function expiryFrom(body: Record<string, unknown>): number | undefined {
|
|
96
|
+
if (!Object.hasOwn(body, 'expires_in')) return undefined;
|
|
97
|
+
const expires = body.expires_in;
|
|
98
|
+
if (typeof expires !== 'number' || !Number.isFinite(expires) || expires <= 0) throw refused('That server returned an invalid token expiry.');
|
|
99
|
+
const at = Date.now() + expires * 1000;
|
|
100
|
+
if (!Number.isSafeInteger(at)) throw refused('That server returned an invalid token expiry.');
|
|
101
|
+
return at;
|
|
102
|
+
}
|
|
103
|
+
|
|
84
104
|
function tokensFrom(body: Record<string, unknown>): OAuthTokens {
|
|
85
105
|
const access = body.access_token;
|
|
86
|
-
if (typeof access !== 'string' || access
|
|
87
|
-
throw refused('That server did not return
|
|
106
|
+
if (typeof access !== 'string' || !/^[\x21-\x7e]{1,16377}$/.test(access))
|
|
107
|
+
throw refused('That server did not return a sendable access token.');
|
|
108
|
+
validateOptionalTokens(body);
|
|
109
|
+
const expiresAt = expiryFrom(body);
|
|
88
110
|
const refresh = body.refresh_token;
|
|
89
|
-
const expires = body.expires_in;
|
|
90
111
|
return {
|
|
91
112
|
accessToken: access,
|
|
92
113
|
...(typeof refresh === 'string' ? { refreshToken: refresh } : {}),
|
|
93
|
-
...(
|
|
94
|
-
? { expiresAt: Date.now() + expires * 1000 }
|
|
95
|
-
: {}),
|
|
114
|
+
...(expiresAt === undefined ? {} : { expiresAt }),
|
|
96
115
|
};
|
|
97
116
|
}
|
|
98
117
|
|
|
@@ -100,6 +119,7 @@ async function postToken(
|
|
|
100
119
|
server: OAuthServer,
|
|
101
120
|
client: OAuthClient,
|
|
102
121
|
form: Record<string, string>,
|
|
122
|
+
fetchImpl: FetchLike,
|
|
103
123
|
): Promise<OAuthTokens> {
|
|
104
124
|
const params = new URLSearchParams({
|
|
105
125
|
...form,
|
|
@@ -110,7 +130,7 @@ async function postToken(
|
|
|
110
130
|
});
|
|
111
131
|
let res: Response;
|
|
112
132
|
try {
|
|
113
|
-
res = await
|
|
133
|
+
res = await fetchImpl(server.tokenEndpoint, {
|
|
114
134
|
method: 'POST',
|
|
115
135
|
redirect: 'manual',
|
|
116
136
|
signal: AbortSignal.timeout(TOKEN_TIMEOUT_MS),
|
|
@@ -124,6 +144,7 @@ async function postToken(
|
|
|
124
144
|
throw refused(`Metro could not reach that server to finish signing in: ${whyUnreachable(err)}`);
|
|
125
145
|
}
|
|
126
146
|
const body: unknown = await res.json().catch(() => null);
|
|
147
|
+
if (form.grant_type === 'refresh_token' && res.status === 400 && isRecord(body) && body.error === 'invalid_grant') throw new OAuthInvalidGrant('That server requires a new sign-in.', 400);
|
|
127
148
|
if (!res.ok || !isRecord(body))
|
|
128
149
|
throw refused('That server refused to complete the sign-in.');
|
|
129
150
|
return tokensFrom(body);
|
|
@@ -133,6 +154,7 @@ export function exchangeCode(
|
|
|
133
154
|
server: OAuthServer,
|
|
134
155
|
client: OAuthClient,
|
|
135
156
|
input: { code: string; redirectUri: string; verifier: string; resource: string },
|
|
157
|
+
fetchImpl: FetchLike = fetch,
|
|
136
158
|
): Promise<OAuthTokens> {
|
|
137
159
|
return postToken(server, client, {
|
|
138
160
|
grant_type: 'authorization_code',
|
|
@@ -140,7 +162,7 @@ export function exchangeCode(
|
|
|
140
162
|
redirect_uri: input.redirectUri,
|
|
141
163
|
code_verifier: input.verifier,
|
|
142
164
|
resource: input.resource,
|
|
143
|
-
});
|
|
165
|
+
}, fetchImpl);
|
|
144
166
|
}
|
|
145
167
|
|
|
146
168
|
export function refreshTokens(
|
|
@@ -149,11 +171,12 @@ export function refreshTokens(
|
|
|
149
171
|
refreshToken: string,
|
|
150
172
|
resource: string,
|
|
151
173
|
scope?: string,
|
|
174
|
+
fetchImpl: FetchLike = fetch,
|
|
152
175
|
): Promise<OAuthTokens> {
|
|
153
176
|
return postToken(server, client, {
|
|
154
177
|
grant_type: 'refresh_token',
|
|
155
178
|
refresh_token: refreshToken,
|
|
156
179
|
resource,
|
|
157
180
|
...(scope === undefined ? {} : { scope }),
|
|
158
|
-
});
|
|
181
|
+
}, fetchImpl);
|
|
159
182
|
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { isRecord } from '@metro-labs/core/is-record';
|
|
2
|
+
|
|
3
|
+
export type ConnectorDecision = 'allow' | 'ask' | 'deny';
|
|
4
|
+
|
|
5
|
+
export interface ConnectorPolicy {
|
|
6
|
+
read?: ConnectorDecision;
|
|
7
|
+
write?: ConnectorDecision;
|
|
8
|
+
tools?: Record<string, ConnectorDecision>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const RANK: Record<ConnectorDecision, number> = { allow: 0, ask: 1, deny: 2 };
|
|
12
|
+
const TOOL_RE = /^[A-Za-z0-9_.:/-]{1,256}$/;
|
|
13
|
+
|
|
14
|
+
const decision = (value: unknown): value is ConnectorDecision => value === 'allow' || value === 'ask' || value === 'deny';
|
|
15
|
+
|
|
16
|
+
export function connectorPolicyOf(value: unknown): ConnectorPolicy {
|
|
17
|
+
if (!isRecord(value) || Object.keys(value).some((key) => !['read', 'write', 'tools'].includes(key))) throw new Error('Invalid connector policy.');
|
|
18
|
+
const out: ConnectorPolicy = {};
|
|
19
|
+
for (const group of ['read', 'write'] as const) {
|
|
20
|
+
if (!Object.hasOwn(value, group)) continue;
|
|
21
|
+
const field = value[group];
|
|
22
|
+
if (!decision(field)) throw new Error('Invalid connector policy.');
|
|
23
|
+
out[group] = field;
|
|
24
|
+
}
|
|
25
|
+
if (Object.hasOwn(value, 'tools')) out.tools = toolPolicyOf(value.tools);
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function toolPolicyOf(value: unknown): Record<string, ConnectorDecision> {
|
|
30
|
+
if (!isRecord(value) || Object.keys(value).length > 2000) throw new Error('Invalid connector policy.');
|
|
31
|
+
const out: Record<string, ConnectorDecision> = {};
|
|
32
|
+
for (const [name, rule] of Object.entries(value)) {
|
|
33
|
+
if (!TOOL_RE.test(name) || ['__proto__', 'constructor', 'prototype'].includes(name) || !decision(rule)) throw new Error('Invalid connector policy.');
|
|
34
|
+
out[name] = rule;
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const strictest = (...values: (ConnectorDecision | undefined)[]): ConnectorDecision => values.reduce<ConnectorDecision>((held, next) => next !== undefined && RANK[next] > RANK[held] ? next : held, 'allow');
|
|
40
|
+
|
|
41
|
+
export function combineConnectorPolicies(global: ConnectorPolicy, assignment: ConnectorPolicy): ConnectorPolicy {
|
|
42
|
+
const policies = [connectorPolicyOf(global), connectorPolicyOf(assignment)];
|
|
43
|
+
const out: ConnectorPolicy = {
|
|
44
|
+
read: strictest(...policies.map((policy) => policy.read)),
|
|
45
|
+
write: strictest(...policies.map((policy) => policy.write)),
|
|
46
|
+
};
|
|
47
|
+
const names = new Set(policies.flatMap((policy) => Object.keys(policy.tools ?? {})));
|
|
48
|
+
if (names.size > 0) {
|
|
49
|
+
out.tools = {};
|
|
50
|
+
for (const name of names) out.tools[name] = strictest(...policies.flatMap((policy) => [policy.read, policy.write, policy.tools?.[name]]));
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
const P = 2n ** 255n - 19n;
|
|
2
|
+
|
|
3
|
+
interface Point {
|
|
4
|
+
x: bigint;
|
|
5
|
+
y: bigint;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const mod = (a: bigint): bigint => ((a % P) + P) % P;
|
|
9
|
+
|
|
10
|
+
function pow(base: bigint, exp: bigint): bigint {
|
|
11
|
+
let result = 1n;
|
|
12
|
+
let b = mod(base);
|
|
13
|
+
for (let e = exp; e > 0n; e >>= 1n) {
|
|
14
|
+
if ((e & 1n) === 1n) result = (result * b) % P;
|
|
15
|
+
b = (b * b) % P;
|
|
16
|
+
}
|
|
17
|
+
return result;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const inv = (a: bigint): bigint => pow(a, P - 2n);
|
|
21
|
+
const D = mod(-121665n * inv(121666n));
|
|
22
|
+
const SQRT_M1 = pow(2n, (P - 1n) / 4n);
|
|
23
|
+
|
|
24
|
+
function rootOf(u: bigint, v: bigint): bigint | null {
|
|
25
|
+
const x = mod(u * pow(v, 3n) * pow(mod(u * pow(v, 7n)), (P - 5n) / 8n));
|
|
26
|
+
const vx2 = mod(v * x * x);
|
|
27
|
+
if (vx2 === u) return x;
|
|
28
|
+
return vx2 === mod(-u) ? mod(x * SQRT_M1) : null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function decode(bytes: Buffer): Point | null {
|
|
32
|
+
if (bytes.length !== 32) return null;
|
|
33
|
+
const last = bytes[31] ?? 0;
|
|
34
|
+
const sign = BigInt(last >> 7);
|
|
35
|
+
const copy = Buffer.from(bytes);
|
|
36
|
+
copy[31] = last & 0x7f;
|
|
37
|
+
const y = BigInt(`0x${copy.reverse().toString('hex')}`);
|
|
38
|
+
if (y >= P) return null;
|
|
39
|
+
const y2 = (y * y) % P;
|
|
40
|
+
const x = rootOf(mod(y2 - 1n), mod(D * y2 + 1n));
|
|
41
|
+
if (x === null || (x === 0n && sign === 1n)) return null;
|
|
42
|
+
return { x: (x & 1n) === sign ? x : mod(-x), y };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function add(a: Point, b: Point): Point {
|
|
46
|
+
const t = mod(D * a.x * b.x * a.y * b.y);
|
|
47
|
+
return { x: mod((a.x * b.y + a.y * b.x) * inv(mod(1n + t))), y: mod((a.y * b.y + a.x * b.x) * inv(mod(1n - t))) };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function strongEd25519(raw: Buffer): boolean {
|
|
51
|
+
const point = decode(raw);
|
|
52
|
+
if (point === null) return false;
|
|
53
|
+
let eight = point;
|
|
54
|
+
for (let i = 0; i < 3; i += 1) eight = add(eight, eight);
|
|
55
|
+
return !(eight.x === 0n && eight.y === 1n);
|
|
56
|
+
}
|
package/runtime/runtime.json
CHANGED
|
File without changes
|