@m6d/cortex-cli 0.2.0 → 0.4.0
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/src/cc/client.ts +58 -90
- package/src/cc/flags.ts +8 -11
- package/src/cc/output.ts +3 -6
- package/src/cc/profiles.ts +8 -11
- package/src/commands/cc.ts +18 -11
- package/src/commands/graph.ts +17 -18
- package/src/commands/new.ts +5 -6
- package/src/commands/swagger.ts +35 -49
- package/src/config/load.ts +6 -6
- package/src/config/validate.ts +2 -2
- package/src/contracts/charts/chart-spec.ts +9 -6
- package/src/contracts/credentials/index.ts +183 -0
- package/src/contracts/egress/index.ts +8 -62
- package/src/contracts/egress/scrub.ts +3 -1
- package/src/contracts/egress/url.ts +109 -0
- package/src/contracts/graph/helpers.ts +8 -10
- package/src/contracts/json/index.ts +21 -0
- package/src/contracts/runtime/api.ts +2 -2
- package/src/contracts/runtime/dataset-sql.ts +49 -70
- package/src/contracts/runtime/datasets.ts +33 -43
- package/src/contracts/runtime/grammar.ts +12 -11
- package/src/contracts/runtime/signature.ts +7 -8
- package/src/contracts/runtime/sync.ts +13 -2
- package/src/contracts/runtime/triggers.ts +4 -1
- package/src/contracts/wire/index.ts +12 -5
- package/src/graph/expand-domains.ts +7 -9
- package/src/graph/generate-cypher.ts +1 -1
- package/src/graph/seed.ts +6 -14
- package/src/scaffold/files.ts +23 -28
- package/src/swagger/extract-endpoints.ts +79 -76
- package/src/swagger/openapi-schema.ts +21 -14
- package/src/swagger/parse-response.ts +1 -1
- package/src/ui/report.ts +1 -1
package/src/config/validate.ts
CHANGED
|
@@ -18,7 +18,7 @@ export function validateConfig(config: Record<string, unknown>, label: string) {
|
|
|
18
18
|
if (!paths.length) return;
|
|
19
19
|
|
|
20
20
|
report.fail({
|
|
21
|
-
what: `${label} has ${paths.length
|
|
21
|
+
what: `${label} has ${report.plural(paths.length, "undefined value")}`,
|
|
22
22
|
blocks: [
|
|
23
23
|
paths.map((entry) => ` ${entry}`).join("\n"),
|
|
24
24
|
"These keys are present but evaluate to undefined — usually a missing\nenvironment variable. Bun loads .env from the directory you run in; check\nthat ./.env exists and is complete.",
|
|
@@ -32,7 +32,7 @@ function findUndefined(value: object, prefix: string, seen: WeakSet<object>): st
|
|
|
32
32
|
if (seen.has(value)) return [];
|
|
33
33
|
seen.add(value);
|
|
34
34
|
|
|
35
|
-
return (Object.entries(value) as [string, unknown][]).flatMap(
|
|
35
|
+
return (Object.entries(value) as [string, unknown][]).flatMap(([key, child]) => {
|
|
36
36
|
const path = prefix ? `${prefix}.${key}` : key;
|
|
37
37
|
if (child === undefined) return [path];
|
|
38
38
|
if (typeof child !== "object" || child === null) return [];
|
|
@@ -155,12 +155,15 @@ function firstViolation(
|
|
|
155
155
|
}
|
|
156
156
|
|
|
157
157
|
const URL_FUNCTION = /url\(([^)]*)\)/gi;
|
|
158
|
-
/**
|
|
159
|
-
|
|
160
|
-
/**
|
|
161
|
-
const
|
|
162
|
-
const
|
|
163
|
-
|
|
158
|
+
/** The CSS functions besides url() that name a resource to load; only url() can point at a fragment, so its argument is checked instead. */
|
|
159
|
+
const OTHER_IMAGE_FUNCTION_NAMES = "src|image|image-set|-webkit-image-set|cross-fade|element";
|
|
160
|
+
/** Every CSS function that names a resource to load. */
|
|
161
|
+
export const IMAGE_FUNCTION = new RegExp(String.raw`\b(url|${OTHER_IMAGE_FUNCTION_NAMES})\(`, "i");
|
|
162
|
+
const OTHER_IMAGE_FUNCTION = new RegExp(String.raw`\b(${OTHER_IMAGE_FUNCTION_NAMES})\(`, "i");
|
|
163
|
+
const OTHER_IMAGE_FUNCTION_GLOBAL = new RegExp(
|
|
164
|
+
String.raw`\b(${OTHER_IMAGE_FUNCTION_NAMES})\([^)]*\)`,
|
|
165
|
+
"gi",
|
|
166
|
+
);
|
|
164
167
|
const CSS_ESCAPE = /\\([0-9a-f]{1,6})\s?|\\(.)/gi;
|
|
165
168
|
|
|
166
169
|
/** Style text with its CSS escapes decoded, so a url() hidden behind one reads as itself. */
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { canCarrySecret } from "../egress/url";
|
|
3
|
+
|
|
4
|
+
export { canCarrySecret };
|
|
5
|
+
|
|
6
|
+
/*
|
|
7
|
+
* A credential is a value Cortex presents to an upstream: one it holds
|
|
8
|
+
* (`static`) or one it acquires (`oauth2-client-credentials`, RFC 6749 §4.4).
|
|
9
|
+
* The definition is the same on both sides; the Control Center stores it,
|
|
10
|
+
* a code config declares it, and each side resolves it to a bearer through
|
|
11
|
+
* the helper and cache below with its own egress-guarded fetch.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export const CREDENTIAL_KINDS = ["static", "oauth2-client-credentials"] as const;
|
|
15
|
+
export type CredentialKind = (typeof CREDENTIAL_KINDS)[number];
|
|
16
|
+
|
|
17
|
+
export const credentialDefinitionSchema = z.discriminatedUnion("kind", [
|
|
18
|
+
z.object({
|
|
19
|
+
kind: z.literal("static"),
|
|
20
|
+
value: z.string().min(1),
|
|
21
|
+
}),
|
|
22
|
+
z.object({
|
|
23
|
+
kind: z.literal("oauth2-client-credentials"),
|
|
24
|
+
/** The token endpoint; a client secret travels over TLS, to loopback, or to a private host while the egress policy is relaxed. */
|
|
25
|
+
tokenUrl: z
|
|
26
|
+
.url()
|
|
27
|
+
.refine(
|
|
28
|
+
(url) => canCarrySecret(url),
|
|
29
|
+
"token URL must be https, or http to loopback (or a private host while the egress policy is relaxed)",
|
|
30
|
+
),
|
|
31
|
+
clientId: z.string().min(1),
|
|
32
|
+
clientSecret: z.string().min(1),
|
|
33
|
+
scope: z.string().min(1).optional(),
|
|
34
|
+
}),
|
|
35
|
+
]);
|
|
36
|
+
export type CredentialDefinition = z.infer<typeof credentialDefinitionSchema>;
|
|
37
|
+
export type OAuth2ClientCredentials = Extract<
|
|
38
|
+
CredentialDefinition,
|
|
39
|
+
{ kind: "oauth2-client-credentials" }
|
|
40
|
+
>;
|
|
41
|
+
|
|
42
|
+
/** One POST to the token endpoint through the caller's guarded fetch; `null` when the transport failed. */
|
|
43
|
+
export type TokenPost = (
|
|
44
|
+
url: string,
|
|
45
|
+
init: { headers: Record<string, string>; body: string },
|
|
46
|
+
) => Promise<{ status: number; body: string } | null>;
|
|
47
|
+
|
|
48
|
+
/** A bearer and when it stops being one; `null` when the issuer did not say. */
|
|
49
|
+
export type AcquiredToken = { token: string; expiresAt: number | null };
|
|
50
|
+
|
|
51
|
+
export type AcquireResult =
|
|
52
|
+
| { ok: true; value: AcquiredToken }
|
|
53
|
+
| {
|
|
54
|
+
ok: false;
|
|
55
|
+
error:
|
|
56
|
+
| "token_url_insecure"
|
|
57
|
+
| "token_request_failed"
|
|
58
|
+
| "token_rejected"
|
|
59
|
+
| "token_response_invalid";
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const tokenResponseSchema = z.object({
|
|
63
|
+
access_token: z.string().min(1),
|
|
64
|
+
token_type: z.string().optional(),
|
|
65
|
+
// Some issuers (Azure AD v1 among them) send the lifetime as a string.
|
|
66
|
+
expires_in: z
|
|
67
|
+
.union([z.number(), z.string().regex(/^\d+$/).transform(Number)])
|
|
68
|
+
.pipe(z.number().nonnegative())
|
|
69
|
+
.optional(),
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The client credentials grant: a form-encoded POST with the client
|
|
74
|
+
* authenticated by `client_secret_basic` (RFC 6749 §2.3.1, each half
|
|
75
|
+
* form-encoded before base64). The endpoint's body never leaves this
|
|
76
|
+
* function: a failure is one of three literals, since the body may echo
|
|
77
|
+
* the client secret or carry the issuer's own error detail.
|
|
78
|
+
*/
|
|
79
|
+
export async function acquireOAuth2ClientCredentials(
|
|
80
|
+
definition: OAuth2ClientCredentials,
|
|
81
|
+
post: TokenPost,
|
|
82
|
+
) {
|
|
83
|
+
// Judged again here, not only when the definition was saved: the policy
|
|
84
|
+
// may have tightened since, and a secret never travels where it may not.
|
|
85
|
+
if (!canCarrySecret(definition.tokenUrl)) {
|
|
86
|
+
return { ok: false, error: "token_url_insecure" } satisfies AcquireResult;
|
|
87
|
+
}
|
|
88
|
+
const basic = Buffer.from(
|
|
89
|
+
`${encodeURIComponent(definition.clientId)}:${encodeURIComponent(definition.clientSecret)}`,
|
|
90
|
+
).toString("base64");
|
|
91
|
+
const answer = await post(definition.tokenUrl, {
|
|
92
|
+
headers: {
|
|
93
|
+
Authorization: `Basic ${basic}`,
|
|
94
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
95
|
+
Accept: "application/json",
|
|
96
|
+
},
|
|
97
|
+
body: new URLSearchParams({
|
|
98
|
+
grant_type: "client_credentials",
|
|
99
|
+
...(definition.scope ? { scope: definition.scope } : {}),
|
|
100
|
+
}).toString(),
|
|
101
|
+
});
|
|
102
|
+
if (!answer) return { ok: false, error: "token_request_failed" } satisfies AcquireResult;
|
|
103
|
+
if (answer.status < 200 || answer.status >= 300) {
|
|
104
|
+
return { ok: false, error: "token_rejected" } satisfies AcquireResult;
|
|
105
|
+
}
|
|
106
|
+
let parsed: z.ZodSafeParseResult<z.infer<typeof tokenResponseSchema>>;
|
|
107
|
+
try {
|
|
108
|
+
parsed = tokenResponseSchema.safeParse(JSON.parse(answer.body));
|
|
109
|
+
} catch {
|
|
110
|
+
return { ok: false, error: "token_response_invalid" } satisfies AcquireResult;
|
|
111
|
+
}
|
|
112
|
+
if (!parsed.success || (parsed.data.token_type ?? "bearer").toLowerCase() !== "bearer") {
|
|
113
|
+
return { ok: false, error: "token_response_invalid" } satisfies AcquireResult;
|
|
114
|
+
}
|
|
115
|
+
const issuedAt = Date.now();
|
|
116
|
+
return {
|
|
117
|
+
ok: true as const,
|
|
118
|
+
value: {
|
|
119
|
+
token: parsed.data.access_token,
|
|
120
|
+
expiresAt:
|
|
121
|
+
parsed.data.expires_in === undefined
|
|
122
|
+
? null
|
|
123
|
+
: issuedAt + parsed.data.expires_in * 1000,
|
|
124
|
+
},
|
|
125
|
+
} satisfies AcquireResult;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** A token is not reused inside this margin of its expiry: the call it is presented on still has to complete. */
|
|
129
|
+
const EXPIRY_MARGIN_MS = 30_000;
|
|
130
|
+
|
|
131
|
+
/** Equal definitions share one key; a rotated secret is another definition and another token. Kept in memory only, like the definitions themselves. */
|
|
132
|
+
function keyOf(definition: CredentialDefinition) {
|
|
133
|
+
return JSON.stringify(definition);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Acquired tokens by definition, reused until their expiry margin. Concurrent
|
|
138
|
+
* acquisitions of one definition share a single request; a token the issuer
|
|
139
|
+
* gave no expiry for is answered but not kept, since nothing says when it
|
|
140
|
+
* stops working. `invalidate` is for the caller that saw a 401: the next
|
|
141
|
+
* resolve acquires again. Pure, so the console's client bundle may import
|
|
142
|
+
* the schemas beside it.
|
|
143
|
+
*/
|
|
144
|
+
export function createCredentialCache(now: () => number = Date.now) {
|
|
145
|
+
const tokens = new Map<string, AcquiredToken>();
|
|
146
|
+
const inFlight = new Map<string, Promise<AcquireResult>>();
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
async get(definition: CredentialDefinition, acquire: () => Promise<AcquireResult>) {
|
|
150
|
+
const key = keyOf(definition);
|
|
151
|
+
const cached = tokens.get(key);
|
|
152
|
+
if (
|
|
153
|
+
cached &&
|
|
154
|
+
cached.expiresAt !== null &&
|
|
155
|
+
cached.expiresAt - EXPIRY_MARGIN_MS > now()
|
|
156
|
+
) {
|
|
157
|
+
return { ok: true, value: cached } satisfies AcquireResult;
|
|
158
|
+
}
|
|
159
|
+
tokens.delete(key);
|
|
160
|
+
const pending = inFlight.get(key);
|
|
161
|
+
if (pending) return pending;
|
|
162
|
+
const acquiring = acquire()
|
|
163
|
+
.then((result) => {
|
|
164
|
+
if (
|
|
165
|
+
result.ok &&
|
|
166
|
+
result.value.expiresAt !== null &&
|
|
167
|
+
result.value.expiresAt - EXPIRY_MARGIN_MS > now()
|
|
168
|
+
) {
|
|
169
|
+
tokens.set(key, result.value);
|
|
170
|
+
}
|
|
171
|
+
return result;
|
|
172
|
+
})
|
|
173
|
+
.finally(() => inFlight.delete(key));
|
|
174
|
+
inFlight.set(key, acquiring);
|
|
175
|
+
return acquiring;
|
|
176
|
+
},
|
|
177
|
+
invalidate(definition: CredentialDefinition) {
|
|
178
|
+
tokens.delete(keyOf(definition));
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export type CredentialCache = ReturnType<typeof createCredentialCache>;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { lookup } from "node:dns/promises";
|
|
2
|
-
import { isIP } from "node:net";
|
|
3
2
|
import type { LookupFunction } from "node:net";
|
|
4
3
|
import { Agent } from "undici";
|
|
4
|
+
import { isEgressRelaxed, isPrivateAddress } from "./url";
|
|
5
5
|
|
|
6
6
|
/*
|
|
7
7
|
* The outbound network policy for URLs a tenant controls (tool endpoints,
|
|
@@ -13,72 +13,18 @@ import { Agent } from "undici";
|
|
|
13
13
|
|
|
14
14
|
export { credentialForms, scrubCredentialStrings } from "./scrub";
|
|
15
15
|
|
|
16
|
-
type Range = { first: number; last: number };
|
|
17
|
-
|
|
18
|
-
/** IPv4 /8 and /16 blocks that never denote a public host, as `[a, b]` octet ranges. */
|
|
19
|
-
const BLOCKED_V4 = [
|
|
20
|
-
{ a: 0 },
|
|
21
|
-
{ a: 10 },
|
|
22
|
-
{ a: 127 },
|
|
23
|
-
{ a: 100, b: { first: 64, last: 127 } },
|
|
24
|
-
{ a: 169, b: { first: 254, last: 254 } },
|
|
25
|
-
{ a: 172, b: { first: 16, last: 31 } },
|
|
26
|
-
{ a: 192, b: { first: 168, last: 168 } },
|
|
27
|
-
{ a: 198, b: { first: 18, last: 19 } },
|
|
28
|
-
] satisfies Array<{ a: number; b?: Range }>;
|
|
29
|
-
|
|
30
|
-
/** Multicast and reserved: everything from 224.0.0.0 up. */
|
|
31
|
-
const BLOCKED_V4_FROM = 224;
|
|
32
|
-
|
|
33
|
-
function isBlockedV4(address: string) {
|
|
34
|
-
const [a = 0, b = 0] = address.split(".").map(Number);
|
|
35
|
-
if (a >= BLOCKED_V4_FROM) return true;
|
|
36
|
-
return BLOCKED_V4.some(
|
|
37
|
-
(block) => block.a === a && (!block.b || (b >= block.b.first && b <= block.b.last)),
|
|
38
|
-
);
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function isBlockedV6(address: string) {
|
|
42
|
-
const normalized = address.toLowerCase();
|
|
43
|
-
if (normalized.startsWith("::ffff:")) {
|
|
44
|
-
const mapped = normalized.slice("::ffff:".length);
|
|
45
|
-
return isIP(mapped) === 4 ? isBlockedV4(mapped) : true;
|
|
46
|
-
}
|
|
47
|
-
return (
|
|
48
|
-
normalized === "::" ||
|
|
49
|
-
normalized === "::1" ||
|
|
50
|
-
normalized.startsWith("fc") ||
|
|
51
|
-
normalized.startsWith("fd") ||
|
|
52
|
-
/^fe[89ab]/.test(normalized)
|
|
53
|
-
);
|
|
54
|
-
}
|
|
55
|
-
|
|
56
16
|
/**
|
|
57
|
-
* Whether an IP address
|
|
17
|
+
* Whether an IP address must not be dialed. `EGRESS_ALLOW_PRIVATE_NETWORK=true` is
|
|
58
18
|
* the development opt-out for localhost and private demo backends; it disables
|
|
59
|
-
* every range check
|
|
19
|
+
* every range check, lets a secret travel over plain http to a private host
|
|
20
|
+
* (`canCarrySecret`), and must never be set in production.
|
|
60
21
|
*/
|
|
61
|
-
|
|
62
|
-
if (
|
|
63
|
-
return
|
|
22
|
+
function isBlockedAddress(address: string) {
|
|
23
|
+
if (isEgressRelaxed()) return false;
|
|
24
|
+
return isPrivateAddress(address);
|
|
64
25
|
}
|
|
65
26
|
|
|
66
|
-
|
|
67
|
-
* Whether a credential may travel to this URL: over TLS anywhere, or over
|
|
68
|
-
* plain http to loopback alone, for a service on the developer's machine.
|
|
69
|
-
*/
|
|
70
|
-
export function isTlsOrLoopback(url: string) {
|
|
71
|
-
try {
|
|
72
|
-
const { protocol, hostname } = new URL(url);
|
|
73
|
-
if (protocol === "https:") return true;
|
|
74
|
-
return (
|
|
75
|
-
protocol === "http:" &&
|
|
76
|
-
(hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]")
|
|
77
|
-
);
|
|
78
|
-
} catch {
|
|
79
|
-
return false;
|
|
80
|
-
}
|
|
81
|
-
}
|
|
27
|
+
export { canCarrySecret, isEgressRelaxed } from "./url";
|
|
82
28
|
|
|
83
29
|
export function egressError() {
|
|
84
30
|
const error: NodeJS.ErrnoException = new Error("egress blocked");
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { isRecord } from "../json";
|
|
2
|
+
|
|
1
3
|
const REDACTION_MARKER = "[REDACTED]";
|
|
2
4
|
|
|
3
5
|
/**
|
|
@@ -49,7 +51,7 @@ export function scrubCredentialStrings(value: unknown, credentials: string[]): u
|
|
|
49
51
|
if (Array.isArray(value)) {
|
|
50
52
|
return value.map((item) => scrubCredentialStrings(item, credentials));
|
|
51
53
|
}
|
|
52
|
-
if (
|
|
54
|
+
if (!isRecord(value)) return value;
|
|
53
55
|
return Object.fromEntries(
|
|
54
56
|
Object.entries(value).map(([name, child]) => [
|
|
55
57
|
scrub(name),
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Where a secret may be sent, judged from the URL alone. Pure and free of
|
|
3
|
+
* node modules, so a browser bundle judges a URL the same way the server does.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
type Range = { first: number; last: number };
|
|
7
|
+
|
|
8
|
+
/** IPv4 /8 and /16 blocks that never denote a public host, as `[a, b]` octet ranges. */
|
|
9
|
+
const BLOCKED_V4 = [
|
|
10
|
+
{ a: 0 },
|
|
11
|
+
{ a: 10 },
|
|
12
|
+
{ a: 127 },
|
|
13
|
+
{ a: 100, b: { first: 64, last: 127 } },
|
|
14
|
+
{ a: 169, b: { first: 254, last: 254 } },
|
|
15
|
+
{ a: 172, b: { first: 16, last: 31 } },
|
|
16
|
+
{ a: 192, b: { first: 168, last: 168 } },
|
|
17
|
+
{ a: 198, b: { first: 18, last: 19 } },
|
|
18
|
+
] satisfies Array<{ a: number; b?: Range }>;
|
|
19
|
+
|
|
20
|
+
/** Multicast and reserved: everything from 224.0.0.0 up. */
|
|
21
|
+
const BLOCKED_V4_FROM = 224;
|
|
22
|
+
|
|
23
|
+
const V4_LITERAL = /^\d{1,3}(\.\d{1,3}){3}$/;
|
|
24
|
+
|
|
25
|
+
function isBlockedV4(address: string) {
|
|
26
|
+
const [a = 0, b = 0] = address.split(".").map(Number);
|
|
27
|
+
if (a >= BLOCKED_V4_FROM) return true;
|
|
28
|
+
return BLOCKED_V4.some(
|
|
29
|
+
(block) => block.a === a && (!block.b || (b >= block.b.first && b <= block.b.last)),
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The IPv4 address inside an IPv4-mapped IPv6 one, which arrives dotted from a
|
|
35
|
+
* DNS answer (`::ffff:8.8.8.8`) and as two hex groups from a parsed URL
|
|
36
|
+
* (`::ffff:808:808`). `null` when the tail is neither.
|
|
37
|
+
*/
|
|
38
|
+
function mappedV4(mapped: string) {
|
|
39
|
+
if (V4_LITERAL.test(mapped)) return mapped;
|
|
40
|
+
const groups = mapped.split(":");
|
|
41
|
+
if (groups.length !== 2 || !groups.every((group) => /^[0-9a-f]{1,4}$/.test(group))) return null;
|
|
42
|
+
const [high = 0, low = 0] = groups.map((group) => parseInt(group, 16));
|
|
43
|
+
return [high >> 8, high & 255, low >> 8, low & 255].join(".");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function isBlockedV6(address: string) {
|
|
47
|
+
const normalized = address.toLowerCase();
|
|
48
|
+
if (normalized.startsWith("::ffff:")) {
|
|
49
|
+
const mapped = mappedV4(normalized.slice("::ffff:".length));
|
|
50
|
+
return mapped === null || isBlockedV4(mapped);
|
|
51
|
+
}
|
|
52
|
+
return (
|
|
53
|
+
normalized === "::" ||
|
|
54
|
+
normalized === "::1" ||
|
|
55
|
+
normalized.startsWith("fc") ||
|
|
56
|
+
normalized.startsWith("fd") ||
|
|
57
|
+
/^fe[89ab]/.test(normalized)
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Whether an IP literal (v4 dotted, or v6 without brackets) lies in a range that never denotes a public host. */
|
|
62
|
+
export function isPrivateAddress(address: string) {
|
|
63
|
+
return address.includes(":") ? isBlockedV6(address) : isBlockedV4(address);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function isLoopback(hostname: string) {
|
|
67
|
+
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* A host on the developer's own network: a private IP literal, a bare compose
|
|
72
|
+
* service name such as `app`, or a `.internal` / `.local` / `.localhost` name
|
|
73
|
+
* such as `host.docker.internal`. Public names and addresses are not.
|
|
74
|
+
*/
|
|
75
|
+
function isPrivateHost(hostname: string) {
|
|
76
|
+
const bare = hostname.replace(/^\[|\]$/g, "");
|
|
77
|
+
if (bare.includes(":") || V4_LITERAL.test(bare)) return isPrivateAddress(bare);
|
|
78
|
+
return !hostname.includes(".") || /\.(internal|local|localhost)$/.test(hostname);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The development opt-out of the egress policy: `EGRESS_ALLOW_PRIVATE_NETWORK=true`
|
|
83
|
+
* lifts every range check and lets a secret travel over plain http within the
|
|
84
|
+
* private network. Read lazily and through `globalThis`, so a browser bundle,
|
|
85
|
+
* which has no `process`, sees it off.
|
|
86
|
+
*/
|
|
87
|
+
export function isEgressRelaxed() {
|
|
88
|
+
return globalThis.process?.env?.EGRESS_ALLOW_PRIVATE_NETWORK === "true";
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Whether a secret may be sent to this URL: over TLS anywhere, or over plain
|
|
93
|
+
* http to loopback, and while the egress policy is relaxed to any private host.
|
|
94
|
+
* Pass `relaxed` explicitly where the policy is known from elsewhere (the
|
|
95
|
+
* browser form reads it from the session) or must stay strict regardless of it
|
|
96
|
+
* (a URL the browser or the CLI dials, not the server).
|
|
97
|
+
*/
|
|
98
|
+
export function canCarrySecret(url: string, relaxed = isEgressRelaxed()) {
|
|
99
|
+
try {
|
|
100
|
+
const { protocol, hostname: raw } = new URL(url);
|
|
101
|
+
if (protocol === "https:") return true;
|
|
102
|
+
if (protocol !== "http:") return false;
|
|
103
|
+
// A trailing dot is the DNS root, the same name: `host.docker.internal.` is `host.docker.internal`.
|
|
104
|
+
const hostname = raw.replace(/\.$/, "");
|
|
105
|
+
return isLoopback(hostname) || (relaxed && isPrivateHost(hostname));
|
|
106
|
+
} catch {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -30,9 +30,7 @@ export function defineDomain(def: Omit<DomainDef, "__brand">) {
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
export function defineEndpoint(input: EndpointInput) {
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
const normalized = input.autoGenerated;
|
|
33
|
+
const generated = input.autoGenerated;
|
|
36
34
|
|
|
37
35
|
return {
|
|
38
36
|
__brand: "endpoint" as const,
|
|
@@ -40,13 +38,13 @@ export function defineEndpoint(input: EndpointInput) {
|
|
|
40
38
|
description: input.description ?? input.name,
|
|
41
39
|
path: input.path,
|
|
42
40
|
method: input.method,
|
|
43
|
-
propertiesDescriptions,
|
|
44
|
-
params:
|
|
45
|
-
body:
|
|
46
|
-
response:
|
|
47
|
-
successStatus:
|
|
48
|
-
errorStatuses:
|
|
49
|
-
responseKind:
|
|
41
|
+
propertiesDescriptions: input.propertiesDescriptions ?? {},
|
|
42
|
+
params: generated ? [...generated.params] : [],
|
|
43
|
+
body: generated ? [...generated.body] : [],
|
|
44
|
+
response: generated ? [...generated.response] : [],
|
|
45
|
+
successStatus: generated?.successStatus ?? 200,
|
|
46
|
+
errorStatuses: generated ? [...generated.errorStatuses] : [],
|
|
47
|
+
responseKind: generated?.responseKind ?? "object",
|
|
50
48
|
queries: input.queries,
|
|
51
49
|
mutates: input.mutates,
|
|
52
50
|
returns: input.returns,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An object that is neither null nor an array: the shape a JSON object parses to, and the
|
|
3
|
+
* guard every walker over an untyped payload (a JSON Schema, an OpenAPI document, a parsed
|
|
4
|
+
* config or cache file) starts from.
|
|
5
|
+
*/
|
|
6
|
+
export function isRecord(value: unknown): value is Record<string, unknown> {
|
|
7
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* One reference token of a JSON Pointer (RFC 6901): `~` is written `~0` and `/` is written
|
|
12
|
+
* `~1`. Escaping replaces `~` first and unescaping `~1` first, so a literal `~1` survives the
|
|
13
|
+
* round trip.
|
|
14
|
+
*/
|
|
15
|
+
export function escapePointerToken(token: string) {
|
|
16
|
+
return token.replaceAll("~", "~0").replaceAll("/", "~1");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function unescapePointerToken(token: string) {
|
|
20
|
+
return token.replaceAll("~1", "/").replaceAll("~0", "~");
|
|
21
|
+
}
|
|
@@ -4,12 +4,12 @@ import {
|
|
|
4
4
|
boundVariablesSchema,
|
|
5
5
|
catalogVersionSchema,
|
|
6
6
|
greetingSchema,
|
|
7
|
+
IDENTIFIER_PATTERN,
|
|
7
8
|
localeSchema,
|
|
8
9
|
localizedTextSchema,
|
|
9
10
|
mentionedToolSchema,
|
|
10
11
|
slugSchema,
|
|
11
12
|
suggestionsSchema,
|
|
12
|
-
TOOL_NAME_PATTERN,
|
|
13
13
|
toolSurfaceSchema,
|
|
14
14
|
variableDeclarationSchema,
|
|
15
15
|
} from "./grammar";
|
|
@@ -106,7 +106,7 @@ export type ToolEmbed = z.infer<typeof toolEmbedSchema>;
|
|
|
106
106
|
|
|
107
107
|
const toolSignatureBaseSchema = z.object({
|
|
108
108
|
toolId: z.uuid(),
|
|
109
|
-
name: z.string().regex(
|
|
109
|
+
name: z.string().regex(IDENTIFIER_PATTERN),
|
|
110
110
|
version: z.number().int().positive(),
|
|
111
111
|
readOnly: z.boolean(),
|
|
112
112
|
/**
|